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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/web/src/keyboard-open.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>): 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;
}
56 changes: 39 additions & 17 deletions apps/web/src/phone-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function PhoneHeader(
archivedAt,
canManage,
editing,
editingDocument,
label,
onAction,
onEditingChange,
Expand All @@ -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;
Expand All @@ -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<HTMLElement>(".plan-content[contenteditable='true']")
?.focus();
}
};
let finishEdit = () => {
handOff();
onEditingChange();
};

Expand All @@ -83,22 +91,36 @@ export function PhoneHeader(
</button>
)}
</div>
<div className="phone-top-bar-title">
{editing && canManage && !archivedAt
? (
<DocumentRename
channel={{ id: room, title: label }}
inline
onCancel={finishEdit}
replay={editing === "new"}
onRenamed={detail => {
onRenamed(detail.channel);
finishEdit();
}}
/>
)
: <span className="truncate">{label}</span>}
{archivedAt && <span className="phone-top-bar-subtitle">Archived</span>}
<div className="phone-top-bar-center">
<div className="phone-top-bar-title">
{editing && canManage && !archivedAt
? (
<DocumentRename
channel={{ id: room, title: label }}
inline
onCancel={finishEdit}
// The body took focus as the save started; a refusal needs the field back.
onErrorChange={error =>
error !== undefined
&& header.current?.querySelector<HTMLInputElement>(".document-title-input")
?.focus()}
onSavingChange={saving => saving && handOff()}
replay={editing === "new"}
onRenamed={detail => {
onRenamed(detail.channel);
finishEdit();
}}
/>
)
: <span className="truncate">{label}</span>}
{archivedAt
? <span className="phone-top-bar-subtitle">Archived</span>
: editingDocument && <span className="phone-top-bar-subtitle">Editing</span>}
</div>
{/* The title waits for the heading to scroll away; the mode must show regardless. */}
{editingDocument && !archivedAt && (
<span aria-hidden="true" className="phone-top-bar-mode">Editing</span>
)}
</div>
<div className="phone-top-bar-slot">
{canManage && (
Expand Down
91 changes: 88 additions & 3 deletions apps/web/src/phone-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,41 @@
}

/* 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);
}

.workspace-root[data-workspace-title="hidden"] .phone-top-bar-title {
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;
Expand Down Expand Up @@ -227,6 +254,7 @@
}

.phone-tab-bar,
.phone-tab-toolbar,
.phone-tab-action {
background: var(--color-glass);
-webkit-backdrop-filter: var(--glass-filter);
Expand All @@ -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);
Expand Down Expand Up @@ -297,7 +351,8 @@
}
}

.phone-tab [data-nucleo-icon] {
.phone-tab [data-nucleo-icon],
.phone-tab-action [data-nucleo-icon] {
color: inherit;
}

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}
}
11 changes: 10 additions & 1 deletion apps/web/src/phone-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -44,13 +45,21 @@ export function PhoneTabBar(
documentActivity?: DocumentActivity;
labels: Record<WorkspaceDestination, string>;
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 (
<div className="phone-tab-row" data-phone-tab-row="">
<div
className="phone-tab-row"
data-phone-tab-row=""
data-phone-tab-toolbar={toolbar ? "" : undefined}
>
{toolbar && <div className="phone-tab-toolbar">{toolbar}</div>}
<nav
hidden={!!toolbar}
aria-label="Workspace view"
className="phone-tab-bar"
style={{ "--phone-tab-count": destinations.length } as CSSProperties}
Expand Down
Loading
Loading