From 9e6ba7d07f1ed0589bf0cd2295083df341c5a245 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Tue, 1 Sep 2026 22:15:06 +0200 Subject: [PATCH 01/99] fix(connect): act as the party the kit shows, or refuse - an unset actAs lets the wallet pick its own primary, which may not be that party - execute sends the connected party; a caller's own actAs passes through - execute and signMessage refuse when the session reports no party --- canton-connect/src/hooks/useExecute.test.tsx | 80 +++++++++++++++++++ canton-connect/src/hooks/useExecute.ts | 19 +++-- .../src/hooks/useSignMessage.test.tsx | 28 +++++-- canton-connect/src/hooks/useSignMessage.ts | 2 +- canton-connect/src/hooks/useWalletCall.ts | 27 +++++-- 5 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 canton-connect/src/hooks/useExecute.test.tsx diff --git a/canton-connect/src/hooks/useExecute.test.tsx b/canton-connect/src/hooks/useExecute.test.tsx new file mode 100644 index 00000000..d0abd204 --- /dev/null +++ b/canton-connect/src/hooks/useExecute.test.tsx @@ -0,0 +1,80 @@ +import type { PrepareExecuteAndWaitResult } from '@canton-network/dapp-sdk' +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { useExecute } from '#src/hooks/useExecute' +import { FakeSessionProvider } from '#src/testing/fakeSession' +import type { Party, WalletSdk } from '#src/types' + +const party = { partyId: 'alice::1220ab', networkId: 'canton:local' } + +const executed: PrepareExecuteAndWaitResult = { + tx: { + status: 'executed', + commandId: 'cmd-1', + payload: { updateId: 'update-1', completionOffset: 42 }, + }, +} + +const liveSession = ( + prepareExecuteAndWait: WalletSdk['prepareExecuteAndWait'], + connectedParty: Party | undefined, +) => { + const sdk: Partial = { + prepareExecuteAndWait, + onTxChanged: async () => undefined, + removeOnTxChanged: async () => undefined, + } + + return { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + } +} + +describe('useExecute', () => { + it('fills actAs with the connected party when the caller sets none', async () => { + const prepareExecuteAndWait = vi + .fn() + .mockResolvedValue(executed) + const { result } = renderHook(() => useExecute(), liveSession(prepareExecuteAndWait, party)) + + await act(async () => { + await result.current.execute({ commands: [] }) + }) + + expect(prepareExecuteAndWait).toHaveBeenCalledWith({ commands: [], actAs: [party.partyId] }) + }) + + it("leaves a caller's own actAs alone", async () => { + const prepareExecuteAndWait = vi + .fn() + .mockResolvedValue(executed) + const { result } = renderHook(() => useExecute(), liveSession(prepareExecuteAndWait, party)) + + await act(async () => { + await result.current.execute({ commands: [], actAs: ['bob::1220cd'] }) + }) + + expect(prepareExecuteAndWait).toHaveBeenCalledWith({ commands: [], actAs: ['bob::1220cd'] }) + }) + + it('refuses a submit over a session that reports no party', async () => { + const prepareExecuteAndWait = vi + .fn() + .mockResolvedValue(executed) + const { result } = renderHook(() => useExecute(), liveSession(prepareExecuteAndWait, undefined)) + + await act(async () => { + await expect(result.current.execute({ commands: [] })).rejects.toThrow( + 'wallet reports no usable party', + ) + }) + + expect(prepareExecuteAndWait).not.toHaveBeenCalled() + expect(result.current.error).toBeUndefined() + }) +}) diff --git a/canton-connect/src/hooks/useExecute.ts b/canton-connect/src/hooks/useExecute.ts index d66d5865..65690297 100644 --- a/canton-connect/src/hooks/useExecute.ts +++ b/canton-connect/src/hooks/useExecute.ts @@ -3,13 +3,18 @@ import { useCallback } from 'react' import type { CantonConnectProvider } from '#src/CantonConnectProvider' import { useTxFeed } from '#src/hooks/useTxFeed' import { useWalletCall } from '#src/hooks/useWalletCall' -import type { TxStatusSnapshot } from '#src/types' +import type { Party, TxStatusSnapshot } from '#src/types' /** * Re-exported so callers need no direct `@canton-network/dapp-sdk` dependency for the type. */ export type { PrepareExecuteParams } +// An unset `actAs` lets the wallet pick its own primary, which may not be the party the kit shows. +/** Defaults `actAs` to the connected party, leaving a caller's own `actAs` untouched. */ +const withActAs = (params: PrepareExecuteParams, party: Party): PrepareExecuteParams => + params.actAs === undefined ? { ...params, actAs: [party.partyId] } : params + /** * Return shape of {@link useExecute}. `execute` resolves once the ledger has executed rather than * at submission, and throws when nothing is connected; `lastTx` follows the wallet's own @@ -27,12 +32,12 @@ export interface UseExecuteResult { /** * Submits ledger commands and tracks the transaction in `lastTx`, fed by the SDK's `txChanged` - * event. - * Wagmi: `useWriteContract` + `useWaitForTransactionReceipt`, since `execute` resolves after - * execution rather than at submission. + * event. `actAs` defaults to the party `useParty` reports, so a submit acts as the party the UI + * shows rather than the wallet's own primary. + * Wagmi: `useWriteContract` + `useWaitForTransactionReceipt`, `execute` resolving after execution. * * @throws with no {@link CantonConnectProvider} above it, and from `execute` where nothing is - * connected or the command fails, the failure also landing in `error`. + * connected or no party is reported. A command that fails throws too, and lands in `error`. * * @example * const { execute, lastTx } = useExecute() @@ -48,7 +53,9 @@ export const useExecute = (): UseExecuteResult => { const execute = useCallback( (params: PrepareExecuteParams): Promise => - call((walletSdk) => walletSdk.prepareExecuteAndWait(params)), + call((walletSdk, actingParty) => + walletSdk.prepareExecuteAndWait(withActAs(params, actingParty)), + ), [call], ) diff --git a/canton-connect/src/hooks/useSignMessage.test.tsx b/canton-connect/src/hooks/useSignMessage.test.tsx index 0f31dd9f..31615021 100644 --- a/canton-connect/src/hooks/useSignMessage.test.tsx +++ b/canton-connect/src/hooks/useSignMessage.test.tsx @@ -6,13 +6,13 @@ import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { useSignMessage } from '#src/hooks/useSignMessage' import { FakeSessionProvider } from '#src/testing/fakeSession' -import type { WalletSdk } from '#src/types' +import type { Party, WalletSdk } from '#src/types' const party = { partyId: 'alice::1220ab', networkId: 'canton:local' } -const liveSession = (sdk: Partial) => ({ +const liveSession = (sdk: Partial, connectedParty: Party | undefined) => ({ wrapper: ({ children }: { children: ReactNode }) => ( - + {children} ), @@ -21,7 +21,7 @@ const liveSession = (sdk: Partial) => ({ describe('useSignMessage', () => { it('publishes the signature the wallet answered with', async () => { const signMessage = vi.fn().mockResolvedValue({ signature: 'sig' }) - const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage })) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, party)) await act(async () => { await expect(result.current.signMessage('hello')).resolves.toBe('sig') @@ -36,7 +36,7 @@ describe('useSignMessage', () => { it('captures the wallet refusal and rethrows it', async () => { const refused = new Error('user refused to sign') const signMessage = vi.fn().mockRejectedValue(refused) - const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage })) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, party)) await act(async () => { await expect(result.current.signMessage('hello')).rejects.toBe(refused) @@ -50,7 +50,7 @@ describe('useSignMessage', () => { it('publishes a refusal that arrived as a JSON-RPC object as an Error', async () => { const rpcError = { code: 4001, message: 'user refused to sign' } const signMessage = vi.fn().mockRejectedValue(rpcError) - const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage })) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, party)) await act(async () => { await expect(result.current.signMessage('hello')).rejects.toBeInstanceOf(Error) @@ -67,7 +67,7 @@ describe('useSignMessage', () => { .fn() .mockResolvedValueOnce({ signature: 'sig' }) .mockRejectedValueOnce(refused) - const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage })) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, party)) await act(async () => { await result.current.signMessage('hello') @@ -93,4 +93,18 @@ describe('useSignMessage', () => { expect(result.current.error).toBeUndefined() }) + + it('refuses a signature over a session that reports no party', async () => { + const signMessage = vi.fn().mockResolvedValue({ signature: 'sig' }) + const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }, undefined)) + + await act(async () => { + await expect(result.current.signMessage('hello')).rejects.toThrow( + 'wallet reports no usable party', + ) + }) + + expect(signMessage).not.toHaveBeenCalled() + expect(result.current.error).toBeUndefined() + }) }) diff --git a/canton-connect/src/hooks/useSignMessage.ts b/canton-connect/src/hooks/useSignMessage.ts index 8b13b96e..d686d013 100644 --- a/canton-connect/src/hooks/useSignMessage.ts +++ b/canton-connect/src/hooks/useSignMessage.ts @@ -21,7 +21,7 @@ export interface UseSignMessageResult { * Wagmi: `useSignMessage`, same name and job. * * @throws with no {@link CantonConnectProvider} above it, and from `signMessage` where nothing is - * connected or the wallet rejects, the rejection also landing in `error`. + * connected or no party is reported. A wallet refusal throws too, and lands in `error`. * * @example * const { signMessage } = useSignMessage() diff --git a/canton-connect/src/hooks/useWalletCall.ts b/canton-connect/src/hooks/useWalletCall.ts index 12da5c0d..a6ed5daa 100644 --- a/canton-connect/src/hooks/useWalletCall.ts +++ b/canton-connect/src/hooks/useWalletCall.ts @@ -3,7 +3,7 @@ import { useCallback, useState } from 'react' import { useCantonConnectContext } from '#src/CantonConnectProvider' import { toError } from '#src/connectError' import { toConnectionStatus } from '#src/machine/connectionMachine' -import type { ConnectionStatus, ConnectionSubscription, WalletSdk } from '#src/types' +import type { ConnectionStatus, ConnectionSubscription, Party, WalletSdk } from '#src/types' /** The resting state, hoisted so a hook that never called keeps one identity across renders. */ const IDLE = { isBusy: false, error: undefined } as const @@ -11,7 +11,9 @@ const IDLE = { isBusy: false, error: undefined } as const /** In-flight and last-failure bookkeeping for one wallet call. */ type WalletCallState = { isBusy: boolean; error: Error | undefined } -// The one home of the two guard messages the SDK-calling hooks throw. +/** What a caller hands `call`: the SDK client, and the party the call acts as. */ +type WalletCallRun = (sdk: WalletSdk, party: Party) => Promise + /** Throws when the wallet is disconnected or locked, the guard every SDK-calling hook shares. */ export const assertUsable = (status: ConnectionStatus, isLocked: boolean): void => { if (status !== 'connected') { @@ -23,12 +25,19 @@ export const assertUsable = (status: ConnectionStatus, isLocked: boolean): void } } +/** Throws when the session reports no party, which a connected one can. */ +function assertParty(party: Party | undefined): asserts party is Party { + if (party === undefined) { + throw new Error('wallet reports no usable party - allocate one in the wallet') + } +} + /** * Return shape of {@link useWalletCall}: busy/error state around one call, plus the session * pieces the public hooks assemble into their own results. */ export interface UseWalletCallResult { - call: (run: (sdk: WalletSdk) => Promise) => Promise + call: (run: WalletCallRun) => Promise isBusy: boolean error: Error | undefined reset: () => void @@ -41,26 +50,28 @@ export interface UseWalletCallResult { // The skeleton shared by the SDK-calling hooks: session selectors, the guards, and the // busy/error bookkeeping around one call. Internal; the public hooks shape its pieces. /** - * Selects the session and wraps one SDK call with the connect/lock guard and busy/error - * bookkeeping that `useExecute`, `useSignMessage` and `useLedger` share. + * Selects the session and wraps one SDK call with the connect, lock and party guards plus the + * busy/error bookkeeping `useExecute` and `useSignMessage` share. */ export const useWalletCall = (): UseWalletCallResult => { const { connection } = useCantonConnectContext() const sdk = useSelector(connection, (snapshot) => snapshot.context.sdk) + const party = useSelector(connection, (snapshot) => snapshot.context.party) const status = useSelector(connection, toConnectionStatus) const isLocked = useSelector(connection, (snapshot) => snapshot.hasTag('unauthenticated')) const [state, setState] = useState(IDLE) const call = useCallback( - async (run: (walletSdk: WalletSdk) => Promise): Promise => { + async (run: WalletCallRun): Promise => { assertUsable(status, isLocked) + assertParty(party) setState({ isBusy: true, error: undefined }) try { - const result = await run(sdk) + const result = await run(sdk, party) setState(IDLE) return result } catch (err) { @@ -69,7 +80,7 @@ export const useWalletCall = (): UseWalletCallResult => { throw error } }, - [isLocked, sdk, status], + [isLocked, party, sdk, status], ) const reset = useCallback((): void => setState(IDLE), []) From 6a6c699fd4285ee80ca92193321bc31556ad294a Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:01:51 -0300 Subject: [PATCH 02/99] feat: split the wallet button into two faces and a chooser `ConnectButton` was one component that swapped between a connect trigger and a Zag popover holding the account, with a `mode` prop to pin it to one. A host that wants a dropdown of its own had to take ours or take nothing. `WalletButton/` replaces it with three exports from `/connect`: `ConnectButton` and `DisconnectButton`, each a plain button that renders whatever the session says and calls one method, and `WalletButton`, a chooser that follows `useWalletStatus().isConnected` and hands its props to the face it picks. The session and not the party decides that face, because a locked wallet keeps the session and clears the party. The popover goes with it, and `@zag-js/popover` with the popover: no component here needs a popper any more. `LogoutIcon` and `ChevronDownIcon` were rendered only inside it. --- canton-dappbooster/doc-fixtures.d.ts | 1 + canton-dappbooster/package.json | 1 - .../ConnectButton/AccountPopover.tsx | 81 ------- .../src/components/ConnectButton/Button.tsx | 39 ---- .../ConnectButton/ConnectButton.test.tsx | 208 ------------------ .../src/components/ConnectButton/anatomy.ts | 30 --- .../src/components/ConnectButton/index.tsx | 54 ----- .../WalletButton/ConnectButton.test.tsx | 92 ++++++++ .../components/WalletButton/ConnectButton.tsx | 51 +++++ .../WalletButton/DisconnectButton.test.tsx | 69 ++++++ .../WalletButton/DisconnectButton.tsx | 51 +++++ .../WalletButton/WalletButton.test.tsx | 55 +++++ .../src/components/WalletButton/anatomy.ts | 13 ++ .../components/WalletButton/composeAction.ts | 15 ++ .../src/components/WalletButton/index.tsx | 29 +++ canton-dappbooster/src/connect.ts | 14 +- .../src/icons/ChevronDownIcon.tsx | 8 - canton-dappbooster/src/icons/LogoutIcon.tsx | 9 - canton-dappbooster/src/icons/index.ts | 2 - pnpm-lock.yaml | 28 --- 20 files changed, 385 insertions(+), 465 deletions(-) delete mode 100644 canton-dappbooster/src/components/ConnectButton/AccountPopover.tsx delete mode 100644 canton-dappbooster/src/components/ConnectButton/Button.tsx delete mode 100644 canton-dappbooster/src/components/ConnectButton/ConnectButton.test.tsx delete mode 100644 canton-dappbooster/src/components/ConnectButton/anatomy.ts delete mode 100644 canton-dappbooster/src/components/ConnectButton/index.tsx create mode 100644 canton-dappbooster/src/components/WalletButton/ConnectButton.test.tsx create mode 100644 canton-dappbooster/src/components/WalletButton/ConnectButton.tsx create mode 100644 canton-dappbooster/src/components/WalletButton/DisconnectButton.test.tsx create mode 100644 canton-dappbooster/src/components/WalletButton/DisconnectButton.tsx create mode 100644 canton-dappbooster/src/components/WalletButton/WalletButton.test.tsx create mode 100644 canton-dappbooster/src/components/WalletButton/anatomy.ts create mode 100644 canton-dappbooster/src/components/WalletButton/composeAction.ts create mode 100644 canton-dappbooster/src/components/WalletButton/index.tsx delete mode 100644 canton-dappbooster/src/icons/ChevronDownIcon.tsx delete mode 100644 canton-dappbooster/src/icons/LogoutIcon.tsx diff --git a/canton-dappbooster/doc-fixtures.d.ts b/canton-dappbooster/doc-fixtures.d.ts index 918d1718..74b374e8 100644 --- a/canton-dappbooster/doc-fixtures.d.ts +++ b/canton-dappbooster/doc-fixtures.d.ts @@ -51,6 +51,7 @@ declare const setAmount: (value: string) => void declare const setReceiver: (value: string) => void declare const setSelected: (token: FixtureToken) => void declare const setError: (error: unknown) => void +declare const toggleMenu: () => void /* Consumer-side components and host wiring */ diff --git a/canton-dappbooster/package.json b/canton-dappbooster/package.json index aba6bc42..601aa2ca 100644 --- a/canton-dappbooster/package.json +++ b/canton-dappbooster/package.json @@ -68,7 +68,6 @@ }, "dependencies": { "@zag-js/dialog": "^1.43.0", - "@zag-js/popover": "^1.43.0", "@zag-js/react": "^1.43.0" } } diff --git a/canton-dappbooster/src/components/ConnectButton/AccountPopover.tsx b/canton-dappbooster/src/components/ConnectButton/AccountPopover.tsx deleted file mode 100644 index ccf4cae7..00000000 --- a/canton-dappbooster/src/components/ConnectButton/AccountPopover.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useConnect } from '@bootnodedev/canton-connect' -import * as popover from '@zag-js/popover' -import { mergeProps, normalizeProps, Portal, useMachine } from '@zag-js/react' -import { type ButtonHTMLAttributes, type ReactElement, type ReactNode, useId } from 'react' -import { anatomy, popoverAnatomy } from '#src/components/ConnectButton/anatomy' -import { Identifier } from '#src/components/Identifier' -import { truncateIdentifier } from '#src/components/Identifier/truncate' -import { ChevronDownIcon, LogoutIcon } from '#src/icons' -import { cx } from '#src/utils/cx' -import { SR_ONLY } from '#src/utils/srOnly' - -// The trigger sits in a header row, so the hint cannot keep the whole length a party may give it. -const HINT_LENGTH = 12 - -export type AccountPopoverProps = Omit, 'children'> & { - avatar?: (partyId: string) => ReactNode - partyId: string -} - -/** The face shown with a session: the party trigger and the popover it opens. */ -export const AccountPopover = ({ - avatar, - className, - partyId, - type = 'button', - ...rest -}: AccountPopoverProps): ReactElement => { - const session = useConnect() - const service = useMachine(popover.machine, { - id: useId(), - positioning: { gutter: 4, placement: 'bottom-end' }, - }) - const api = popover.connect(service, normalizeProps) - - const disconnect = (): void => { - api.setOpen(false) - void session.disconnect() - } - - return ( - <> - - {api.open && ( - -
-
- {/* Zag names the dialog only from a rendered title, and this one has nothing to show. */} -

- Account -

- - -
-
-
- )} - - ) -} diff --git a/canton-dappbooster/src/components/ConnectButton/Button.tsx b/canton-dappbooster/src/components/ConnectButton/Button.tsx deleted file mode 100644 index 2dd9eb6c..00000000 --- a/canton-dappbooster/src/components/ConnectButton/Button.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useConnect } from '@bootnodedev/canton-connect' -import type { ButtonHTMLAttributes, MouseEvent, ReactElement } from 'react' -import { anatomy } from '#src/components/ConnectButton/anatomy' -import { cx } from '#src/utils/cx' - -export type ButtonProps = ButtonHTMLAttributes - -/** The face shown with no session: opens the wallet flow. */ -export const Button = ({ - children, - className, - onClick, - type = 'button', - ...rest -}: ButtonProps): ReactElement => { - const session = useConnect() - const pending = session.isConnecting - - const handleClick = (event: MouseEvent): void => { - onClick?.(event) - if (event.defaultPrevented) return - - void session.connect().catch(() => undefined) - } - - return ( - - ) -} diff --git a/canton-dappbooster/src/components/ConnectButton/ConnectButton.test.tsx b/canton-dappbooster/src/components/ConnectButton/ConnectButton.test.tsx deleted file mode 100644 index fc6b1ac9..00000000 --- a/canton-dappbooster/src/components/ConnectButton/ConnectButton.test.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { CantonConnectProvider, createMockAdapter } from '@bootnodedev/canton-connect' -import { createAutoPicker, FakeSessionProvider } from '@bootnodedev/canton-connect/testing' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { ReactElement } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { ConnectButton } from '#src/components/ConnectButton' -import { anatomy, popoverAnatomy } from '#src/components/ConnectButton/anatomy' -import { stubResizeObserver } from '#src/testing/resizeObserver' - -const PARTY = 'nico::1220df946c5b01ad0f2d2b480f1f43b1d1f2e498f5a49c2f0b1cbb46' -const SHORT_PARTY = 'nico::1220df…0b1cbb46' -const NETWORK = 'canton:local' - -// Every state below the connect flow itself, so a markup assertion pays no SDK discovery sleep. -// null is a session naming no party at all, which an explicit undefined could not say here. -const renderInSession = ( - ui: ReactElement, - party: { partyId: string } | null = { partyId: PARTY }, -): ReturnType => - render( - - {ui} - , - ) - -const renderDisconnected = (ui: ReactElement): ReturnType => - render({ui}) - -// The connect flow is the SDK's, so the few tests that drive it drive the real provider. -const renderWithWallet = (ui: ReactElement): ReturnType => - render( - - {ui} - , - ) - -describe('ConnectButton', () => { - it('renders with the root part', () => { - renderDisconnected() - expect(screen.getByTestId('connect-button')).toHaveClass(anatomy.parts.root) - }) - - it('appends a consumer class to the root part', () => { - renderDisconnected() - expect(screen.getByTestId('connect-button')).toHaveClass(anatomy.parts.root, 'extra') - }) - - it('shows the connect face while no session exists', () => { - renderDisconnected() - expect(screen.getByTestId('connect-button')).toHaveAttribute(anatomy.states.mode, 'connect') - }) - - it('renames the connect face while pending, keeping it focusable to announce that', async () => { - renderWithWallet() - fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) - const button = await screen.findByRole('button', { name: 'Connecting…' }) - - expect(button).toHaveAttribute('aria-disabled', 'true') - expect(button).toHaveAttribute(anatomy.states.pending, 'true') - expect(button).toBeEnabled() - }) - - it('keeps a caller-supplied label while pending, that caller owning what it says', async () => { - renderWithWallet(Confirm in your wallet) - const button = screen.getByRole('button', { name: 'Confirm in your wallet' }) - fireEvent.click(button) - - await waitFor(() => expect(button).toHaveAttribute(anatomy.states.pending, 'true')) - expect(button).toHaveAccessibleName('Confirm in your wallet') - }) - - it('runs a consumer handler on the connect face and still connects', async () => { - const onClick = vi.fn() - renderWithWallet(Connect wallet) - fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) - - expect(onClick).toHaveBeenCalledTimes(1) - expect(await screen.findByRole('button', { name: SHORT_PARTY })).toBeInTheDocument() - }) - - it('lets a consumer handler bring its own connect by preventing the default', async () => { - renderWithWallet( - event.preventDefault()}>Connect wallet, - ) - fireEvent.click(screen.getByRole('button', { name: 'Connect wallet' })) - - expect(await screen.findByRole('button', { name: 'Connect wallet' })).toHaveAttribute( - anatomy.states.mode, - 'connect', - ) - }) - - it('renders nothing where the placement wants the account face and there is no session', () => { - renderDisconnected() - expect(screen.queryByRole('button')).not.toBeInTheDocument() - }) - - it('names the account face by its truncated party id once connected', () => { - renderInSession() - expect(screen.getByRole('button', { name: SHORT_PARTY })).toHaveAttribute( - anatomy.states.mode, - 'account', - ) - }) - - it('hands the party id to a consumer avatar, beside the party id', () => { - renderInSession( } />) - - expect(screen.getByRole('button', { name: SHORT_PARTY })).toContainElement( - screen.getByRole('presentation'), - ) - expect(screen.getByRole('presentation')).toHaveAttribute('src', `${PARTY}.svg`) - }) - - it('keeps the connect face where a session names no party', () => { - renderInSession(Connect wallet, null) - expect(screen.getByRole('button', { name: 'Connect wallet' })).toHaveAttribute( - anatomy.states.mode, - 'connect', - ) - }) - - it('keeps the connect face where the wallet names an empty party', () => { - renderInSession(Connect wallet, { partyId: '' }) - expect(screen.getByRole('button', { name: 'Connect wallet' })).toHaveAttribute( - anatomy.states.mode, - 'connect', - ) - }) - - it('renders no popover panel until it is opened, and drops it again on close', async () => { - stubResizeObserver() - renderInSession() - const trigger = screen.getByRole('button', { name: SHORT_PARTY }) - - expect(document.querySelector(`.${popoverAnatomy.parts.content}`)).toBeNull() - - fireEvent.click(trigger) - expect(await screen.findByRole('dialog', { name: 'Account' })).toBeInTheDocument() - - fireEvent.click(trigger) - await waitFor(() => - expect(document.querySelector(`.${popoverAnatomy.parts.content}`)).toBeNull(), - ) - }) - - it('names the popover it opens from the account face', async () => { - // Opening it starts the popper, which watches its reference through a ResizeObserver. - stubResizeObserver() - renderInSession() - fireEvent.click(screen.getByRole('button', { name: SHORT_PARTY })) - expect(await screen.findByRole('dialog', { name: 'Account' })).toBeInTheDocument() - }) - - it('runs a consumer handler on the account face and still opens the popover', async () => { - stubResizeObserver() - const onClick = vi.fn() - renderInSession() - fireEvent.click(screen.getByRole('button', { name: SHORT_PARTY })) - - expect(onClick).toHaveBeenCalledTimes(1) - expect(await screen.findByRole('dialog', { name: 'Account' })).toBeInTheDocument() - }) - - it('marks the party id it places in the popover with its own part', async () => { - stubResizeObserver() - renderInSession() - fireEvent.click(screen.getByRole('button', { name: SHORT_PARTY })) - - expect(await screen.findByRole('dialog', { name: 'Account' })).toContainElement( - document.querySelector(`.${popoverAnatomy.parts.partyId}`), - ) - }) - - it('ends the session from the popover, returning to the connect face', async () => { - stubResizeObserver() - renderInSession(Connect wallet) - fireEvent.click(screen.getByRole('button', { name: SHORT_PARTY })) - fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })) - - expect(await screen.findByRole('button', { name: 'Connect wallet' })).toHaveAttribute( - anatomy.states.mode, - 'connect', - ) - }) - - it('leaves the page where the placement only wants the connect face', () => { - renderInSession( - <> - - - Connect wallet - - , - ) - expect(screen.getByTestId('header')).toHaveAttribute(anatomy.states.mode, 'account') - expect(screen.queryByTestId('hero')).not.toBeInTheDocument() - }) -}) diff --git a/canton-dappbooster/src/components/ConnectButton/anatomy.ts b/canton-dappbooster/src/components/ConnectButton/anatomy.ts deleted file mode 100644 index 1c40ac18..00000000 --- a/canton-dappbooster/src/components/ConnectButton/anatomy.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Pins a {@link ConnectButton} placement to one face, for a screen that only ever wants that one. - * Omit it and the placement follows the session. A pinned placement renders nothing rather than - * the other face, so a header can carry the account face without sprouting a connect button. - * - * @example - * // header: nothing until there is a session - * - * @category Components - */ -export type ConnectButtonMode = 'account' | 'connect' - -export const anatomy = { - parts: { - root: 'cnc-connect-button', - party: 'cnc-connect-button__party', - spinner: 'cnc-connect-button__spinner', - }, - states: { mode: 'data-mode', pending: 'data-pending' }, -} as const - -export const popoverAnatomy = { - parts: { - content: 'cnc-account-popover', - disconnect: 'cnc-account-popover__disconnect', - partyId: 'cnc-account-popover__party-id', - positioner: 'cnc-account-popover__positioner', - title: 'cnc-account-popover__title', - }, -} as const diff --git a/canton-dappbooster/src/components/ConnectButton/index.tsx b/canton-dappbooster/src/components/ConnectButton/index.tsx deleted file mode 100644 index 3c982b3c..00000000 --- a/canton-dappbooster/src/components/ConnectButton/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useParty } from '@bootnodedev/canton-connect' -import type { ButtonHTMLAttributes, ReactElement, ReactNode } from 'react' -import { AccountPopover } from '#src/components/ConnectButton/AccountPopover' -import type { ConnectButtonMode } from '#src/components/ConnectButton/anatomy' -import { Button } from '#src/components/ConnectButton/Button' - -/** - * Props for {@link ConnectButton}. - * - * @example - * } /> - * - * @category Components - */ -export type ConnectButtonProps = ButtonHTMLAttributes & { - mode?: ConnectButtonMode - avatar?: (partyId: string) => ReactNode -} - -/** - * One button that follows the wallet session: a connect trigger with no session, the account - * popover with one, read from `useParty` rather than a prop so nothing can contradict a connect - * already in flight. A `` above it is required, alone among the components - * here. `mode` pins it to one face, which renders nothing rather than the other one; `children` - * replace the whole label, the pending copy included, so word that off `useConnect().isConnecting`. - * Imported from `/connect`, the sub-path that pulls the Canton SDK into a consumer's graph. - * - * @example - * import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' - * - * - * {label} - * - * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/ConnectButton/anatomy.ts) for the part classes and state attributes the theme selects. - * - * @category Components - */ -export const ConnectButton = ({ - avatar, - mode, - ...rest -}: ConnectButtonProps): ReactElement | null => { - const { isConnected, party } = useParty() - const account = party?.partyId - const hasAccount = isConnected && account !== undefined && account !== '' - const showAccount = hasAccount && mode !== 'connect' - const showConnect = !hasAccount && mode !== 'account' - - return showAccount ? ( - - ) : showConnect ? ( - + ) +} diff --git a/canton-dappbooster/src/components/WalletButton/DisconnectButton.test.tsx b/canton-dappbooster/src/components/WalletButton/DisconnectButton.test.tsx new file mode 100644 index 00000000..61a31ac9 --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/DisconnectButton.test.tsx @@ -0,0 +1,69 @@ +import { useParty } from '@bootnodedev/canton-connect' +import { FakeSessionProvider } from '@bootnodedev/canton-connect/testing' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { disconnectAnatomy } from '#src/components/WalletButton/anatomy' +import { DisconnectButton } from '#src/components/WalletButton/DisconnectButton' + +const PARTY = 'nico::1220df946c5b01ad0f2d2b480f1f43b1d1f2e498f5a49c2f0b1cbb46' +const NETWORK = 'canton:local' + +// The button renders whatever the session says, so the session itself is what a disconnect asserts. +const Session = (): ReactElement => { + const { isConnected } = useParty() + return {isConnected ? 'connected' : 'disconnected'} +} + +const renderInSession = (ui: ReactElement): ReturnType => + render( + + {ui} + + , + ) + +describe('DisconnectButton', () => { + it('renders with the root part', () => { + renderInSession() + expect(screen.getByTestId('account-button')).toHaveClass(disconnectAnatomy.parts.root) + }) + + it('appends a consumer class to the root part', () => { + renderInSession() + expect(screen.getByTestId('account-button')).toHaveClass(disconnectAnatomy.parts.root, 'extra') + }) + + it('names itself for the action it carries', () => { + renderInSession() + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + }) + + it('takes a caller label over its own', () => { + renderInSession(Account) + expect(screen.getByRole('button', { name: 'Account' })).toBeInTheDocument() + }) + + it('ends the session', async () => { + renderInSession() + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('disconnected')) + }) + + it('runs a consumer handler and still disconnects', async () => { + const onClick = vi.fn() + renderInSession() + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + expect(onClick).toHaveBeenCalledTimes(1) + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('disconnected')) + }) + + it('lets a consumer handler keep the session by preventing the default', async () => { + renderInSession( event.preventDefault()} />) + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('connected')) + }) +}) diff --git a/canton-dappbooster/src/components/WalletButton/DisconnectButton.tsx b/canton-dappbooster/src/components/WalletButton/DisconnectButton.tsx new file mode 100644 index 00000000..3bd27192 --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/DisconnectButton.tsx @@ -0,0 +1,51 @@ +import { useConnect } from '@bootnodedev/canton-connect' +import type { ButtonHTMLAttributes, ReactElement } from 'react' +import { disconnectAnatomy } from '#src/components/WalletButton/anatomy' +import { composeAction } from '#src/components/WalletButton/composeAction' +import { cx } from '#src/utils/cx' + +/** + * Props for {@link DisconnectButton}. + * + * @category Components + */ +export type DisconnectButtonProps = ButtonHTMLAttributes + +/** + * Disconnect button. Can be customized. + * + * @example + * import { DisconnectButton } from '@bootnodedev/canton-dappbooster/connect' + * + * + * + * @example + * { event.preventDefault(); toggleMenu() }}> + * {label} + * + * + * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/WalletButton/anatomy.ts) for the part classes and state attributes the theme selects. + * + * @category Components + */ +export const DisconnectButton = ({ + children, + className, + onClick, + type = 'button', + ...rest +}: DisconnectButtonProps): ReactElement => { + const session = useConnect() + const handleClick = composeAction(onClick, session.disconnect) + + return ( + + ) +} diff --git a/canton-dappbooster/src/components/WalletButton/WalletButton.test.tsx b/canton-dappbooster/src/components/WalletButton/WalletButton.test.tsx new file mode 100644 index 00000000..cb9d604a --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/WalletButton.test.tsx @@ -0,0 +1,55 @@ +import { FakeSessionProvider } from '@bootnodedev/canton-connect/testing' +import { render, screen } from '@testing-library/react' +import type { ReactElement } from 'react' +import { describe, expect, it } from 'vitest' +import { WalletButton } from '#src/components/WalletButton' +import { connectAnatomy, disconnectAnatomy } from '#src/components/WalletButton/anatomy' + +const PARTY = 'nico::1220df946c5b01ad0f2d2b480f1f43b1d1f2e498f5a49c2f0b1cbb46' +const NETWORK = 'canton:local' + +const renderInSession = (ui: ReactElement, isLocked = false): ReturnType => + render( + + {ui} + , + ) + +describe('WalletButton', () => { + it('shows the connect face with no session', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: 'Connect wallet' })).toHaveClass( + connectAnatomy.parts.root, + ) + }) + + it('shows the disconnect face once a session stands', () => { + renderInSession() + expect(screen.getByRole('button', { name: 'Disconnect' })).toHaveClass( + disconnectAnatomy.parts.root, + ) + }) + + // A lock clears the party but keeps the session, which is what the face has to follow. + it('keeps the disconnect face on a locked session', () => { + renderInSession(, true) + expect(screen.getByRole('button', { name: 'Disconnect' })).toHaveClass( + disconnectAnatomy.parts.root, + ) + }) + + it('passes children to the face it picks', () => { + renderInSession(Account) + expect(screen.getByRole('button', { name: 'Account' })).toHaveClass( + disconnectAnatomy.parts.root, + ) + }) +}) diff --git a/canton-dappbooster/src/components/WalletButton/anatomy.ts b/canton-dappbooster/src/components/WalletButton/anatomy.ts new file mode 100644 index 00000000..42226d6e --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/anatomy.ts @@ -0,0 +1,13 @@ +export const connectAnatomy = { + parts: { + root: 'cnc-connect-button', + spinner: 'cnc-connect-button__spinner', + }, + states: { pending: 'data-pending' }, +} as const + +export const disconnectAnatomy = { + parts: { + root: 'cnc-disconnect-button', + }, +} as const diff --git a/canton-dappbooster/src/components/WalletButton/composeAction.ts b/canton-dappbooster/src/components/WalletButton/composeAction.ts new file mode 100644 index 00000000..7603f85c --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/composeAction.ts @@ -0,0 +1,15 @@ +import type { MouseEvent, MouseEventHandler } from 'react' + +// The consumer's handler runs first and `preventDefault` is how it opts out of the built-in action, +// so passing one never silently drops the behaviour the button exists for. +export const composeAction = + ( + onClick: MouseEventHandler | undefined, + action: () => Promise, + ): MouseEventHandler => + (event: MouseEvent): void => { + onClick?.(event) + if (event.defaultPrevented) return + + void action().catch(() => undefined) + } diff --git a/canton-dappbooster/src/components/WalletButton/index.tsx b/canton-dappbooster/src/components/WalletButton/index.tsx new file mode 100644 index 00000000..98e7057d --- /dev/null +++ b/canton-dappbooster/src/components/WalletButton/index.tsx @@ -0,0 +1,29 @@ +import { useWalletStatus } from '@bootnodedev/canton-connect' +import type { ButtonHTMLAttributes, ReactElement } from 'react' +import { ConnectButton } from '#src/components/WalletButton/ConnectButton' +import { DisconnectButton } from '#src/components/WalletButton/DisconnectButton' + +/** + * Props for {@link WalletButton}. + * + * @category Components + */ +export type WalletButtonProps = ButtonHTMLAttributes + +/** + * Follows the session: {@link ConnectButton} without one, {@link DisconnectButton} with one. + * + * @example + * import { WalletButton } from '@bootnodedev/canton-dappbooster/connect' + * + * + * + * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/WalletButton/anatomy.ts) for the part classes and state attributes the theme selects. + * + * @category Components + */ +export const WalletButton = (props: WalletButtonProps): ReactElement => { + const { isConnected } = useWalletStatus() + + return isConnected ? : +} diff --git a/canton-dappbooster/src/connect.ts b/canton-dappbooster/src/connect.ts index de7a6250..c0089d59 100644 --- a/canton-dappbooster/src/connect.ts +++ b/canton-dappbooster/src/connect.ts @@ -1,12 +1,16 @@ /** - * The `/connect` sub-path. Its one component reads the wallet session, so it is kept off the main + * The `/connect` sub-path. Its components read the wallet session, so they are kept off the main * barrel to keep the Canton SDK out of a consumer's graph unless they ask for it. Merged into Main - * here because a reader browsing components wants it beside the others; the import path is on the - * component itself. + * here because a reader browsing components wants them beside the others; the import path is on the + * components themselves. * * @module * @mergeModuleWith Main */ -export { ConnectButton, type ConnectButtonProps } from '#src/components/ConnectButton' -export type { ConnectButtonMode } from '#src/components/ConnectButton/anatomy' +export { WalletButton, type WalletButtonProps } from '#src/components/WalletButton' +export { ConnectButton, type ConnectButtonProps } from '#src/components/WalletButton/ConnectButton' +export { + DisconnectButton, + type DisconnectButtonProps, +} from '#src/components/WalletButton/DisconnectButton' diff --git a/canton-dappbooster/src/icons/ChevronDownIcon.tsx b/canton-dappbooster/src/icons/ChevronDownIcon.tsx deleted file mode 100644 index ad3ac3b2..00000000 --- a/canton-dappbooster/src/icons/ChevronDownIcon.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { ReactElement } from 'react' -import { Svg } from '#src/icons/Svg' - -export const ChevronDownIcon = (): ReactElement => ( - - - -) diff --git a/canton-dappbooster/src/icons/LogoutIcon.tsx b/canton-dappbooster/src/icons/LogoutIcon.tsx deleted file mode 100644 index 8c0524bb..00000000 --- a/canton-dappbooster/src/icons/LogoutIcon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ReactElement } from 'react' -import { Svg } from '#src/icons/Svg' - -export const LogoutIcon = (): ReactElement => ( - - - - -) diff --git a/canton-dappbooster/src/icons/index.ts b/canton-dappbooster/src/icons/index.ts index 2bc60210..9902cf3a 100644 --- a/canton-dappbooster/src/icons/index.ts +++ b/canton-dappbooster/src/icons/index.ts @@ -1,7 +1,5 @@ export { CheckIcon } from '#src/icons/CheckIcon' -export { ChevronDownIcon } from '#src/icons/ChevronDownIcon' export { CloseIcon } from '#src/icons/CloseIcon' export { CopyIcon } from '#src/icons/CopyIcon' export { ExternalLinkIcon } from '#src/icons/ExternalLinkIcon' -export { LogoutIcon } from '#src/icons/LogoutIcon' export { SearchIcon } from '#src/icons/SearchIcon' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97e6b298..335eb7df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,9 +114,6 @@ importers: '@zag-js/dialog': specifier: ^1.43.0 version: 1.43.3 - '@zag-js/popover': - specifier: ^1.43.0 - version: 1.43.3 '@zag-js/react': specifier: ^1.43.0 version: 1.43.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1879,12 +1876,6 @@ packages: '@zag-js/interact-outside@1.43.3': resolution: {integrity: sha512-qgyAyWELSzFrHUtB6D7IzemI48NjX7E5oIDCXsz/DBPh+ybsK+pKMGsZkduTCK+uEqJcA/w+DAWaDUOxhjqvWA==} - '@zag-js/popover@1.43.3': - resolution: {integrity: sha512-kLQIj+9XaaEvSbviG+r8hI1OJxC4P4JTETNcnHV4w4aE5nR2r/LDP3JoJSEQsAvTR/MMYpZQDL5pdVQj0M/Oqg==} - - '@zag-js/popper@1.43.3': - resolution: {integrity: sha512-99PPQerLylh+ouG16d0Go0UgpLR3rCQvWMmJoO5Ti8+2Ww3aqBNLTqpF0rJuLPe1Ps4BfEt5Lr4dYhL1ixgbRQ==} - '@zag-js/react@1.43.3': resolution: {integrity: sha512-PVfq738OhicFsGOZNqrsBmdOHuh54XnIhkyJ9HSWDpzQcGHq4fuFWIvfmqW/5o/Xkj+JwFQLoIDVX/KHaUjcXA==} peerDependencies: @@ -5994,25 +5985,6 @@ snapshots: '@zag-js/dom-query': 1.43.3 '@zag-js/utils': 1.43.3 - '@zag-js/popover@1.43.3': - dependencies: - '@zag-js/anatomy': 1.43.3 - '@zag-js/aria-hidden': 1.43.3 - '@zag-js/core': 1.43.3 - '@zag-js/dismissable': 1.43.3 - '@zag-js/dom-query': 1.43.3 - '@zag-js/focus-trap': 1.43.3 - '@zag-js/popper': 1.43.3 - '@zag-js/remove-scroll': 1.43.3 - '@zag-js/types': 1.43.3 - '@zag-js/utils': 1.43.3 - - '@zag-js/popper@1.43.3': - dependencies: - '@floating-ui/dom': 1.8.0 - '@zag-js/dom-query': 1.43.3 - '@zag-js/utils': 1.43.3 - '@zag-js/react@1.43.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@zag-js/core': 1.43.3 From 211f51787999c68166b178fb7d102a06aab9a4ea Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:02:25 -0300 Subject: [PATCH 03/99] feat: style the two wallet buttons and drop the popover rules `.cnc-disconnect-button` shares the connect button's rules, since they are the same object with different words. The popover's own block goes with the component, taking the second `z-index` value and the only `[hidden]` rule in the file with it. Two rules the file was already following go into CLAUDE.md while there is a case to point at: hoist a shared trailing part into `:is()` where every entry weighs the same, and never nest with `&`, which hides a class from the regex `check:anatomy` harvests selectors with. --- canton-theme/CLAUDE.md | 48 +++++++++++++++---------- canton-theme/src/default.css | 69 ++++++++---------------------------- 2 files changed, 45 insertions(+), 72 deletions(-) diff --git a/canton-theme/CLAUDE.md b/canton-theme/CLAUDE.md index 4cad9f6f..cf31bdc1 100644 --- a/canton-theme/CLAUDE.md +++ b/canton-theme/CLAUDE.md @@ -102,6 +102,20 @@ exactly the case the attribute exists for. once; a fallback would be a second copy that drifts and that nothing checks. - Select only on parts and states a component actually renders. `anatomy.ts` in [`../canton-dappbooster`](../canton-dappbooster) is the source of truth; never invent a selector. +- Where every entry of a selector list repeats the same trailing part, hoist it into `:is()` rather + than spelling the shared part once per entry. A list whose entries share no suffix + (`.cnc-token-input__token, .cnc-token-select-dialog__favorite`) stays a list, and a list only some + of whose entries share one is left alone: a half-hoist reads as two rules fused rather than one. +- **Hoist only entries of equal specificity.** `:is()` takes the specificity of its most specific + argument and hands it to every branch, so one `.cnc-token-input__token[data-interactive]` in the + list silently raises the six plain classes beside it from `(0,2,0)` to `(0,3,0)` and they start + beating overrides written against them. An attribute-carrying entry stays written out on its own + line beside the `:is()`, which is why the `:focus-visible` rule has two selectors. +- **Never nest with `&`,** however much repetition it would spare. `pnpm check:anatomy` harvests + class names by regex over each rule's own selector text, so a nested `&:disabled` contributes no + class and reports at the block head instead of the rule. Flat selectors are also what keeps + `rg cnc-connect-button` returning every rule that touches it, which is how the two rules above are + audited at all. - Put colour on the root part, never on the inner value part, so a consumer's utility class on the root still wins. - Never declare `font-size` on a primitive that can sit inside a heading, a row, or a table cell. @@ -151,24 +165,22 @@ exactly the case the attribute exists for. rule collapsing it. What announces the change is a separate live region the component hides inline and out of flow; never style it with `display: none`, which drops a live region out of the accessibility tree and silences the announcement it exists for. -- Depth is set twice, on the token select dialog's backdrop and positioner at `100` and the account - popover's positioner at `50`, because those sit above the page instead of in it. Both are - portalled, so document order cannot decide it: a host's own stacking context — a sticky header, - say — otherwise renders over them. The two values are ordered so a dialog covers a popover. - Everything else stacks in document order, and a third value means three components can fight - over depth, so treat adding one as a contract decision. -- **A part Zag marks `hidden` needs its own `[hidden] { display: none }` rule here.** Zag's - `getContentProps` closes a popover by setting the `hidden` attribute and leaves the hiding to CSS, - but `[hidden]` only carries `display: none` in the user-agent stylesheet, which any author `display` - loses to whatever the layer or the specificity. So `.cnc-account-popover`'s own `display: flex` - keeps the closed panel on screen for a consumer whose reset does not re-declare `[hidden]`; ours - only looked right because `dapp/frontend` pulls in Tailwind's preflight. This applies to every - future part whose machine hides it by attribute rather than by unmounting. -- The popover's `z-index` goes on its content, never its positioner. Zag's popper owns the - positioner's inline style and, on every placement, copies the *content's* computed `z-index` onto - it as `--z-index`; a rule on the positioner is overwritten with `auto`, and the popover ends up - behind the header it was opened from. The dialog is the other way round, on the positioner and - the backdrop, because its machine does not use the popper. +- Depth is set once, on the token select dialog's backdrop and positioner at `100`, because those + sit above the page instead of in it. Both are portalled, so document order cannot decide it: a + host's own stacking context — a sticky header, say — otherwise renders over them. Everything else + stacks in document order, and a second value means components can fight over depth, so treat + adding one as a contract decision. +- **A part Zag marks `hidden` needs its own `[hidden] { display: none }` rule here.** Zag closes a + panel by setting the `hidden` attribute and leaves the hiding to CSS, but `[hidden]` only carries + `display: none` in the user-agent stylesheet, which any author `display` loses to whatever the + layer or the specificity. So a part with its own `display` keeps the closed panel on screen for a + consumer whose reset does not re-declare `[hidden]`; ours only looked right because + `dapp/frontend` pulls in Tailwind's preflight. +- The dialog's `z-index` goes on its positioner and its backdrop, which is only safe because its + machine does not use Zag's popper. A popper-positioned part takes it on the *content* instead: the + popper owns the positioner's inline style and copies the content's computed `z-index` onto it as + `--z-index`, so a rule on the positioner is overwritten with `auto`. Nothing here is + popper-positioned today, so that half is for whoever adds the first one. - A `@keyframes` name is global whatever layer declares it, so it carries the `cnc-` prefix like a token does and is public the moment it ships. Its duration comes off the `duration` scale, by `calc()` where no step fits, for the same reason every other distance does. diff --git a/canton-theme/src/default.css b/canton-theme/src/default.css index 7dd37729..0f012aac 100644 --- a/canton-theme/src/default.css +++ b/canton-theme/src/default.css @@ -2,7 +2,6 @@ @layer cnc { /* Button-like parts */ - .cnc-account-popover__disconnect, .cnc-identifier__copy, .cnc-explorer-link, .cnc-token-input__max, @@ -16,13 +15,15 @@ color var(--cnc-duration) ease; } - .cnc-account-popover__disconnect:focus-visible, - .cnc-connect-button:focus-visible, - .cnc-identifier__copy:focus-visible, - .cnc-explorer-link:focus-visible, - .cnc-token-input__max:focus-visible, - .cnc-token-input__token[data-interactive]:focus-visible, - .cnc-token-select-dialog__close:focus-visible { + :is( + .cnc-disconnect-button, + .cnc-connect-button, + .cnc-identifier__copy, + .cnc-explorer-link, + .cnc-token-input__max, + .cnc-token-select-dialog__close + ):focus-visible, + .cnc-token-input__token[data-interactive]:focus-visible { outline: 2px solid var(--cnc-accent); outline-offset: 2px; } @@ -34,8 +35,7 @@ outline-offset: 1px; } - .cnc-party-id-input::placeholder, - .cnc-token-input__field::placeholder { + :is(.cnc-party-id-input, .cnc-token-input__field)::placeholder { color: var(--cnc-text-muted); opacity: 0.7; } @@ -515,7 +515,8 @@ color: var(--cnc-swatch-8-fg); } - /* Connect button */ + /* Wallet buttons */ + .cnc-disconnect-button, .cnc-connect-button { align-items: center; background: var(--cnc-surface); @@ -534,17 +535,17 @@ border-color var(--cnc-duration) ease; } - .cnc-connect-button:hover:not(:disabled, [data-pending]) { + :is(.cnc-disconnect-button, .cnc-connect-button):hover:not(:disabled, [data-pending]) { background: var(--cnc-surface-muted); border-color: var(--cnc-border-strong); } - .cnc-connect-button:active:not(:disabled, [data-pending]) { + :is(.cnc-disconnect-button, .cnc-connect-button):active:not(:disabled, [data-pending]) { background: var(--cnc-accent-subtle); border-color: var(--cnc-accent); } - .cnc-connect-button:disabled, + :is(.cnc-disconnect-button, .cnc-connect-button):disabled, .cnc-connect-button[data-pending] { cursor: not-allowed; opacity: 0.5; @@ -571,44 +572,4 @@ animation: none; } } - - /* Account popover */ - .cnc-account-popover { - background: var(--cnc-surface); - border-radius: var(--cnc-radius); - border: 1px solid var(--cnc-border); - box-shadow: var(--cnc-shadow); - color: var(--cnc-text); - display: flex; - flex-direction: column; - gap: var(--cnc-space); - min-width: 15rem; - padding: var(--cnc-space); - z-index: 50; - } - - .cnc-account-popover[hidden] { - display: none; - } - - .cnc-identifier.cnc-account-popover__party-id { - font-weight: 700; - justify-content: center; - } - - .cnc-account-popover__disconnect { - align-items: center; - background: var(--cnc-surface-muted); - display: flex; - font-size: 0.875rem; - font-weight: 500; - gap: var(--cnc-space-sm); - justify-content: center; - padding: calc(var(--cnc-space-xs) * 2.5) var(--cnc-space-sm); - } - - .cnc-account-popover__disconnect:hover { - background: var(--cnc-danger-subtle); - color: var(--cnc-danger); - } } From cc718873df02b586e1c45e2b60ba3483e0217bf2 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:02:43 -0300 Subject: [PATCH 04/99] feat: give the header an account dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit's account popover was the whole of the connected face, so the app could show the party id and nothing else. `TopBar/AccountMenu` replaces it with a dropdown the app owns: a copyable party id, the network the session is on, and the kit's `DisconnectButton`. The top bar picks the face rather than mounting the kit's `WalletButton`, on the party or the lock and not on `isConnected` alone. A standing session reports no party while the account read is in flight and again after it fails, and the connect face answers both — pending copy for the first, a retry for the second. A lock is the one state that clears the party for good, and there the dropdown is replaced whole by a disabled button. `useParty` reports that lock beside the party, so nothing else in the app reaches for a canton-connect hook. Dismissal is `hooks/useDismissable`, shared with `RoleSelect`: the focusout test against the root, the Escape close, the focus returned to the trigger, and the mousedown guard without which Safari unmounts the panel before the click lands. --- .../frontend/src/components/ConnectPrompt.tsx | 5 +- dapp/frontend/src/components/EmptyState.tsx | 2 - dapp/frontend/src/components/RoleSelect.tsx | 26 ++----- .../src/components/TopBar/AccountMenu.tsx | 74 +++++++++++++++++++ dapp/frontend/src/components/TopBar/index.tsx | 11 ++- dapp/frontend/src/hooks/useDismissable.ts | 52 +++++++++++++ dapp/frontend/src/hooks/useParty.ts | 18 +++-- 7 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 dapp/frontend/src/components/TopBar/AccountMenu.tsx create mode 100644 dapp/frontend/src/hooks/useDismissable.ts diff --git a/dapp/frontend/src/components/ConnectPrompt.tsx b/dapp/frontend/src/components/ConnectPrompt.tsx index 44a421cd..a241ca6f 100644 --- a/dapp/frontend/src/components/ConnectPrompt.tsx +++ b/dapp/frontend/src/components/ConnectPrompt.tsx @@ -2,13 +2,10 @@ import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' import { buttonClass } from '@/components/Button' import { EmptyState } from '@/components/EmptyState' -// Stands in wherever a page's ledger data would be, so the shell and its ConnectButton stay -// reachable instead of a gate replacing the whole app. Styled as the app's primary button, since -// here it is the call to action rather than the header's quiet chip. export const ConnectPrompt = (): React.JSX.Element => ( } + action={} /> ) diff --git a/dapp/frontend/src/components/EmptyState.tsx b/dapp/frontend/src/components/EmptyState.tsx index 0eb250c9..daee1633 100644 --- a/dapp/frontend/src/components/EmptyState.tsx +++ b/dapp/frontend/src/components/EmptyState.tsx @@ -1,8 +1,6 @@ import type { ReactNode } from 'react' import { LogoMark } from '@/icons' -// `level` is the rank the surrounding page leaves free: 2 under a PageTitle, 1 where this state -// replaces the page and there is no other heading for it to sit beneath. export const EmptyState = ({ title, description, diff --git a/dapp/frontend/src/components/RoleSelect.tsx b/dapp/frontend/src/components/RoleSelect.tsx index 68d2a043..5fd2edf6 100644 --- a/dapp/frontend/src/components/RoleSelect.tsx +++ b/dapp/frontend/src/components/RoleSelect.tsx @@ -1,5 +1,4 @@ -import type { FocusEvent, KeyboardEvent } from 'react' -import { useRef, useState } from 'react' +import { useDismissable } from '@/hooks/useDismissable' import { CaretDownIcon } from '@/icons' import type { Role } from '@/store/types' import { cn } from '@/utils/cn' @@ -19,24 +18,10 @@ export const RoleSelect = ({ onChange: (role: Role) => void value: Role }): React.JSX.Element => { - const [open, setOpen] = useState(false) - const root = useRef(null) - const current = roles.find((role) => role.value === value) ?? roles[0] - // On every button rather than on the wrapper, because focus leaving the whole control is what // closes it and a handler on a plain span is neither reachable nor allowed. - const closers = { - onBlur: (e: FocusEvent) => { - if (root.current?.contains(e.relatedTarget) !== true) { - setOpen(false) - } - }, - onKeyDown: (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setOpen(false) - } - }, - } + const { closers, keepFocus, open, root, setOpen, trigger } = useDismissable() + const current = roles.find((role) => role.value === value) ?? roles[0] return ( @@ -46,6 +31,7 @@ export const RoleSelect = ({ aria-expanded={open} aria-label={`View as: ${current.label}`} onClick={() => setOpen(!open)} + ref={trigger} className="inline-flex items-center gap-1.5 text-xl font-extrabold tracking-tight text-fg-muted transition-colors hover:text-fg" > {current.label} @@ -59,9 +45,7 @@ export const RoleSelect = ({ key={role.value} type="button" aria-pressed={role.value === value} - // Safari does not focus a button on mousedown, so without this the trigger blurs and - // the menu unmounts before the click it was aimed at ever lands. - onMouseDown={(e) => e.preventDefault()} + onMouseDown={keepFocus} onClick={() => { onChange(role.value) setOpen(false) diff --git a/dapp/frontend/src/components/TopBar/AccountMenu.tsx b/dapp/frontend/src/components/TopBar/AccountMenu.tsx new file mode 100644 index 00000000..9dc02adb --- /dev/null +++ b/dapp/frontend/src/components/TopBar/AccountMenu.tsx @@ -0,0 +1,74 @@ +import { Identifier, truncateIdentifier } from '@bootnodedev/canton-dappbooster' +import { DisconnectButton } from '@bootnodedev/canton-dappbooster/connect' +import { useId } from 'react' +import { PartyAvatar } from '@/components/TopBar/PartyAvatar' +import { useDismissable } from '@/hooks/useDismissable' +import { useParty } from '@/hooks/useParty' +import { CaretDownIcon, LockIcon } from '@/icons' +import { cn } from '@/utils/cn' +import { copyToast } from '@/utils/toast' + +const TRUNCATE = { head: 6, hint: 12, tail: 6 } + +// Transcribes the kit's `.cnc-connect-button` rather than taking `buttonClass`, so the header keeps +// one look across the two faces the session swaps between. +const triggerClass = + 'inline-flex h-11 items-center gap-2 rounded-[10px] border border-border bg-surface px-3 text-sm font-semibold text-fg transition-colors focus-visible:outline-none focus-visible:shadow-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-45' + +export const AccountMenu = (): React.JSX.Element => { + const { party } = useParty() + const { closers, keepFocus, open, root, setOpen, trigger } = useDismissable() + const panelId = useId() + + // The top bar mounts this with a party or with a locked wallet, and a lock is what clears + // the party, so no party here means locked. + return party === undefined ? ( + + ) : ( +
+ + {open && ( +
+
+ +

+ + Connected · {party.networkId} +

+
+
+ setOpen(false)} + onMouseDown={keepFocus} + /> +
+ )} +
+ ) +} diff --git a/dapp/frontend/src/components/TopBar/index.tsx b/dapp/frontend/src/components/TopBar/index.tsx index 93edc21d..75fa262e 100644 --- a/dapp/frontend/src/components/TopBar/index.tsx +++ b/dapp/frontend/src/components/TopBar/index.tsx @@ -1,7 +1,7 @@ import { ConnectButton } from '@bootnodedev/canton-dappbooster/connect' import { NavLink, type NavLinkRenderProps } from 'react-router-dom' +import { AccountMenu } from '@/components/TopBar/AccountMenu' import { Logo } from '@/components/TopBar/Logo' -import { PartyAvatar } from '@/components/TopBar/PartyAvatar' import { ThemeToggle } from '@/components/TopBar/ThemeToggle' import { useParty } from '@/hooks/useParty' import { SpinnerIcon } from '@/icons' @@ -15,8 +15,11 @@ const items = [ ] export const TopBar = (): React.JSX.Element => { - const { party } = useParty() + const { isLocked, party } = useParty() const { sessionPending } = useBackend() + // The connect face answers a session still reading its account and one whose read failed alike: + // it renders its own pending copy for the first and retries for the second. + const wallet = party !== undefined || isLocked ? : const pendingGrants = useVestingStore((s) => s.pendingGrants) const incoming = party === undefined ? 0 : pendingGrants.filter((p) => p.receiver === party.partyId).length @@ -37,13 +40,13 @@ export const TopBar = (): React.JSX.Element => { Restoring wallet session
) : ( - } /> + wallet )} {/* Centred over the row above from md, where there is room beside the logo and the wallet - chip; below that it takes a row of its own, since hiding it left Pending reachable + control; below that it takes a row of its own, since hiding it left Pending reachable only by typing the URL. */}