diff --git a/example-apps/dashnote/src/App.tsx b/example-apps/dashnote/src/App.tsx index 68b27cd..a131681 100644 --- a/example-apps/dashnote/src/App.tsx +++ b/example-apps/dashnote/src/App.tsx @@ -10,6 +10,7 @@ import { NotesWorkspace } from "./components/NotesWorkspace"; import { OperationResultNotice } from "./components/OperationResultNotice"; import { SettingsPanel } from "./components/SettingsPanel"; import type { TopTab } from "./components/Tabs"; +import { useMediaQuery } from "./hooks/useMediaQuery"; import { useSession } from "./session/useSession"; const screenCopy: Record = { @@ -36,6 +37,7 @@ function App() { const [tab, setTab] = useState("notes"); const [loginOpen, setLoginOpen] = useState(false); const [activityOpen, setActivityOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); const mobileFullBleed = tab === "notes"; @@ -59,7 +61,7 @@ function App() { return ( <> - + setLoginOpen(true)} + onOpenActivity={() => setActivityOpen(true)} mobileFullBleed={mobileFullBleed} > {tab === "notes" ? ( diff --git a/example-apps/dashnote/src/components/AppShell.tsx b/example-apps/dashnote/src/components/AppShell.tsx index 5ad9111..d438d54 100644 --- a/example-apps/dashnote/src/components/AppShell.tsx +++ b/example-apps/dashnote/src/components/AppShell.tsx @@ -14,6 +14,7 @@ interface AppShellProps { dpnsName: string | null; contractId: string | null; onLoginOpen: () => void; + onOpenActivity?: () => void; children: ReactNode; mobileFullBleed?: boolean; } @@ -97,6 +98,7 @@ export function AppShell({ dpnsName, contractId, onLoginOpen, + onOpenActivity, children, mobileFullBleed = false, }: AppShellProps) { @@ -132,6 +134,19 @@ export function AppShell({ closeDrawer(); }} /> + {onOpenActivity && ( +
+ { + onOpenActivity(); + closeDrawer(); + }} + /> +
+ )} {status !== "authenticated" && status !== "browsing" && ( @@ -279,14 +280,14 @@ export function LoginModal({ open, onClose }: LoginModalProps) { @@ -312,7 +313,7 @@ export function LoginModal({ open, onClose }: LoginModalProps) { href="https://bridge.thepasta.org/" target="_blank" rel="noreferrer" - className="font-semibold text-accent underline-offset-2 hover:underline" + className="font-semibold text-accent underline-offset-2 hover:underline max-md:mt-2 max-md:inline-flex max-md:min-h-10 max-md:items-center max-md:rounded-full max-md:border max-md:border-line-2 max-md:px-3 max-md:no-underline" > Create one on Dash Bridge → diff --git a/example-apps/dashnote/src/components/MobileActionSheet.tsx b/example-apps/dashnote/src/components/MobileActionSheet.tsx new file mode 100644 index 0000000..4044c87 --- /dev/null +++ b/example-apps/dashnote/src/components/MobileActionSheet.tsx @@ -0,0 +1,115 @@ +import { useEffect, useId, useRef, type ReactNode } from "react"; + +interface MobileActionSheetProps { + open: boolean; + title: string; + children: ReactNode; + onClose: () => void; +} + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not(:disabled)", + "input:not(:disabled)", + "select:not(:disabled)", + "textarea:not(:disabled)", + '[tabindex]:not([tabindex="-1"])', +].join(","); + +export function MobileActionSheet({ + open, + title, + children, + onClose, +}: MobileActionSheetProps) { + const dialogRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + if (!open) return; + + const previousFocus = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const dialog = dialogRef.current; + + function focusableElements() { + if (!dialog) return []; + return Array.from( + dialog.querySelectorAll(FOCUSABLE_SELECTOR), + ); + } + + const firstFocusable = focusableElements()[0]; + (firstFocusable ?? dialog)?.focus(); + + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose(); + return; + } + if (event.key !== "Tab") return; + + const focusable = focusableElements(); + if (focusable.length === 0) { + event.preventDefault(); + dialog?.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + if (previousFocus && document.contains(previousFocus)) { + previousFocus.focus(); + } + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > +

+ {title} +

+
{children}
+ +
+
+ ); +} diff --git a/example-apps/dashnote/src/components/Modal.tsx b/example-apps/dashnote/src/components/Modal.tsx index 14c3142..31e8bb4 100644 --- a/example-apps/dashnote/src/components/Modal.tsx +++ b/example-apps/dashnote/src/components/Modal.tsx @@ -42,7 +42,7 @@ export function Modal({ return (
e.stopPropagation()} >
diff --git a/example-apps/dashnote/src/components/NoteEditor.tsx b/example-apps/dashnote/src/components/NoteEditor.tsx index 42b2305..83a5ee8 100644 --- a/example-apps/dashnote/src/components/NoteEditor.tsx +++ b/example-apps/dashnote/src/components/NoteEditor.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import type { NoteRecord } from "../dash/queries"; import { FIELD_BYTE_LIMIT } from "../lib/fieldLimits"; import { formatRelativeTime, formatTimestamp } from "../lib/format"; +import { MobileActionSheet } from "./MobileActionSheet"; import { NoteJsonDrawer } from "./NoteJsonDrawer"; import { OperationResultNotice } from "./OperationResultNotice"; @@ -65,6 +66,18 @@ export function NoteEditor({ const isNew = selectedId === "new"; const oversize = messageOversize; const [jsonOpen, setJsonOpen] = useState(false); + const [mobileActionsOpen, setMobileActionsOpen] = useState(false); + const [mobileInfoOpen, setMobileInfoOpen] = useState(false); + const showMobileSave = + !isDesktop && !isReadOnly && hasSelection && (dirty || saving); + const mobileHeaderStatus = + !isDesktop && hasSelection + ? dirty + ? "Edited" + : note && !isNew + ? `Updated ${formatRelativeTime(note.updatedAt)}` + : null + : null; // Cmd/Ctrl-S triggers Save (matches the keyboard hint chip). useEffect(() => { @@ -160,7 +173,13 @@ export function NoteEditor({ ) : null}
)} -
+
+ {mobileHeaderStatus && ( + + {mobileHeaderStatus} + + )} +
{isDesktop && note && ( @@ -208,7 +227,7 @@ export function NoteEditor({ )} - {!isReadOnly && hasSelection && ( + {!isReadOnly && hasSelection && (isDesktop || showMobileSave) && ( + )}
@@ -314,7 +357,7 @@ export function NoteEditor({ onChange={(event) => onTitleChange(event.target.value)} placeholder={isNew ? "New note title" : "Title"} disabled={!canEdit} - className="w-full border-0 bg-transparent px-0 pt-0 pb-1 text-[28px] font-semibold leading-tight tracking-tight text-ink outline-none placeholder:text-ink-4 disabled:cursor-not-allowed disabled:text-ink-4" + className="mobile-note-editor-field w-full border-0 bg-transparent px-0 pt-0 pb-1 text-[28px] font-semibold leading-tight tracking-tight text-ink outline-none placeholder:text-ink-4 disabled:cursor-not-allowed disabled:text-ink-4" />