diff --git a/biome.json b/biome.json index cf10b330..24e8dfd7 100644 --- a/biome.json +++ b/biome.json @@ -1,4 +1,190 @@ { + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "useEditorconfig": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100, + "attributePosition": "auto", + "bracketSpacing": true, + "ignore": [ + "**/package.json", + "**/yarn.lock", + "coverage/**", + "**/coverage/**", + "**/build", + "**/dist", + "**/node_modules", + "**/vendor-js/**", + "**/*-css.ts", + "**/*-svg.ts" + ] + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "a11y": { + "noBlankTarget": "error" + }, + "complexity": { + "noBannedTypes": "error", + "noExtraBooleanCast": "error", + "noMultipleSpacesInRegularExpressionLiterals": "error", + "noUselessCatch": "error", + "noUselessConstructor": "off", + "noUselessRename": "warn", + "noUselessStringConcat": "warn", + "noUselessTernary": "error", + "noUselessThisAlias": "error", + "noUselessTypeConstraint": "error", + "noUselessUndefinedInitialization": "error", + "noWith": "error", + "useArrowFunction": "warn" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "error", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "off", + "noGlobalObjectCalls": "error", + "noInnerDeclarations": "error", + "noInvalidConstructorSuper": "error", + "noNewSymbol": "error", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedImports": "error", + "noUnusedLabels": "error", + "noUnusedVariables": "error", + "useArrayLiterals": "off", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "useIsNan": "error", + "useJsxKeyInIterable": "error", + "useValidForDirection": "error", + "useYield": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "warn" + }, + "style": { + "noArguments": "warn", + "noDoneCallback": "error", + "noNamespace": "error", + "noRestrictedGlobals": { + "level": "error", + "options": { + "deniedGlobals": ["parseInt"] + } + }, + "noUselessElse": "warn", + "noVar": "warn", + "useAsConstAssertion": "error", + "useBlockStatements": "off", + "useCollapsedElseIf": "error", + "useConsistentBuiltinInstantiation": "error", + "useTemplate": "warn" + }, + "suspicious": { + "noAssignInExpressions": "error", + "noAsyncPromiseExecutor": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCommentText": "error", + "noCompareNegZero": "error", + "noConsole": { + "level": "error", + "options": { + "allow": ["warn", "error", "info"] + } + }, + "noControlCharactersInRegex": "error", + "noDebugger": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateJsxProps": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "off", + "noExplicitAny": "warn", + "noExportsInTest": "error", + "noExtraNonNullAssertion": "error", + "noFallthroughSwitchClause": "error", + "noFocusedTests": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noMisleadingCharacterClass": "error", + "noMisleadingInstantiator": "error", + "noMisplacedAssertion": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noShadowRestrictedNames": "error", + "noSkippedTests": "warn", + "noSparseArray": "error", + "noUnsafeDeclarationMerging": "error", + "noUnsafeNegation": "error", + "useGetterReturn": "error", + "useValidTypeof": "error" + } + }, + "ignore": ["**/*.md", "**/build", "**/dist", "**/node_modules", "**/vendor-js/**", "**/*.json"] + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "es5", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto", + "bracketSpacing": true + }, + "jsxRuntime": "transparent", + "globals": ["global", "browser", "expect"] + }, + "overrides": [ + { + "include": ["**/*.test.*"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + }, + "correctness": { + "noUndeclaredVariables": "off" + } + } + } + } + ] +} +======= "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "vcs": { "enabled": true, diff --git a/examples/testapp/.gitignore b/examples/testapp/.gitignore new file mode 100644 index 00000000..6036b3a0 --- /dev/null +++ b/examples/testapp/.gitignore @@ -0,0 +1 @@ +src/components/test-dialog.tsx diff --git a/packages/account-sdk/src/ui/Dialog/Dialog.test.tsx b/packages/account-sdk/src/ui/Dialog/Dialog.test.tsx index a33f6de9..1fbfa9d4 100644 --- a/packages/account-sdk/src/ui/Dialog/Dialog.test.tsx +++ b/packages/account-sdk/src/ui/Dialog/Dialog.test.tsx @@ -5,6 +5,37 @@ import { vi } from 'vitest'; import { DialogContainer, DialogInstance, DialogInstanceProps } from './Dialog.js'; +// Mock des hooks +vi.mock('../hooks/index.js', () => ({ + usePhonePortrait: vi.fn(() => false), + useDragToDismiss: vi.fn(() => ({ + dragY: 0, + isDragging: false, + handlers: { + onTouchStart: vi.fn(), + onTouchMove: vi.fn(), + onTouchEnd: vi.fn(), + }, + })), + useUsername: vi.fn(() => ({ + isLoading: false, + username: 'testuser.eth', + })), +})); + +// Mock du store et getDisplayableUsername +vi.mock(':store/store.js', () => ({ + store: { + account: { + get: vi.fn(() => ({ accounts: ['0x123'] })), + }, + }, +})); + +vi.mock(':core/username/getDisplayableUsername.js', () => ({ + getDisplayableUsername: vi.fn(() => Promise.resolve('testuser.eth')), +})); + const renderDialogContainer = (props?: Partial) => render( @@ -16,6 +47,8 @@ describe('DialogContainer', () => { beforeEach(() => { vi.useFakeTimers(); vi.spyOn(window, 'setTimeout'); + // Reset mocks + vi.clearAllMocks(); }); afterEach(() => { @@ -53,6 +86,7 @@ describe('DialogContainer', () => { const button = screen.getByText('Try again'); expect(button).toBeInTheDocument(); + expect(button.tagName).toBe('BUTTON'); // Vérifie que c'est un bouton sémantique fireEvent.click(button); expect(onClick).toHaveBeenCalledTimes(1); @@ -72,6 +106,7 @@ describe('DialogContainer', () => { const button = screen.getByText('Cancel'); expect(button).toBeInTheDocument(); + expect(button.tagName).toBe('BUTTON'); fireEvent.click(button); expect(onClick).toHaveBeenCalledTimes(1); @@ -84,8 +119,11 @@ describe('DialogContainer', () => { const closeButton = document.getElementsByClassName( '-base-acc-sdk-dialog-instance-header-close' )[0]; - fireEvent.click(closeButton); + expect(closeButton.tagName).toBe('BUTTON'); // Vérifie que c'est un bouton + expect(closeButton).toHaveAttribute('aria-label', 'Close dialog'); // Accessibilité + + fireEvent.click(closeButton); expect(handleClose).toHaveBeenCalledTimes(1); }); @@ -111,4 +149,91 @@ describe('DialogContainer', () => { expect(screen.getByText('Primary')).toBeInTheDocument(); expect(screen.getByText('Secondary')).toBeInTheDocument(); }); + + test('displays username when loaded', () => { + renderDialogContainer(); + + // Le mock retourne 'testuser.eth' + expect(screen.getByText('Signed in as testuser.eth')).toBeInTheDocument(); + }); + + test('displays default title when no username', async () => { + // Mock pour retourner pas d'username + const { useUsername } = vi.mocked(await import('../hooks/index.js')); + useUsername.mockReturnValue({ + isLoading: false, + username: null, + }); + + renderDialogContainer(); + + expect(screen.getByText('Base Account')).toBeInTheDocument(); + }); + + test('uses drag handlers from hook', async () => { + const mockHandlers = { + onTouchStart: vi.fn(), + onTouchMove: vi.fn(), + onTouchEnd: vi.fn(), + }; + + const { useDragToDismiss } = vi.mocked(await import('../hooks/index.js')); + useDragToDismiss.mockReturnValue({ + dragY: 50, + isDragging: true, + handlers: mockHandlers, + }); + + renderDialogContainer(); + + const backdrop = document.getElementsByClassName('-base-acc-sdk-dialog-backdrop')[0]; + + // Vérifie que les handlers sont attachés + fireEvent.touchStart(backdrop); + fireEvent.touchMove(backdrop); + fireEvent.touchEnd(backdrop); + + expect(mockHandlers.onTouchStart).toHaveBeenCalled(); + expect(mockHandlers.onTouchMove).toHaveBeenCalled(); + expect(mockHandlers.onTouchEnd).toHaveBeenCalled(); + }); + + test('applies drag transform from hook', async () => { + const { useDragToDismiss } = vi.mocked(await import('../hooks/index.js')); + useDragToDismiss.mockReturnValue({ + dragY: 100, + isDragging: true, + handlers: { + onTouchStart: vi.fn(), + onTouchMove: vi.fn(), + onTouchEnd: vi.fn(), + }, + }); + + renderDialogContainer(); + + const dialog = document.getElementsByClassName('-base-acc-sdk-dialog')[0]; + expect(dialog).toHaveStyle('transform: translateY(100px)'); + expect(dialog).toHaveStyle('transition: none'); + }); + + test('shows handle bar on phone portrait', async () => { + const { usePhonePortrait } = vi.mocked(await import('../hooks/index.js')); + usePhonePortrait.mockReturnValue(true); + + renderDialogContainer(); + + const handleBar = document.getElementsByClassName('-base-acc-sdk-dialog-handle-bar')[0]; + expect(handleBar).toBeInTheDocument(); + }); + + test('hides handle bar on desktop', async () => { + const { usePhonePortrait } = vi.mocked(await import('../hooks/index.js')); + usePhonePortrait.mockReturnValue(false); + + renderDialogContainer(); + + const handleBar = document.getElementsByClassName('-base-acc-sdk-dialog-handle-bar')[0]; + expect(handleBar).toBeUndefined(); + }); }); diff --git a/packages/account-sdk/src/ui/Dialog/Dialog.tsx b/packages/account-sdk/src/ui/Dialog/Dialog.tsx index 20df9942..56602683 100644 --- a/packages/account-sdk/src/ui/Dialog/Dialog.tsx +++ b/packages/account-sdk/src/ui/Dialog/Dialog.tsx @@ -2,48 +2,19 @@ import { clsx } from 'clsx'; import { FunctionComponent, render } from 'preact'; +import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; -import { getDisplayableUsername } from ':core/username/getDisplayableUsername.js'; -import { store } from ':store/store.js'; import { BaseLogo } from ':ui/assets/BaseLogo.js'; -import { useEffect, useMemo, useState } from 'preact/hooks'; +import { useDragToDismiss, usePhonePortrait, useUsername } from '../hooks/index.js'; import css from './Dialog-css.js'; const closeIcon = `data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzIDFMMSAxM20wLTEyTDEzIDEzIiBzdHJva2U9IiM5Q0EzQUYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9zdmc+`; -// Helper function to detect phone portrait mode -function isPhonePortrait(): boolean { - return window.innerWidth <= 600 && window.innerHeight > window.innerWidth; -} - // Handle bar component for mobile bottom sheet const DialogHandleBar: FunctionComponent = () => { - const [showHandleBar, setShowHandleBar] = useState(false); - - useEffect(() => { - // Only show handle bar on phone portrait mode - const checkOrientation = () => { - setShowHandleBar(isPhonePortrait()); - }; - - // Initial check - checkOrientation(); - - // Listen for orientation/resize changes - window.addEventListener('resize', checkOrientation); - window.addEventListener('orientationchange', checkOrientation); - - return () => { - window.removeEventListener('resize', checkOrientation); - window.removeEventListener('orientationchange', checkOrientation); - }; - }, []); + const isPhonePortrait = usePhonePortrait(); - if (!showHandleBar) { - return null; - } - - return
; + return isPhonePortrait ?
: null; }; export type DialogProps = { @@ -86,7 +57,18 @@ export class Dialog { this.render(); } + public dismissItem(key: number): void { + const item = this.items.get(key); + this.items.delete(key); + this.render(); + item?.onClose?.(); + } + public clear(): void { + // Call onClose for all items before clearing + for (const [, item] of this.items) { + item.onClose?.(); + } this.items.clear(); if (this.root) { render(null, this.root); @@ -94,84 +76,48 @@ export class Dialog { } private render(): void { - if (this.root) { - render( -
- - {Array.from(this.items.entries()).map(([key, itemProps]) => ( - { - this.clear(); - itemProps.onClose?.(); - }} - /> - ))} - -
, - this.root - ); - } + if (!this.root) return; + + render( +
+ + {Array.from(this.items.entries()).map(([key, itemProps]) => ( + { + this.dismissItem(key); + }} + /> + ))} + +
, + this.root + ); } } -export const DialogContainer: FunctionComponent = (props) => { - const [dragY, setDragY] = useState(0); - const [isDragging, setIsDragging] = useState(false); - const [startY, setStartY] = useState(0); - - // Touch event handlers for drag-to-dismiss (entire dialog area) - const handleTouchStart = (e: any) => { - // Only enable drag on mobile portrait mode - if (!isPhonePortrait()) return; - - const touch = e.touches[0]; - setStartY(touch.clientY); - setIsDragging(true); - }; - - const handleTouchMove = (e: any) => { - if (!isDragging) return; - - const touch = e.touches[0]; - const deltaY = touch.clientY - startY; - - // Only allow dragging down (positive deltaY) - if (deltaY > 0) { - setDragY(deltaY); - e.preventDefault(); // Prevent scrolling +export const DialogContainer: FunctionComponent = ({ children }) => { + const handleDismiss = useCallback(() => { + // Find the dialog instance and trigger its close handler + const closeButton = document.querySelector( + '.-base-acc-sdk-dialog-instance-header-close' + ) as HTMLElement; + if (closeButton) { + closeButton.click(); } - }; - - const handleTouchEnd = () => { - if (!isDragging) return; - - setIsDragging(false); - - // Dismiss if dragged down more than 100px - if (dragY > 100) { - // Find the dialog instance and trigger its close handler - const closeButton = document.querySelector( - '.-base-acc-sdk-dialog-instance-header-close' - ) as HTMLElement; - if (closeButton) { - closeButton.click(); - } - } else { - // Animate back to original position - setDragY(0); - } - }; + }, []); + + const { dragY, isDragging, handlers } = useDragToDismiss(handleDismiss); return (
{ }} > - {props.children} + {children}
@@ -195,8 +141,7 @@ export const DialogInstance: FunctionComponent = ({ handleClose, }) => { const [hidden, setHidden] = useState(true); - const [isLoadingUsername, setIsLoadingUsername] = useState(true); - const [username, setUsername] = useState(null); + const { isLoading: isLoadingUsername, username } = useUsername(); useEffect(() => { const timer = window.setTimeout(() => { @@ -208,26 +153,36 @@ export const DialogInstance: FunctionComponent = ({ }; }, []); - useEffect(() => { - const fetchEnsName = async () => { - const address = store.account.get().accounts?.[0]; - - if (address) { - const username = await getDisplayableUsername(address); - setUsername(username); - } - - setIsLoadingUsername(false); - }; - fetchEnsName(); - }, []); - const headerTitle = useMemo(() => { return username ? `Signed in as ${username}` : 'Base Account'; }, [username]); const shouldShowHeaderTitle = !isLoadingUsername; + // Memoize action buttons + const actionButtons = useMemo(() => { + if (!actionItems?.length) return null; + + return ( +
+ {actionItems.map((action, i) => ( + + ))} +
+ ); + }, [actionItems]); + return (
= ({
)}
-
- -
+
+
{title}
{message}
- {actionItems && actionItems.length > 0 && ( -
- {actionItems.map((action, i) => ( - - ))} -
- )} + + {actionButtons} ); }; diff --git a/packages/account-sdk/src/ui/hooks/index.ts b/packages/account-sdk/src/ui/hooks/index.ts new file mode 100644 index 00000000..064451bd --- /dev/null +++ b/packages/account-sdk/src/ui/hooks/index.ts @@ -0,0 +1,4 @@ +export { useDragToDismiss } from './useDragToDismiss.js'; +export { useMediaQuery } from './useMediaQuery.js'; +export { usePhonePortrait } from './usePhonePortrait.js'; +export { useUsername } from './useUsername.js'; diff --git a/packages/account-sdk/src/ui/hooks/useDragToDismiss.ts b/packages/account-sdk/src/ui/hooks/useDragToDismiss.ts new file mode 100644 index 00000000..9b485372 --- /dev/null +++ b/packages/account-sdk/src/ui/hooks/useDragToDismiss.ts @@ -0,0 +1,75 @@ +import { useCallback, useState } from 'preact/hooks'; +import { usePhonePortrait } from './usePhonePortrait.js'; + +const DRAG_DISMISS_THRESHOLD = 100; + +interface DragState { + dragY: number; + isDragging: boolean; + startY: number; +} + +export function useDragToDismiss(onDismiss: () => void) { + const [dragState, setDragState] = useState({ + dragY: 0, + isDragging: false, + startY: 0, + }); + + const isPhonePortrait = usePhonePortrait(); + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + if (!isPhonePortrait) return; + + const touch = e.touches[0]; + setDragState((prev) => ({ + ...prev, + startY: touch.clientY, + isDragging: true, + })); + }, + [isPhonePortrait] + ); + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (!dragState.isDragging) return; + + const touch = e.touches[0]; + const deltaY = touch.clientY - dragState.startY; + + // Only allow dragging down (positive deltaY) + if (deltaY > 0) { + setDragState((prev) => ({ ...prev, dragY: deltaY })); + e.preventDefault(); // Prevent scrolling + } + }, + [dragState.isDragging, dragState.startY] + ); + + const handleTouchEnd = useCallback(() => { + if (!dragState.isDragging) return; + + const shouldDismiss = dragState.dragY > DRAG_DISMISS_THRESHOLD; + + if (shouldDismiss) { + onDismiss(); + } else { + // Reset to original position + setDragState((prev) => ({ ...prev, dragY: 0 })); + } + + setDragState((prev) => ({ ...prev, isDragging: false })); + }, [dragState.isDragging, dragState.dragY, onDismiss]); + + return { + dragY: dragState.dragY, + isDragging: dragState.isDragging, + handlers: { + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + }, + }; +} diff --git a/packages/account-sdk/src/ui/hooks/useMediaQuery.ts b/packages/account-sdk/src/ui/hooks/useMediaQuery.ts new file mode 100644 index 00000000..c8b8aa1a --- /dev/null +++ b/packages/account-sdk/src/ui/hooks/useMediaQuery.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'preact/hooks'; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if (typeof window !== 'undefined') { + return window.matchMedia(query).matches; + } + return false; + }); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const mediaQuery = window.matchMedia(query); + const handler = (event: MediaQueryListEvent) => setMatches(event.matches); + + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }, [query]); + + return matches; +} diff --git a/packages/account-sdk/src/ui/hooks/usePhonePortrait.ts b/packages/account-sdk/src/ui/hooks/usePhonePortrait.ts new file mode 100644 index 00000000..38a971c9 --- /dev/null +++ b/packages/account-sdk/src/ui/hooks/usePhonePortrait.ts @@ -0,0 +1,7 @@ +import { useMediaQuery } from './useMediaQuery.js'; + +const PHONE_PORTRAIT_BREAKPOINT = 600; + +export function usePhonePortrait(): boolean { + return useMediaQuery(`(max-width: ${PHONE_PORTRAIT_BREAKPOINT}px) and (orientation: portrait)`); +} diff --git a/packages/account-sdk/src/ui/hooks/useUsername.ts b/packages/account-sdk/src/ui/hooks/useUsername.ts new file mode 100644 index 00000000..a1c3b4b6 --- /dev/null +++ b/packages/account-sdk/src/ui/hooks/useUsername.ts @@ -0,0 +1,46 @@ +import { getDisplayableUsername } from ':core/username/getDisplayableUsername.js'; +import { store } from ':store/store.js'; +import { useEffect, useRef, useState } from 'preact/hooks'; + +interface UsernameState { + isLoading: boolean; + username: string | null; +} + +export function useUsername() { + const [state, setState] = useState({ + isLoading: true, + username: null, + }); + + const addressRef = useRef(null); + + useEffect(() => { + const fetchUsername = async () => { + const currentAddress = store.account.get().accounts?.[0]; + + // Skip if address hasn't changed + if (currentAddress === addressRef.current) { + return; + } + + addressRef.current = currentAddress ?? null; + + if (currentAddress) { + try { + const username = await getDisplayableUsername(currentAddress); + setState({ isLoading: false, username }); + } catch (error) { + console.warn('Failed to fetch username:', error); + setState({ isLoading: false, username: null }); + } + } else { + setState({ isLoading: false, username: null }); + } + }; + + fetchUsername(); + }, []); + + return state; +}