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

@@ -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>