v4: phone layouts hold down to 300px in every style, Kanagawa's wide monospace included, and the lint is clean — the floating chat and "+" buttons hid the end of every page (Save on the dump edit page, the last setting), since the page kept room for one button and they stack two; it keeps room for both now, read from one --fab-size. A 1fr page grid grew to its widest unbreakable content, and the edit forms' action bars didn't wrap, so at 320px the dump edit page scrolled sideways and the playlist edit's Save stuck out of its card; the grids take minmax(0, 1fr) and .form-actions wraps. The profile header's info column was a hidden horizontal scroller under a fade, which cut the username, the inviter, the email and the Log out and Invite buttons; it wraps instead (long names and emails break anywhere), and the avatar shrinks to 70% on phones. TabBar pins its leading content (the feed's category switcher) at the row's start while the tabs scroll under it, where scrolling to the active tab used to push it out of view; its edge fades show only while tabs are hidden past that edge, so the last tab is no longer dimmed once scrolled to, and a left fade marks a scrolled row. Tabs are narrower on phones, which lets Kanagawa's search tabs fit at 375px. The global player puts its track controls on a row of their own on phones, where they left the title three letters, and an audio bar narrower than 420px drops its volume slider for the waveform. The profile bio lost the margins it stacked on the page's gap. A dump card is now a container and lays out by its own width rather than the viewport's, since a playlist's is narrowed by its drag handle and remove button and titles broke every few letters: under 472px it is a grid where the vote ends the date line, as on the journal's cards, instead of hanging under the thumb, with a spacer row keeping it off the title; under 300px the thumb takes the top row and the text the full width. The lint errors that predated this are fixed: a useless fragment and two async callbacks without await in tests (they now await advanceTimersByTimeAsync), EmailKit's renderer gets the kit as an argument instead of aliasing this, its figure regex drops the literal no-break spaces \s already matches, digest-service's highlightOf takes NotificationData narrowed per case instead of any, and eslint ignores coverage/. 254 backend and 670 frontend tests pass, and tsc, deno check, deno lint, deno fmt, eslint and doc:lint are clean; every page was checked in screenshots at 300 to 480px in Kanagawa and Smooth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-08 21:19:43 +00:00
parent 58c95dc4f3
commit b9ff9c417e
9 changed files with 383 additions and 209 deletions

View File

@@ -393,9 +393,9 @@ export class EmailKit {
private mode: "text" | "footer" | "row" | "sentence" = "text";
/** Draws Markdown's inline tokens with the kit's colors. */
private readonly marked: Marked = (() => {
// deno-lint-ignore no-this-alias
const kit = this;
// The kit comes in as an argument: the renderer's methods need marked's
// `this` (its parser) as well as the kit's colors.
private readonly marked: Marked = ((kit: EmailKit) => {
return new Marked({
renderer: {
link({ href, tokens }) {
@@ -408,7 +408,9 @@ export class EmailKit {
},
strong({ tokens }) {
const html = this.parser.parseInline(tokens);
const figure = /^[\d\s.,  ]+$/.test(html);
// A figure, its digits grouped by any space: `\s` takes the no-break
// ones locales group thousands with.
const figure = /^[\d\s.,]+$/.test(html);
return `<b${
kit.paint(
{ fg: "text" },
@@ -441,7 +443,7 @@ export class EmailKit {
},
},
});
})();
})(this);
private inline(tokens: Token[], mode: EmailKit["mode"]): string {
this.mode = mode;

View File

@@ -23,9 +23,15 @@ import {
} from "../../shared/settings.ts";
import { SETTINGS_COLUMNS } from "../model/settings.ts";
import type {
CommentLikedData,
DumpCommentedData,
DumpRef,
NotificationData,
NotificationType,
PlaylistFollowedData,
PlaylistRef,
UserFollowedData,
UserMentionedData,
} from "../../shared/notification.ts";
import { dumpPath, feedPath, playlistPath } from "../../shared/paths.ts";
import {
@@ -253,64 +259,76 @@ const playlistTarget = (ref: PlaylistRef, title: string) => ({
*/
function highlightOf(
type: NotificationType,
// deno-lint-ignore no-explicit-any
data: any,
data: NotificationData,
): Omit<DigestHighlight, "at"> | null {
// `type` says which member `data` is, which TypeScript can't follow from a
// separate argument, so each case names it.
switch (type) {
case "user_mentioned":
switch (data.contextType) {
case "user_mentioned": {
const d = data as UserMentionedData;
switch (d.contextType) {
case "comment":
return {
kind: "mentionComment",
who: data.mentionerUsername,
target: dumpTarget(data, data.contextTitle),
quote: quoteOf(commentBody(data.contextId)),
who: d.mentionerUsername,
target: dumpTarget(d, d.contextTitle),
quote: quoteOf(commentBody(d.contextId)),
};
case "dump":
return {
kind: "mentionDump",
who: data.mentionerUsername,
target: dumpTarget(data, data.contextTitle),
who: d.mentionerUsername,
target: dumpTarget(d, d.contextTitle),
};
case "playlist":
return {
kind: "mentionPlaylist",
who: data.mentionerUsername,
target: playlistTarget(data, data.contextTitle),
who: d.mentionerUsername,
target: playlistTarget(d, d.contextTitle),
};
case "chat":
return {
kind: "mentionChat",
who: data.mentionerUsername,
who: d.mentionerUsername,
quote: quoteOf(
(db.prepare(`SELECT body FROM chat_messages WHERE id = ?;`)
.get(data.contextId) as { body: string } | undefined)?.body,
.get(d.contextId) as { body: string } | undefined)?.body,
),
};
}
return null;
case "dump_commented":
}
case "dump_commented": {
const d = data as DumpCommentedData;
return {
kind: "comment",
who: data.commenterUsername,
target: dumpTarget(data, data.dumpTitle),
quote: quoteOf(commentBody(data.commentId)),
who: d.commenterUsername,
target: dumpTarget(d, d.dumpTitle),
quote: quoteOf(commentBody(d.commentId)),
};
case "playlist_followed":
}
case "playlist_followed": {
const d = data as PlaylistFollowedData;
return {
kind: "playlistFollow",
who: data.followerUsername,
target: playlistTarget(data, data.playlistTitle),
who: d.followerUsername,
target: playlistTarget(d, d.playlistTitle),
};
}
case "user_followed":
return { kind: "userFollow", who: data.followerUsername };
case "comment_liked":
return {
kind: "userFollow",
who: (data as UserFollowedData).followerUsername,
};
case "comment_liked": {
const d = data as CommentLikedData;
return {
kind: "commentLike",
who: data.likerUsername,
target: dumpTarget(data, data.dumpTitle),
quote: quoteOf(commentBody(data.commentId)),
who: d.likerUsername,
target: dumpTarget(d, d.dumpTitle),
quote: quoteOf(commentBody(d.commentId)),
};
}
}
return null;
}

View File

@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist", "locales"]),
globalIgnores(["dist", "locales", "coverage"]),
{
files: ["**/*.{ts,tsx}"],
extends: [

View File

@@ -1132,6 +1132,7 @@
/* ── Custom audio player ── */
.audio-player {
container-type: inline-size;
display: flex;
align-items: center;
flex-wrap: nowrap;
@@ -1323,6 +1324,14 @@
display: block;
}
/* A narrow bar keeps the mute button but drops the volume slider, which took
as much room as the waveform (a phone's volume is in its buttons anyway). */
@container (max-width: 420px) {
.audio-player-track--volume {
display: none;
}
}
.file-preview-pdf {
width: 100%;
height: 80vh;
@@ -1542,6 +1551,19 @@ a.global-player-title:hover {
opacity: 0.35;
cursor: default;
}
/* On a phone the controls would leave the title a few letters, so they take a
row of their own under it. */
@media (max-width: 600px) {
.global-player-header {
flex-wrap: wrap;
row-gap: 0.25rem;
}
.global-player-transport {
order: 1;
width: 100%;
justify-content: center;
}
}
.global-player-status {
margin: 0;
padding: 0 1rem 0.5rem;
@@ -1627,9 +1649,13 @@ body.has-player {
moves to the top-right while the global player is mounted (it owns the
bottom of the screen, full-width on mobile). Sits below the player and
modal backdrop (both z-index 1000). */
body {
/* Both floating buttons' size, which the page's end clearance reads too. */
--fab-size: 3.5rem;
}
.dump-fab {
position: fixed;
--fab-size: 3.5rem;
/* Sit just outside the right edge of the centered content lane — in the
margin beside it — rather than over the content or pinned to the viewport
edge. The lane width is `--fab-lane` (860px for most feeds; the journal
@@ -1705,10 +1731,11 @@ body.has-player .dump-fab {
top: calc(1.25rem + env(safe-area-inset-top, 0px));
}
/* Reserve room at the end of the scroll so the button never hides the last
content when scrolled all the way down. */
/* Reserve room at the end of the scroll so the buttons never hide the last
content when scrolled all the way down: the chat button stacks on the "+",
so the pair reaches two buttons and their gaps (2rem) up from the bottom. */
body.has-fab .page-content {
padding-bottom: 5rem;
padding-bottom: calc(var(--fab-size) * 2 + 3rem);
}
/* Chat FAB — mirrors .dump-fab and stacks directly above it (the "+" dump
@@ -1716,7 +1743,6 @@ body.has-fab .page-content {
never overlap. */
.chat-fab {
position: fixed;
--fab-size: 3.5rem;
/* Share the dump-fab's column. */
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) -
1rem));
@@ -2123,37 +2149,24 @@ body.has-player .chat-fab {
}
.profile-info {
position: relative;
flex: 1;
min-width: 0;
/* A long name or email breaks rather than running past a phone's edge. */
overflow-wrap: anywhere;
}
.profile-info-scroll {
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.25rem;
padding-right: 0.25rem;
}
.profile-info-scroll::-webkit-scrollbar {
display: none;
}
/* Fade hint only at narrow viewports where the info column can actually overflow */
@media (max-width: 600px) {
.profile-info::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2rem;
background: linear-gradient(to right, transparent, var(--color-bg));
pointer-events: none;
.profile-header {
gap: 1rem;
}
/* zoom shrinks the layout box too, which Avatar's inline size pins at 128px,
so the info column keeps room for its buttons. */
.profile-avatar-wrapper {
zoom: 0.7;
}
}
.profile-tabs-scroller {
margin-top: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: var(--border-width) solid var(--color-border);
}
@@ -2343,9 +2356,9 @@ body.has-player .chat-fab {
}
/* ── Profile description ── */
/* No margins of its own: the page's gap already sets it apart. */
.profile-description {
width: 100%;
margin-bottom: 1.5rem;
}
.profile-description-view {
@@ -2399,6 +2412,7 @@ body.has-player .chat-fab {
.profile-email-display {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 0.35rem;
font-size: var(--text-xs);
@@ -2545,11 +2559,7 @@ body.has-player .chat-fab {
cursor: progress;
}
/* Raised above the info column's narrow-viewport overflow fade
(.profile-info::after) so the Copy button isn't washed out. */
.invite-panel {
position: relative;
z-index: 1;
width: 0;
min-width: 100%;
box-sizing: border-box;
@@ -3289,7 +3299,9 @@ body.has-player .chat-fab {
.form-page {
display: grid;
grid-template-columns: 1fr;
/* minmax(0, …): a 1fr track grows to its widest unbreakable content, so a
long URL or button row would push the page past a phone's edge. */
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto;
gap: 3px;
animation: page-enter 0.2s ease both;
@@ -3301,14 +3313,14 @@ body.has-player .chat-fab {
columns end on the same line. Only the header and footer are rounded. */
.dump-edit-form {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
gap: inherit;
}
@media (min-width: 860px) {
.form-page--two-col,
.dump-edit-form {
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-page--two-col .form-page-header,
.dump-edit-form,
@@ -3403,6 +3415,9 @@ body.has-player .chat-fab {
.form-actions {
display: flex;
/* A destructive action, Cancel and Save don't fit a phone side by side in
every face; the right group then drops to its own line, still right. */
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding-top: 0.75rem;
@@ -3631,15 +3646,23 @@ body.has-player .chat-fab {
/* ── Feed sort scroller + buttons ── */
/* Wrapper: constrains width, clips scroll overflow, and carries the gradient hint */
/* Wrapper: constrains width, clips scroll overflow, and carries the gradient
hint. --tab-row-bg is what the row sits on, which the fade and the pinned
leading content paint so the tabs vanish under them. */
.feed-sort-scroller {
position: relative;
min-width: 0;
overflow: hidden;
--tab-row-bg: var(--color-bg);
}
/* Right-edge gradient: absolutely positioned over the scroll container,
always at the viewport-right edge regardless of scroll position */
.app-header .feed-sort-scroller {
--tab-row-bg: var(--header-bg);
}
/* Right-edge fade, over the scroll container whatever its scroll position.
It shows only while tabs are hidden past that edge (TabBar's
--more), so it never dims the last tab once it is scrolled to or fits. */
.feed-sort-scroller::after {
content: "";
position: absolute;
@@ -3647,19 +3670,66 @@ body.has-player .chat-fab {
right: 0;
bottom: 0;
width: 2rem;
background: linear-gradient(to right, transparent, var(--color-bg));
background: linear-gradient(to right, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
/* In the app header the tabs only scroll as a last resort, and a permanent
fade would dim the last tab whenever they fit — so it shows only while they
overflow, in the header's own colour. */
.app-header .feed-sort-scroller::after {
background: linear-gradient(to right, transparent, var(--header-bg));
.feed-sort-scroller--more::after {
opacity: 1;
}
.app-header .feed-sort-scroller:not(.feed-sort-scroller--overflowing)::after {
display: none;
/* Its twin on the left edge once the row has scrolled, unless leading content
is pinned there, which draws its own (.feed-sort-leading::after). */
.feed-sort-scroller::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 2rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more-left:not(:has(.feed-sort-leading))::before {
opacity: 1;
}
/* The leading content (the scope switcher) stays at the row's start while the
tabs scroll under it. Its padding hides them right after its divider, and
the matching negative margin keeps the gap to the first tab unchanged. */
.feed-sort-leading {
position: sticky;
left: 0;
z-index: 1;
display: flex;
flex-shrink: 0;
padding-right: 0.4rem;
margin-right: -0.4rem;
background: var(--tab-row-bg);
}
/* Once tabs have scrolled under it, a short fade softens their cut edge. */
.feed-sort-leading::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 100%;
width: 0.75rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more-left .feed-sort-leading::after {
opacity: 1;
}
/* In the index below-header the scroller should fill the available row width */
@@ -3712,6 +3782,13 @@ body.has-player .chat-fab {
color: var(--tab-active-fg);
}
/* Narrower pills on a phone, so more tabs fit before the row has to scroll. */
@media (max-width: 600px) {
.feed-sort-btn {
padding-inline: 0.6rem;
}
}
/* ── Dump feed ── */
@keyframes card-enter {
from {
@@ -3764,8 +3841,11 @@ body.has-player .chat-fab {
border-color: var(--card-hover-border);
}
/* Card-specific layout */
/* Card-specific layout. A container, since a card is often much narrower than
the viewport (a playlist's has a drag handle and a remove button beside it),
and its own width decides when the body has no room left beside the thumb. */
.dump-card {
container: dump-card / inline-size;
display: grid;
grid-template-rows: 1fr;
transition:
@@ -3821,47 +3901,11 @@ body.has-player .chat-fab {
}
@media (max-width: 512px) {
.dump-card-inner,
.playlist-card-inner {
gap: 0.5rem;
padding: 0.6rem 0.75rem;
}
/* Reset flex order and margin — narrow mode uses dump-card-left as a real flex container */
.dump-card-body,
.dump-card-vote {
order: 0;
}
/* Left column: preview + vote stacked with fixed gap, independent of body height */
.dump-card-left {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
flex-shrink: 0;
}
.dump-card-body {
align-self: center;
}
/* Slightly smaller 16:9 thumb on phones so the body keeps enough width. */
.dump-card-preview {
width: 104px;
height: 58px;
}
.dump-card-meta {
flex-direction: column;
gap: 0.1rem;
}
.dump-card-comment {
display: none;
}
.playlist-card-description {
display: none;
}
@@ -4183,6 +4227,100 @@ body.has-player .chat-fab {
align-self: center;
}
/* A narrow card (on a phone, or in a playlist, whose drag handle and remove
button narrow it further) is a grid that both columns' wrappers dissolve
into, so the vote ends the date line, as on the journal's cards, rather
than hanging under the thumb. The empty 1fr rows centre the text against
the thumb when it is the taller; the fixed row after the title keeps the
vote, which the title's last line can run over, clear of it. */
@container dump-card (max-width: 472px) {
.dump-card-inner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: 1fr auto 0.4rem auto auto 1fr;
/* No row gap: the empty rows would add theirs; the spacer row and the
categories' margin set the space instead. */
gap: 0 0.75rem;
padding: 0.6rem 0.75rem;
}
.dump-card-left,
.dump-card-body {
display: contents;
}
.dump-card-preview {
grid-column: 1;
grid-row: 1 / -1;
align-self: center;
/* Slightly smaller 16:9 thumb, so the text keeps enough width. */
width: 104px;
height: 58px;
}
.dump-card-title {
grid-column: 2 / -1;
grid-row: 2;
}
.dump-card-comment {
display: none;
}
.dump-card-meta {
grid-column: 2;
grid-row: 4;
align-self: center;
margin-top: 0;
/* Its parts wrap whole, so a date never breaks across lines. */
flex-wrap: wrap;
row-gap: 0.1rem;
white-space: nowrap;
}
.dump-card-vote {
grid-column: 3;
grid-row: 4;
}
.dump-card-categories {
grid-column: 2 / -1;
grid-row: 5;
}
}
/* Too narrow for any text beside the thumb, which left a title a few letters
per line: the thumb takes the top row and the text the full width under it,
the vote still ending the date line. */
@container dump-card (max-width: 300px) {
.dump-card-inner {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto 0.4rem auto auto;
}
.dump-card-preview {
grid-column: 1 / -1;
grid-row: 1;
margin-bottom: 0.5rem;
}
.dump-card-title {
grid-column: 1 / -1;
}
.dump-card-meta {
grid-column: 1;
}
.dump-card-vote {
grid-column: 2;
}
.dump-card-categories {
grid-column: 1 / -1;
}
}
@media (max-width: 512px) {
.app-header-brand-name {
display: none;

View File

@@ -107,90 +107,88 @@ export function ProfileHeader(
)}
</div>
<div className="profile-info" ref={infoRef}>
<div className="profile-info-scroll">
<div className="profile-username-row">
<h1 className="profile-username">{user.username}</h1>
<RoleChip role={user.role} />
</div>
{user.invitedByUsername
<div className="profile-username-row">
<h1 className="profile-username">{user.username}</h1>
<RoleChip role={user.role} />
</div>
{user.invitedByUsername
? (
<p className="profile-invited-by">
<Trans>invited by</Trans>{" "}
<Link
to={userPath(user.invitedByUsername)}
className="profile-invited-by-link"
>
@{user.invitedByUsername}
</Link>
</p>
)
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{isOwnProfile && (
emailEditing
? (
<p className="profile-invited-by">
<Trans>invited by</Trans>{" "}
<Link
to={userPath(user.invitedByUsername)}
className="profile-invited-by-link"
>
@{user.invitedByUsername}
</Link>
</p>
<EmailEditor
initialEmail={me?.email ?? ""}
onCancel={() => setEmailEditing(false)}
onSaved={handleEmailSaved}
/>
)
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{isOwnProfile && (
emailEditing
? (
<EmailEditor
initialEmail={me?.email ?? ""}
onCancel={() => setEmailEditing(false)}
onSaved={handleEmailSaved}
/>
)
: (
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
</p>
)
)}
{avatarError && (
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
)}
{isOwnProfile && (
<ProfileOwnActions>
<Link to="/settings" className="btn-border">
<Trans>Settings</Trans>
</Link>
{can(me, "category:manage") && (
<Link to="/manage" className="btn-border">
<Trans>Manage</Trans>
</Link>
)}
<button
type="button"
className="btn-border btn-border--danger"
onClick={logout}
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
>
<Trans>Log out</Trans>
</button>
</ProfileOwnActions>
)}
</div>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
</p>
)
)}
{avatarError && (
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
)}
{isOwnProfile && (
<ProfileOwnActions>
<Link to="/settings" className="btn-border">
<Trans>Settings</Trans>
</Link>
{can(me, "category:manage") && (
<Link to="/manage" className="btn-border">
<Trans>Manage</Trans>
</Link>
)}
<button
type="button"
className="btn-border btn-border--danger"
onClick={logout}
>
<Trans>Log out</Trans>
</button>
</ProfileOwnActions>
)}
</div>
</div>
);

View File

@@ -28,7 +28,8 @@ export interface TabBarProps<T extends string> {
innerClassName?: string;
/**
* Optional content rendered inside the row, before the tabs (e.g. a scope
* selector). Sits in the same flex line so it scrolls and aligns with them.
* selector). Sits in the same flex line, aligned with them, and stays pinned
* at the row's start while they scroll under it.
*/
leading?: ReactNode;
}
@@ -42,23 +43,35 @@ export function TabBar<T extends string>(
TabBarProps<T>,
): React.JSX.Element {
const rowRef = useRef<HTMLDivElement>(null);
const leadingRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState(false);
// Tabs hidden past either edge, which the stylesheet fades only then, so a
// fade never dims a tab that is fully in view.
const [moreLeft, setMoreLeft] = useState(false);
const [moreRight, setMoreRight] = useState(false);
// Flags a row whose tabs don't all fit, for places that fade the edge only
// then (the app header) rather than always.
useLayoutEffect(() => {
const row = rowRef.current;
if (!row) return;
const measure = () => setOverflowing(row.scrollWidth > row.clientWidth);
const measure = () => {
setOverflowing(row.scrollWidth > row.clientWidth);
setMoreLeft(row.scrollLeft > 1);
setMoreRight(row.scrollLeft + row.clientWidth < row.scrollWidth - 1);
};
const resize = new ResizeObserver(measure);
resize.observe(row);
for (const child of row.children) resize.observe(child);
row.addEventListener("scroll", measure, { passive: true });
measure();
return () => resize.disconnect();
return () => {
resize.disconnect();
row.removeEventListener("scroll", measure);
};
}, [tabs.length]);
// When they don't all fit, keep the active tab in view, clear of the fade
// over the row's right edge — scrolling the row alone, never the page.
// When they don't all fit, keep the active tab in view, between the leading
// content pinned over the row's left edge and the fade over its right edge,
// scrolling the row alone, never the page.
useLayoutEffect(() => {
const row = rowRef.current;
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
@@ -67,7 +80,8 @@ export function TabBar<T extends string>(
const fade =
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
if (b.left < r.left) row.scrollLeft -= r.left - b.left;
const left = r.left + (leadingRef.current?.offsetWidth ?? 0);
if (b.left < left) row.scrollLeft -= left - b.left;
else if (b.right > r.right - fade) {
row.scrollLeft += b.right - r.right + fade;
}
@@ -76,14 +90,18 @@ export function TabBar<T extends string>(
return (
<div
className={`feed-sort-scroller${
overflowing ? " feed-sort-scroller--overflowing" : ""
}${className ? ` ${className}` : ""}`}
moreLeft ? " feed-sort-scroller--more-left" : ""
}${moreRight ? " feed-sort-scroller--more" : ""}${
className ? ` ${className}` : ""
}`}
>
<div
ref={rowRef}
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
>
{leading}
{leading && (
<div ref={leadingRef} className="feed-sort-leading">{leading}</div>
)}
{tabs.map(({ key, label }) => (
<button
key={key}

View File

@@ -39,7 +39,7 @@ describe("CategoriesProvider", () => {
expect(result.current.isLoaded).toBe(false);
route("GET /api/categories", () => wire([makeCategory({ id: "a" })]));
await act(async () => {
vi.advanceTimersByTime(10_000);
await vi.advanceTimersByTimeAsync(10_000);
});
await act(async () => {});
expect(result.current.isLoaded).toBe(true);

View File

@@ -26,7 +26,7 @@ describe("ChatProvider", () => {
test("?chat=open deep-links into it, and closing strips the param", async () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
renderApp(<></>, { session, route: "/somewhere?chat=open&x=1" });
renderApp(<div />, { session, route: "/somewhere?chat=open&x=1" });
await screen.findByRole("dialog", { name: "Chat" });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).toBeNull();

View File

@@ -97,7 +97,7 @@ describe("FollowProvider", () => {
followedPlaylistIds: [],
}));
await act(async () => {
vi.advanceTimersByTime(10_000);
await vi.advanceTimersByTimeAsync(10_000);
});
await act(async () => {});
expect(result.current.isLoaded).toBe(true);