Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions app/components/ui/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

import type { ReactNode } from 'react';
import { Drawer as BaseDrawer } from '@base-ui/react/drawer';

import { cn } from './cn';
import { CloseIcon } from './icons';
import { textVariantClasses } from './Text';

type DrawerProps = {
open: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
// Rendered in a pinned footer row (e.g. Cancel / Confirm actions).
footer?: ReactNode;
// Widen/narrow the panel; defaults to a form-width sheet (narrower than Modal).
className?: string;
};

// Right-side drawer on Base UI Drawer: swipe-to-dismiss, focus trap, Escape,
// and document scroll lock come from the library. Chrome matches Modal so
// account/create flows can swap one for the other without a visual rewrite.
export function Drawer({ open, onClose, title, children, footer, className }: DrawerProps) {
return (
<BaseDrawer.Root
open={open}
swipeDirection="right"
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
>
<BaseDrawer.Portal>
<BaseDrawer.Backdrop className="[--backdrop-opacity:0.4] dark:[--backdrop-opacity:0.6] fixed inset-0 z-[120] min-h-dvh bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] backdrop-blur-[2px] transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[swiping]:duration-0 data-[ending-style]:duration-[calc(var(--drawer-swipe-strength)*400ms)] ios:absolute" />
<BaseDrawer.Viewport className="fixed inset-0 z-[120] flex items-stretch justify-end">
<BaseDrawer.Popup
className={cn(
'flex h-full w-full max-w-xl flex-col overflow-hidden bg-background text-foreground shadow-xl outline-none ring-1 ring-black/[0.06] [transform:translateX(var(--drawer-swipe-movement-x))] transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:[transform:translateX(100%)] data-[starting-style]:[transform:translateX(100%)] data-[swiping]:select-none data-[ending-style]:duration-[calc(var(--drawer-swipe-strength)*500ms)] dark:bg-[#141414] dark:text-white dark:ring-white/10 motion-reduce:transition-none',
className,
)}
>
<div className="flex items-center justify-between gap-4 border-b border-bds-gray-10 px-5 pb-3 pt-4 dark:border-white/10">
<BaseDrawer.Title className={cn(textVariantClasses.headline, 'm-0 text-foreground')}>
{title}
</BaseDrawer.Title>
<div className="flex shrink-0 items-center gap-2">
<BaseDrawer.Close
aria-label="Close"
className="-mr-1.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-bds-gray-60 transition-colors hover:bg-bds-gray-10 hover:text-foreground dark:text-bds-gray-40 dark:hover:bg-white/10 dark:hover:text-white"
>
<CloseIcon size={14} />
</BaseDrawer.Close>
</div>
</div>

<BaseDrawer.Content className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-5 pb-5 pt-5">
{children}
</BaseDrawer.Content>

{footer ? (
<div className="flex items-center justify-end gap-3 border-t border-bds-gray-10 px-5 py-4 dark:border-white/10">
{footer}
</div>
) : null}
</BaseDrawer.Popup>
</BaseDrawer.Viewport>
</BaseDrawer.Portal>
</BaseDrawer.Root>
);
}
4 changes: 2 additions & 2 deletions app/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ export function Modal({ open, onClose, title, children, headerAction, footer, cl
}}
>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 z-[120] min-h-dvh bg-black/40 backdrop-blur-[2px] transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 dark:bg-black/60 supports-[-webkit-touch-callout:none]:absolute" />
<Dialog.Backdrop className="fixed inset-0 z-[120] min-h-dvh bg-black/40 backdrop-blur-[2px] transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 dark:bg-black/60 ios:absolute" />
<Dialog.Popup
className={cn(
'fixed left-1/2 top-1/2 z-[120] flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-bds-gray-10 bg-background text-foreground shadow-xl outline-none transition-[opacity,transform] duration-150 data-[ending-style]:scale-[0.96] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0 dark:border-white/10 dark:bg-[#141414] dark:text-white motion-reduce:transition-none',
'fixed left-1/2 top-1/2 z-[120] flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl bg-background text-foreground shadow-xl outline-none ring-1 ring-black/[0.06] transition-[opacity,transform] duration-150 data-[ending-style]:scale-[0.96] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0 dark:bg-[#141414] dark:text-white dark:ring-white/10 motion-reduce:transition-none',
className,
)}
>
Expand Down
5 changes: 5 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,11 @@ body {
}

body {
/* Containing block for iOS 26+ absolute dialog/drawer backdrops. Without
this, `position: absolute; inset: 0` still sizes to the visual viewport
and leaves gaps under the browser chrome.
https://base-ui.com/react/overview/quick-start#ios-26-safari */
position: relative;
font-family:
var(--font-google-sans-flex),
-apple-system,
Expand Down
2 changes: 1 addition & 1 deletion app/vibenet/demos/_components/AccountDemoShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function AccountDemoShell({
}: AccountDemoShellProps) {
const engine = useAccountEngine();
const [topbarSlot, setTopbarSlot] = useState<HTMLElement | null>(null);
// The switcher and the empty-state gate both open the create-account modal.
// The switcher and the empty-state gate both open the create-account drawer.
const [createOpen, setCreateOpen] = useState(false);
const onCreate = () => setCreateOpen(true);
useEffect(() => {
Expand Down
10 changes: 5 additions & 5 deletions app/vibenet/demos/account/components/CreateAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

// The account-creation modal. "Account Type" is the top-level choice:
// The account-creation drawer. "Account Type" is the top-level choice:
// Default — one-click EOA off your first unused key (mints one if none)
// Passkey — one-click smart account owned by your first unused passkey
// Advanced — hand-pick smart/EOA + initial keys + salt
Expand All @@ -15,7 +15,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Button } from '../../../../components/ui/Button';
import { cn } from '../../../../components/ui/cn';
import { Text } from '../../../../components/ui/Text';
import { Modal } from '../../../../components/ui/Modal';
import { Drawer } from '../../../../components/ui/Drawer';
import { KIND_LABEL, short, signerIdentity, type CreateMode, type WalletSigner } from '../shared';
import { CheckIcon, KindBadge, TrashIcon } from '../../_shared/primitives';
import { actorPairs, normalizeSalt, randomHex32, sortActors, toStoredActor } from '../library/derive';
Expand Down Expand Up @@ -55,7 +55,7 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) {
const [modalIds, setModalIds] = useState<string[]>([]);
const [modalEoaId, setModalEoaId] = useState<string | null>(null);

// Reset to the one-click default each time the modal opens.
// Reset to the one-click default each time the drawer opens.
useEffect(() => {
if (!open) return;
setCreateMode('default');
Expand Down Expand Up @@ -218,7 +218,7 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) {
};

return (
<Modal
<Drawer
open={open}
onClose={onClose}
title="Create Account"
Expand Down Expand Up @@ -383,7 +383,7 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) {
</div>
</>
) : null}
</Modal>
</Drawer>
);
}

Expand Down
67 changes: 29 additions & 38 deletions app/vibenet/demos/b20/B20Demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type { Address, Hex } from 'viem';
import { trackB20Action, trackB20ModuleSelect } from '../../../analytics/events';
import { Button } from '../../../components/ui/Button';
import { Card } from '../../../components/ui/Card';
import { Modal } from '../../../components/ui/Modal';
import { Text } from '../../../components/ui/Text';
import { FeatureCard } from '../../components/FeatureCard';
import { walletErrorMessage } from '../../library/wallet';
Expand Down Expand Up @@ -173,11 +172,11 @@ function B20DemoInner() {
// A policy assignment chosen from the Policies list, pending confirmation in
// the transaction popup.
const [pendingAssign, setPendingAssign] = useState<PendingAssignment | null>(null);
// The scope whose "+ Policy" opened the Create Policy dialog. When set, the
// The scope whose "+ Policy" opened the Create Policy drawer. When set, the
// newly created policy is auto-assigned to this scope once creation lands.
const [pendingCreateScope, setPendingCreateScope] = useState<{ scope: string; label: string } | null>(null);
// True while CreatePolicy runs its own preflight reads / broadcast, before the
// parent-level `busy` is set. Blocks closing the modal so an aborted dialog
// parent-level `busy` is set. Blocks closing the drawer so an aborted dialog
// can't still sign and broadcast a policy transaction.
const [policyPreflight, setPolicyPreflight] = useState(false);

Expand Down Expand Up @@ -632,46 +631,38 @@ function B20DemoInner() {
onFirstPayment={startFirstPayment}
/>

{/* Create Policy modal */}
<Modal
open={openModal === 'createPolicy'}
<CreatePolicy
open={Boolean(token) && openModal === 'createPolicy'}
onClose={() => {
if (policyPreflight) return;
setPendingCreateScope(null);
closeModal();
}}
title="Create Policy"
className="max-w-3xl"
>
{token ? (
<CreatePolicy
wallet={wallet}
recentPolicies={recentPolicies}
addressBook={addressBook}
onSend={send}
onPolicyCreated={(policy) => {
if (wallet) setRecentPolicies(writeRecentPolicy(wallet, policy));
}}
onComplete={(policy) => {
setOpenModal(null);
// Created from a scope's "+ Policy" — carry straight into
// assigning the new policy to that scope.
const target = pendingCreateScope;
setPendingCreateScope(null);
if (target) {
setPendingAssign({
scope: target.scope,
scopeLabel: target.label,
policyId: policy.id,
policyLabel: policy.label || `Policy ${policy.id.toString()}`,
});
}
}}
onBusyChange={setPolicyPreflight}
busy={busy}
/>
) : null}
</Modal>
wallet={wallet}
recentPolicies={recentPolicies}
addressBook={addressBook}
onSend={send}
onPolicyCreated={(policy) => {
if (wallet) setRecentPolicies(writeRecentPolicy(wallet, policy));
}}
onComplete={(policy) => {
setOpenModal(null);
// Created from a scope's "+ Policy" — carry straight into
// assigning the new policy to that scope.
const target = pendingCreateScope;
setPendingCreateScope(null);
if (target) {
setPendingAssign({
scope: target.scope,
scopeLabel: target.label,
policyId: policy.id,
policyLabel: policy.label || `Policy ${policy.id.toString()}`,
});
}
}}
onBusyChange={setPolicyPreflight}
busy={busy}
/>

{/* Assign-policy confirmation (owns the shared transaction dialog) */}
<AssignPolicyModal
Expand Down
37 changes: 30 additions & 7 deletions app/vibenet/demos/b20/components/CreatePolicy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { encodeFunctionData, type Address, type Hex } from 'viem';

import { Button } from '../../../../components/ui/Button';
import { cn } from '../../../../components/ui/cn';
import { Drawer } from '../../../../components/ui/Drawer';
import { Select } from '../../../../components/ui/Select';
import { Text } from '../../../../components/ui/Text';
import { walletErrorMessage } from '../../../library/wallet';
Expand All @@ -30,6 +31,8 @@ import { ErrorNote, Field, Input } from './primitives';
const compositeKinds: CompositePolicyKind[] = ['union', 'intersect'];

export function CreatePolicy({
open,
onClose,
wallet,
recentPolicies,
addressBook,
Expand All @@ -39,13 +42,15 @@ export function CreatePolicy({
onBusyChange,
busy,
}: {
open: boolean;
onClose: () => void;
wallet: Address | null;
recentPolicies: RecentPolicy[];
addressBook: AddressBookEntry[];
onSend: (label: string, to: Address, data: Hex, action: string) => Promise<Hex | null>;
onPolicyCreated: (policy: CreatedPolicy) => void;
onComplete: (policy: CreatedPolicy) => void;
/** Reports the local preflight/broadcast state so the modal can block close. */
/** Reports the local preflight/broadcast state so the parent can block close. */
onBusyChange?: (busy: boolean) => void;
busy: string | null;
}) {
Expand Down Expand Up @@ -135,9 +140,31 @@ export function CreatePolicy({
};
const pending = !!busy || finalizing;
const compositeReady = children.length >= 2 && children.every(Boolean);
const canCreate = Boolean(wallet && label.trim() && (mode !== 'composite' || compositeReady));

return (
<div className="flex flex-col">
<Drawer
open={open}
onClose={onClose}
title="Create Policy"
footer={
<>
<Button variant="secondary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={() => void submit()}
disabled={!canCreate || pending}
className="disabled:cursor-not-allowed disabled:opacity-50"
>
{pending ? 'Creating…' : 'Create Policy'}
</Button>
</>
}
>
<div className="flex flex-col">
<div>
<Field label="Policy name" hint="Saved in this browser so you can recognize the policy later.">
<Input value={label} onChange={(event) => setLabel(event.target.value)} placeholder="KYC allowlist" />
Expand Down Expand Up @@ -245,11 +272,7 @@ export function CreatePolicy({
</>
)}
<ErrorNote message={error} />
<div className="mt-5 flex justify-end">
<Button size="sm" onClick={() => void submit()} disabled={pending || !wallet || !label.trim() || (mode === 'composite' && !compositeReady)}>
{pending ? 'Creating…' : 'Create'}
</Button>
</div>
</div>
</Drawer>
);
}
2 changes: 1 addition & 1 deletion app/vibenet/demos/b20/components/PolicyList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const READ_RETRY_MS = [0, 2_500, 6_000];
// Inline policy assignment: one row per token feature (scope), each with a
// dropdown of the account's named policies. Selecting one hands the choice up to
// the transaction popup flow (it does not assign in place); "+ Policy" opens the
// Create Policy dialog for that scope, then assigns the new policy to it.
// Create Policy drawer for that scope, then assigns the new policy to it.
export function PolicyList({
token,
adminStatus,
Expand Down
9 changes: 8 additions & 1 deletion tailwind.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Config } from 'tailwindcss';
import plugin from 'tailwindcss/plugin';

// BDS palette. The CSS variables (--bds-<family>-<step>) are defined in
// globals.css, so Tailwind color utilities resolve straight onto them. This
Expand Down Expand Up @@ -75,7 +76,13 @@ const config: Config = {
},
},
},
plugins: [],
plugins: [
// iOS/WebKit only. Same @supports gate Base UI uses for iOS 26 backdrop
// positioning (`-webkit-touch-callout` is implemented on iOS Safari/Chrome).
plugin(({ addVariant }) => {
addVariant('ios', '@supports (-webkit-touch-callout: none)');
}),
],
};

export default config;
Loading