v4: the playlist and dump edit forms end in one bar of actions, a panel of its own — the playlist editor drew rules between its fields and its actions, and on a phone its Delete, Cancel and Save wrapped into a staircase, then stacked (a0c8b1d), neither of which held together. Both forms are now built as DumpEdit was: surface panels 3px apart (.form-panel, from .dump-edit-panel), the last a bar (.form-panel--bar, from .dump-edit-footer) holding Delete on the left and Cancel and Save on the right, compact (btn-sm), on one line. The new DeleteButton says "Delete" and names what goes to assistive tech ("Delete playlist", "Delete dump"); in a bar too narrow for the three labels in every face and language (a container query, under 18rem), it shows 🗑 instead. While edited, the playlist's header drops its own card for the form's panels. The phone stacking rule is gone. An auto-sized editor also re-fits when its width changes with its text unchanged: opening the form makes the page tall enough for a scrollbar, which narrowed the description after it was measured, so it overflowed; and it hides its overflow below its max-height, where a scrollbar could only come of rounding, and drops the resize grip, which the next keystroke would undo. 669 frontend tests pass, and tsc, eslint, deno fmt, doc:lint and i18n:compile --strict are clean; checked in screenshots in Kanagawa dark (French, 385 and 360px), Smooth light at 1000px and DumpEdit in Smooth at 390px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-08 22:05:43 +00:00
parent b9281da9c9
commit 5acdf79c8d
9 changed files with 313 additions and 224 deletions

View File

@@ -164,8 +164,7 @@
.dump-post-header,
.dump-rich-content,
.form-page-header,
.dump-edit-panel,
.dump-edit-footer,
.form-panel,
.playlist-detail-header,
.comment-node-inner {
box-shadow: var(--panel-ring), var(--card-shadow);
@@ -3374,7 +3373,7 @@ body.has-player .chat-fab {
}
.form-page--two-col .form-page-header,
.dump-edit-form,
.dump-edit-footer {
.dump-edit-form > .form-panel--bar {
grid-column: 1 / -1;
}
/* "Why?" soaks up whatever height the source column adds, so a tall
@@ -3422,7 +3421,10 @@ body.has-player .chat-fab {
word-break: break-word;
}
.dump-edit-panel {
/* A form split into panels, a few pixels apart (as on the page's grid), in
place of rules between its parts: the fields, then a bar of actions, which
ends the card. */
.form-panel {
display: flex;
flex-direction: column;
gap: 1rem;
@@ -3435,18 +3437,35 @@ body.has-player .chat-fab {
overflow: hidden;
}
.dump-edit-footer {
.form-panel--bar {
padding: 0.875rem 1.25rem;
background: var(--color-surface);
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
container: form-bar / inline-size;
}
.dump-edit-footer .form-actions {
.form-panel--bar .form-actions {
flex-wrap: nowrap;
padding-top: 0;
border-top: 0;
margin-top: 0;
}
/* Too narrow for Delete, Cancel and Save side by side in every face and
language, the bar shows Delete as its icon. */
.form-delete-icon {
display: none;
}
@container form-bar (max-width: 18rem) {
.form-delete-icon {
display: inline;
}
.form-delete-label {
display: none;
}
}
.dump-edit-refresh {
margin-top: 0.75rem;
}
@@ -3481,25 +3500,6 @@ body.has-player .chat-fab {
margin-left: auto;
}
/* A destructive action, Cancel and Save don't fit a phone side by side in
every face, and wrapped they would make a staircase. On a phone they stack:
Cancel and Save across the top, Save taking the room left, the destructive
action the full width under them. */
@media (max-width: 512px) {
.form-actions:has(> .btn-danger) {
flex-direction: column-reverse;
align-items: stretch;
}
.form-actions:has(> .btn-danger) > .form-actions-right {
margin-left: 0;
}
.form-actions:has(> .btn-danger) > .form-actions-right > :last-child {
flex: 1;
}
}
/* Subtle text action. Resets the global <button> chrome so it looks the same
whether rendered as a <button> (modal/page forms) or a <Link> (DumpEdit). */
.form-cancel {
@@ -5327,6 +5327,21 @@ body.has-player .chat-fab {
padding: 1.25rem;
}
/* While edited, the header gives way to the form's own panels. */
.playlist-detail-header--editing {
padding: 0;
background: none;
box-shadow: none;
}
.playlist-edit-form {
gap: 3px;
}
.playlist-edit-form > .form-panel:first-child {
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
.playlist-detail-header-top {
display: flex;
align-items: flex-start;

View File

@@ -99,6 +99,20 @@ interface PendingInsert {
focus: boolean;
}
/**
* Sizes an `autoResize` textarea to its text. A border-box height counts the
* borders, which `scrollHeight` leaves out. Below its `max-height` the box
* holds all its text, so a scrollbar there could only come of sub-pixel
* rounding, and would narrow the text into more lines; overflow is hidden
* then, and left to the stylesheet once the box is capped.
*/
function fitHeight(el: HTMLTextAreaElement): void {
el.style.height = "auto";
const height = el.scrollHeight + el.offsetHeight - el.clientHeight;
el.style.height = `${height}px`;
el.style.overflowY = el.offsetHeight < height ? "" : "hidden";
}
/**
* Renders a textarea that completes `@mentions` and `:emoji`, opens an emoji
* picker from its button, and counts characters near `maxLength`. A pasted or
@@ -274,17 +288,25 @@ export const TextEditor: React.ForwardRefExoticComponent<
// Before paint, so that the first frame, and any layout effect measuring
// it (such as a height transition), sees the final size.
useLayoutEffect(() => {
if (!autoResize) return;
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
// A border-box height counts the borders, which scrollHeight leaves
// out; without them the text overflows by a few pixels and scrolls.
el.style.height = `${
el.scrollHeight + el.offsetHeight - el.clientHeight
}px`;
if (autoResize && textareaRef.current) fitHeight(textareaRef.current);
}, [value, autoResize]);
// The text rewraps when the width changes with the value unchanged: when
// the page's scrollbar shows as the editor makes it taller, or the window
// narrows.
useEffect(() => {
const el = textareaRef.current;
if (!autoResize || !el) return;
let width = el.clientWidth;
const observer = new ResizeObserver(() => {
if (el.clientWidth === width) return;
width = el.clientWidth;
fitHeight(el);
});
observer.observe(el);
return () => observer.disconnect();
}, [autoResize]);
// The textarea is read-only while images upload, so the spot they go to
// can't move. They go in once it is writable again.
useLayoutEffect(() => {
@@ -534,6 +556,9 @@ export const TextEditor: React.ForwardRefExoticComponent<
rows={rows}
id={id}
className={className}
// Its height follows the text, so a grip to drag it would only
// be undone at the next keystroke.
style={autoResize ? { resize: "none" } : undefined}
/>
<button
ref={emojiButtonRef}

View File

@@ -0,0 +1,40 @@
/**
* Renders the destructive action of an edit form's bar, in `DumpEdit` and
* `PlaylistEditForm`.
*
* @module
*/
import { Trans } from "@lingui/react/macro";
/** Passed to {@link DeleteButton} by an edit form. */
export interface DeleteButtonProps {
/** Its accessible name, which names what goes, such as "Delete dump". */
label: string;
/** Opens the page's confirmation, which deletes. */
onClick: () => void;
}
/**
* Renders a compact "Delete" button, at the start of the bar's
* `.form-actions`. A bar too narrow for it beside Cancel and Save shows its
* icon instead (`.form-delete-icon`).
*/
export function DeleteButton(
{ label, onClick }: DeleteButtonProps,
): React.JSX.Element {
return (
<button
type="button"
className="btn-danger btn-sm"
aria-label={label}
title={label}
onClick={onClick}
>
<span className="form-delete-icon" aria-hidden>🗑</span>
<span className="form-delete-label">
<Trans>Delete</Trans>
</span>
</button>
);
}

View File

@@ -26,6 +26,7 @@ import { ImagePicker } from "../shared/ImagePicker.tsx";
import { TextField } from "../form/TextField.tsx";
import { RichTextField } from "../form/RichTextField.tsx";
import { VisibilityToggle } from "../form/SegmentedField.tsx";
import { DeleteButton } from "../form/DeleteButton.tsx";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
import { SlugField } from "../form/SlugField.tsx";
@@ -121,84 +122,90 @@ export function PlaylistEditForm(
return (
<FormProvider {...form}>
<form
className="form animate-in"
className="form playlist-edit-form animate-in"
onSubmit={onSubmit}
onKeyDown={(e) => {
// The editor's popups take their own Escape and prevent it.
if (e.key === "Escape" && !e.defaultPrevented) onCancel();
}}
>
<div className="form-field">
<span className="form-label">
<Trans>Cover</Trans>
</span>
<Controller
name="image"
control={form.control}
render={({ field }) => (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? playlistImageUrl(playlist, mediaToken)
: null)}
alt="Cover"
size={72}
onChange={(file) => {
field.onChange(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
)}
<div className="form-panel">
<div className="form-field">
<span className="form-label">
<Trans>Cover</Trans>
</span>
<Controller
name="image"
control={form.control}
render={({ field }) => (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? playlistImageUrl(playlist, mediaToken)
: null)}
alt="Cover"
size={72}
onChange={(file) => {
field.onChange(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
)}
/>
</div>
<TextField<EditValues>
name="title"
label={t`Title`}
autoFocus
rules={{ required: true }}
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
showCount
/>
<SlugField<EditValues>
name="slug"
titleName="title"
prefix={`${userPath(playlist.ownerUsername, "playlists")}/`}
saved={playlist}
/>
<RichTextField<EditValues>
name="description"
label={t`Description`}
rows={2}
autoResize
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
onSubmit={() => void onSubmit()}
/>
<VisibilityToggle<EditValues> name="isPublic" />
<FormError title={t`Failed to save`} />
</div>
<TextField<EditValues>
name="title"
label={t`Title`}
autoFocus
rules={{ required: true }}
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
showCount
/>
<SlugField<EditValues>
name="slug"
titleName="title"
prefix={`${userPath(playlist.ownerUsername, "playlists")}/`}
saved={playlist}
/>
<RichTextField<EditValues>
name="description"
label={t`Description`}
rows={2}
autoResize
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
onSubmit={() => void onSubmit()}
/>
<VisibilityToggle<EditValues> name="isPublic" />
<FormError title={t`Failed to save`} />
<div className="form-actions">
<button
type="button"
className="btn-danger"
onClick={onRequestDelete}
>
<Trans>Delete</Trans>
</button>
<div className="form-actions-right">
<button type="button" className="form-cancel" onClick={onCancel}>
<Trans>Cancel</Trans>
</button>
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={!title.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
<div className="form-panel form-panel--bar">
<div className="form-actions">
<DeleteButton
label={t`Delete playlist`}
onClick={onRequestDelete}
/>
<div className="form-actions-right">
<button
type="button"
className="form-cancel"
onClick={onCancel}
>
<Trans>Cancel</Trans>
</button>
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={!title.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
</div>
</div>
</div>
</form>

View File

@@ -47,6 +47,7 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
import { VisibilityToggle } from "../components/form/SegmentedField.tsx";
import { DeleteButton } from "../components/form/DeleteButton.tsx";
import { SlugField } from "../components/form/SlugField.tsx";
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
import { userPath } from "../../shared/paths.ts";
@@ -446,7 +447,7 @@ function DumpEditForm({
/* The source panel holds what was dumped, the details panel how it
is presented and who sees it. */
}
<div className="dump-edit-panel dump-edit-panel--source">
<div className="form-panel dump-edit-panel--source">
{preview}
<div className="form-field">
@@ -507,7 +508,7 @@ function DumpEditForm({
)}
</div>
<div className="dump-edit-panel dump-edit-panel--details">
<div className="form-panel dump-edit-panel--details">
<FormError title={t`Could not save`} />
<TextField<EditValues>
@@ -553,23 +554,17 @@ function DumpEditForm({
<VisibilityToggle<EditValues> name="isPublic" />
</div>
<div className="dump-edit-footer">
<div className="form-panel form-panel--bar">
{deleteError && (
<ErrorCard title={t`Could not delete`} message={deleteError} />
)}
<div className="form-actions">
<button
type="button"
onClick={onRequestDelete}
className="btn-danger"
>
<Trans>Delete dump</Trans>
</button>
<DeleteButton label={t`Delete dump`} onClick={onRequestDelete} />
<div className="form-actions-right">
<Link to={dumpUrl(dump)} className="form-cancel">
<Trans>Cancel</Trans>
</Link>
<SubmitButton>
<SubmitButton className="btn-primary btn-sm">
<Trans>Save</Trans>
</SubmitButton>
</div>

View File

@@ -90,7 +90,12 @@ export function PlaylistDetail(): React.JSX.Element {
return (
<PageShell>
<div className="playlist-detail-header" ref={headerRef}>
<div
className={`playlist-detail-header${
editOpen ? " playlist-detail-header--editing" : ""
}`}
ref={headerRef}
>
{editOpen
? (
<PlaylistEditForm

View File

@@ -366,7 +366,7 @@ describe("PlaylistDetail", () => {
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete playlist",
@@ -379,7 +379,7 @@ describe("PlaylistDetail", () => {
route("DELETE /api/playlists/:id", () => fail("Forbidden", 403));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete playlist",