diff --git a/apps/web/src/keyboard-open.ts b/apps/web/src/keyboard-open.ts new file mode 100644 index 000000000..c456c2a77 --- /dev/null +++ b/apps/web/src/keyboard-open.ts @@ -0,0 +1,44 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; + +import type { RefObject } from "react"; + +/** Follows `:root[data-keyboard-open]`, which the visual-viewport adapter keeps current. */ +export function useKeyboardOpen(): boolean { + return useSyncExternalStore(subscribe, current, () => false); +} + +function subscribe(notify: () => void): () => void { + let observer = new MutationObserver(notify); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-keyboard-open"], + }); + return () => observer.disconnect(); +} + +function current(): boolean { + return document.documentElement.hasAttribute("data-keyboard-open"); +} + +/** Whether focus is in a document editor inside `scope`: where the docked formatting acts. */ +export function useEditorFocus(scope: RefObject): boolean { + let [focused, setFocused] = useState(false); + useEffect(() => { + let update = () => { + let active = document.activeElement; + setFocused( + !!active?.closest(".plan-content") && !!scope.current?.contains(active), + ); + }; + update(); + // `focusout` fires before the next element takes focus, so settle after both events. + let settle = () => queueMicrotask(update); + document.addEventListener("focusin", settle); + document.addEventListener("focusout", settle); + return () => { + document.removeEventListener("focusin", settle); + document.removeEventListener("focusout", settle); + }; + }, [scope]); + return focused; +} diff --git a/apps/web/src/phone-header.tsx b/apps/web/src/phone-header.tsx index b0fd3baa3..71c8281c9 100644 --- a/apps/web/src/phone-header.tsx +++ b/apps/web/src/phone-header.tsx @@ -24,6 +24,7 @@ export function PhoneHeader( archivedAt, canManage, editing, + editingDocument, label, onAction, onEditingChange, @@ -34,6 +35,8 @@ export function PhoneHeader( archivedAt?: string; canManage: boolean; editing?: TitleEdit; + /** The document itself is in edit mode, which the bar says under the title. */ + editingDocument?: boolean; label: string; onAction: (action: DocumentAction) => void; onEditingChange: (editing?: TitleEdit) => void; @@ -56,13 +59,18 @@ export function PhoneHeader( return () => cancel(handle); }, [hostsTrigger]); - let finishEdit = () => { + // Also runs as a save starts, not after it: keys typed while the rename travels would + // otherwise land in a field that is about to close. + let handOff = () => { // Naming a new document leads into writing it, unless the user already tapped elsewhere. if (editing === "new" && header.current?.contains(document.activeElement)) { header.current.closest(".workspace-root") ?.querySelector(".plan-content[contenteditable='true']") ?.focus(); } + }; + let finishEdit = () => { + handOff(); onEditingChange(); }; @@ -83,22 +91,36 @@ export function PhoneHeader( )} -
- {editing && canManage && !archivedAt - ? ( - { - onRenamed(detail.channel); - finishEdit(); - }} - /> - ) - : {label}} - {archivedAt && Archived} +
+
+ {editing && canManage && !archivedAt + ? ( + + error !== undefined + && header.current?.querySelector(".document-title-input") + ?.focus()} + onSavingChange={saving => saving && handOff()} + replay={editing === "new"} + onRenamed={detail => { + onRenamed(detail.channel); + finishEdit(); + }} + /> + ) + : {label}} + {archivedAt + ? Archived + : editingDocument && Editing} +
+ {/* The title waits for the heading to scroll away; the mode must show regardless. */} + {editingDocument && !archivedAt && ( + + )}
{canManage && ( diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index a12520b94..606e24ce8 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -147,7 +147,18 @@ } /* The small title waits until the document's own title has scrolled away. */ -.phone-top-bar-title { +.phone-top-bar-center { + display: grid; + min-width: 0; + place-items: center; +} + +.phone-top-bar-center > * { + grid-area: 1 / 1; +} + +.phone-top-bar-title, +.phone-top-bar-mode { transition: opacity var(--duration-base) var(--ease-out); } @@ -155,6 +166,22 @@ opacity: 0; } +/* Stands in for the hidden title while editing, so the mode is always on screen. */ +.phone-top-bar-mode { + padding: calc(var(--spacing) / 2) calc(var(--spacing) * 2); + border-radius: var(--radius-full); + background: var(--color-brand-wash); + color: var(--color-brand-ink); + font-size: var(--text-2xs); + font-weight: var(--font-weight-medium); + opacity: 0; + pointer-events: none; +} + +.workspace-root[data-workspace-title="hidden"] .phone-top-bar-mode { + opacity: 1; +} + .phone-top-bar-slot { display: flex; align-items: center; @@ -227,6 +254,7 @@ } .phone-tab-bar, +.phone-tab-toolbar, .phone-tab-action { background: var(--color-glass); -webkit-backdrop-filter: var(--glass-filter); @@ -246,6 +274,32 @@ border-radius: var(--radius-full); } +.phone-tab-bar[hidden] { + display: none; +} + +/* Editing on a phone: formatting takes the tabs' place and rides above the keyboard. */ +:root[data-keyboard-open] .phone-tab-row[data-phone-tab-toolbar] { + visibility: visible; + transform: none; +} + +.phone-tab-toolbar { + flex: 1; + min-width: 0; + height: var(--phone-tab-bar-height); + border-radius: var(--radius-full); + overflow: hidden; + animation: phone-tab-toolbar-in var(--tab-slide-dur) var(--tab-slide-ease); +} + +@keyframes phone-tab-toolbar-in { + from { + opacity: 0; + transform: translateY(calc(var(--spacing) * 3)) scale(0.98); + } +} + .phone-tab-indicator { position: absolute; inset-block: var(--spacing); @@ -297,7 +351,8 @@ } } -.phone-tab [data-nucleo-icon] { +.phone-tab [data-nucleo-icon], +.phone-tab-action [data-nucleo-icon] { color: inherit; } @@ -386,6 +441,31 @@ } } +/* The edit toggle turns into a filled Done button while editing. */ +.phone-tab-action[data-editing] { + background: var(--color-brand); + color: var(--color-page); + outline-color: var(--color-brand); +} + +.phone-tab-action > svg { + grid-area: 1 / 1; + transition: + opacity var(--duration-fast) var(--ease-out), + transform var(--tab-slide-dur) var(--tab-slide-ease); +} + +.phone-tab-action > [data-edit-icon="done"], +.phone-tab-action[data-editing] > [data-edit-icon="edit"] { + opacity: 0; + transform: scale(0.4) rotate(45deg); +} + +.phone-tab-action[data-editing] > [data-edit-icon="done"] { + opacity: 1; + transform: none; +} + /* Destinations ---------------------------------------------------------- */ /* Sibling tabs glide in from the side their tab lies on and push the old one out. */ @@ -431,7 +511,12 @@ .workspace-root[data-workspace-phone] > .phone-top-bar, .workspace-root[data-workspace-phone] .workspace-stack-bar, .phone-tab-indicator, - .phone-tab-row { + .phone-tab-row, + .phone-tab-action > svg { transition: none; } + + .phone-tab-toolbar { + animation: none; + } } diff --git a/apps/web/src/phone-workspace.tsx b/apps/web/src/phone-workspace.tsx index 187e7f583..82d6ab231 100644 --- a/apps/web/src/phone-workspace.tsx +++ b/apps/web/src/phone-workspace.tsx @@ -32,6 +32,7 @@ export function PhoneTabBar( documentActivity, labels, onSelect, + toolbar, unanswered, }: { /** The circular control at the bar's trailing end, such as an edit toggle. */ @@ -44,13 +45,21 @@ export function PhoneTabBar( documentActivity?: DocumentActivity; labels: Record; onSelect: (destination: WorkspaceDestination, current: boolean, source: HTMLElement) => void; + /** Takes the tabs' place while set, such as formatting while editing; stays above the keyboard. */ + toolbar?: ReactNode; unanswered: number; }, ) { let index = destinations.indexOf(active); return ( -
+
+ {toolbar &&
{toolbar}
}