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:
67
src/App.css
67
src/App.css
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
40
src/components/form/DeleteButton.tsx
Normal file
40
src/components/form/DeleteButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user