Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export let targetLabel: (comment: AdminModerationComment) => string;
<Dialog.Content
class="grid max-h-[calc(100dvh-1rem)] min-h-0 max-w-2xl grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-2xl"
aria-labelledby="manage-comment-title"
showCloseButton={false}
>
<Dialog.Header>
<Dialog.Title id="manage-comment-title">{copy.manageComment}</Dialog.Title>
Expand All @@ -56,7 +57,7 @@ export let targetLabel: (comment: AdminModerationComment) => string;
</Dialog.Header>

<ScrollArea class="min-h-0 h-[min(56dvh,34rem)] max-h-[calc(100dvh-12rem)]">
<div class="grid gap-5 px-5 py-4">
<div class="grid gap-5 px-5 pt-4 pb-6">
{#if dialogMessage}<Alert.Root class="py-2" variant={dialogMessageVariant}><Alert.Description>{dialogMessage}</Alert.Description></Alert.Root>{/if}

<AdminModerationCommentPreview
Expand Down
6 changes: 4 additions & 2 deletions src/features/comments/components/CommentThreadHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export let toggleReply: (comment: CommentNode) => void;
</script>

<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 items-start gap-3">
<div class="flex min-w-0 flex-1 items-start gap-3">
<Avatar.Root>
{#if !comment.authorHidden && comment.author?.image}
<Avatar.Image src={comment.author.image} alt={authorName(comment)} />
Expand All @@ -35,7 +35,9 @@ export let toggleReply: (comment: CommentNode) => void;
{#if comment.visibility === "logged_in_only"}<Badge variant="outline">{commentCopy.visibilityLoggedIn}</Badge>{/if}
{#if comment.status !== "active"}<Badge variant="outline">{statusLabel(comment.status)}</Badge>{/if}
{#if comment.contextLabel}
<Badge variant="outline">{commentCopy.postedIn} {comment.contextLabel}</Badge>
<Badge class="h-auto max-w-full whitespace-normal break-words text-left" variant="outline">
{commentCopy.postedIn} {comment.contextLabel}
</Badge>
{/if}
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
Expand Down
4 changes: 2 additions & 2 deletions src/features/comments/components/CommentThreadItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@ export let viewer: ViewerContext;
</script>

<article
class={`grid gap-3 border-b border-border/70 py-4 last:border-b-0 ${
class={`grid min-w-0 gap-3 border-b border-border/70 py-4 last:border-b-0 ${
highlightedId === comment.id ? "rounded-lg ring-2 ring-primary/40" : ""
}`}
id={`comment-${comment.id}`}
style={`margin-left: ${Math.min(depth, 3) * 1.25}rem`}
style={`padding-left: ${Math.min(depth, 3) * 1.25}rem`}
>
<CommentThreadHeader
bind:actionMenuId
Expand Down
2 changes: 1 addition & 1 deletion src/features/comments/components/CommentsPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ $: _replyUploading = commentUploadPendingForMode(_uploadPending, "reply");
$: _editUploading = commentUploadPendingForMode(_uploadPending, "edit");
</script>

<section class="grid gap-4">
<section class="grid min-w-0 gap-4">
{#if _message}<Alert.Root variant={_messageVariant}><Alert.Description>{_message}</Alert.Description></Alert.Root>{/if}
{#if _viewer.isSuspended}
<CommentsPanelSuspensionAlert
Expand Down
2 changes: 1 addition & 1 deletion src/features/comments/components/CommentsThreadList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export let visibilityOptions: CommentThreadProps["visibilityOptions"];
{/each}
{/snippet}

<div class="grid gap-4">
<div class="grid min-w-0 gap-4">
{#each comments as comment}
{@render commentItem(comment)}
{/each}
Expand Down
2 changes: 1 addition & 1 deletion src/features/comments/lib/comment-panel-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ export function createCommentPanelInteractions(input: {
input.setDeleting(false);
}
closeDeleteDialog();
await input.loadComments();
input.onSuccess?.("delete");
await input.loadComments();
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ $: sectionExamEvents = sectionCalendarEvents.filter(

<div
class={cn(
"min-w-0 min-h-0 overflow-y-auto px-4 pt-4 sm:px-5 lg:px-6 md:pb-4",
"min-w-0 min-h-0 overflow-x-hidden overflow-y-auto px-4 pt-4 sm:px-5 lg:px-6 md:pb-4",
viewer.signedIn
? "pb-[calc(9rem+max(0.75rem,env(safe-area-inset-bottom)))]"
: "pb-[calc(5rem+max(0.75rem,env(safe-area-inset-bottom)))]",
Expand Down Expand Up @@ -143,7 +143,7 @@ $: sectionExamEvents = sectionCalendarEvents.filter(
{/if}

<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,20rem)] lg:items-start lg:gap-10">
<div class="grid min-w-0 gap-10">
<div class="grid min-w-0 grid-cols-1 gap-10">
<section id="introduction" class="scroll-mt-4">
{#key `description:section:${data.section.id}`}
{#if DescriptionCard}
Expand Down Expand Up @@ -197,6 +197,7 @@ $: sectionExamEvents = sectionCalendarEvents.filter(
this={SectionExamSection}
events={sectionExamEvents}
{fmtDate}
heading={sectionCopy.tabs.exams}
{sectionCopy}
/>
{/if}
Expand Down Expand Up @@ -232,7 +233,7 @@ $: sectionExamEvents = sectionCalendarEvents.filter(
{/if}
</section>

<section id="comments" class="scroll-mt-4">
<section id="comments" class="scroll-mt-4 min-w-0">
{#key `comments:section:${data.section.id}`}
{#if CommentsPanel}
<svelte:component
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type SectionExamCopy = {

export let events: SectionCalendarEvent[];
export let fmtDate: (value: string | Date | null | undefined) => string;
export let heading: string;
export let sectionCopy: SectionExamCopy;

function detailValue(event: SectionCalendarEvent, label: string) {
Expand All @@ -40,7 +41,7 @@ function examLocation(event: SectionCalendarEvent) {
<ResponsiveCollection>
{#snippet mobile()}
<Item.Group
aria-label={sectionCopy.examBatch}
aria-label={heading}
class="gap-3"
data-testid="section-exams-items"
role="list"
Expand Down Expand Up @@ -125,7 +126,7 @@ function examLocation(event: SectionCalendarEvent) {
{#snippet desktop()}
<div data-testid="section-exams-list">
<Table.Root>
<Table.Caption class="sr-only">{sectionCopy.examBatch}</Table.Caption>
<Table.Caption class="sr-only">{heading}</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head scope="col">{sectionCopy.examBatch}</Table.Head>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/shell/AppShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ afterNavigate(({ from, to }) => {
<!-- svelte-ignore a11y_no_noninteractive_tabindex -- the desktop content region is the keyboard-scrollable viewport -->
<div
bind:this={contentScrollContainer}
aria-label={mainContentLabel}
aria-label={data.copy.shell.scrollRegion}
data-shell-scroll-container
role="region"
tabindex="0"
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/ui/button/button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { type VariantProps, tv } from "tailwind-variants";

export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none md:min-h-0 md:min-w-0 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/ui/input-group/input-group.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
data-slot="input-group"
role="group"
class={cn(
"group/input-group border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
"group/input-group border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-11 rounded-lg border transition-colors md:h-8 in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className
)}
{...props}
Expand Down
14 changes: 12 additions & 2 deletions src/lib/components/ui/table/table.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@
let {
ref = $bindable(null),
class: className,
containerLabel,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
}: WithElementRef<HTMLTableAttributes> & {
containerLabel?: string;
} = $props();
</script>

<div data-slot="table-container" class="relative w-full overflow-x-auto">
<!-- svelte-ignore a11y_no_noninteractive_tabindex (focus enables keyboard scrolling for overflowing tables) -->
<div
data-slot="table-container"
class="relative w-full overflow-x-auto"
role={containerLabel ? "region" : undefined}
aria-label={containerLabel}
tabindex={containerLabel ? 0 : undefined}
>
<table bind:this={ref} data-slot="table" class={cn("w-full caption-bottom text-sm", className)} {...restProps}>
{@render children?.()}
</table>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/ui/toggle/toggle.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { type VariantProps, tv } from "tailwind-variants";

export const toggleVariants = tv({
base: "hover:text-foreground aria-pressed:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[state=on]:bg-accent data-[state=on]:text-accent-foreground gap-1 rounded-lg text-sm font-medium transition-all [&_svg:not([class*='size-'])]:size-4 group/toggle hover:bg-muted inline-flex items-center justify-center whitespace-nowrap outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: "hover:text-foreground aria-pressed:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[state=on]:bg-accent data-[state=on]:text-accent-foreground gap-1 rounded-lg text-sm font-medium transition-all [&_svg:not([class*='size-'])]:size-4 group/toggle hover:bg-muted inline-flex min-h-11 min-w-11 items-center justify-center whitespace-nowrap outline-none focus-visible:ring-[3px] md:min-h-0 md:min-w-0 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/shell/layout-server-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export function buildLayoutCopy(locale: LayoutLocale) {
profileMenu: messages.common.profileMenu,
secondaryNavigation:
locale === "zh-cn" ? "次级导航" : "Secondary navigation",
scrollRegion:
locale === "zh-cn" ? "主要内容滚动区域" : "Main content scroll region",
skipToMainContent: messages.accessibility.skipToMainContent,
theme: locale === "zh-cn" ? "主题" : "Theme",
},
Expand Down
5 changes: 3 additions & 2 deletions tests/e2e/src/app/admin/moderation/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,9 @@ test("/admin/moderation 移动端弹窗滚动体不遮挡封禁控件", async ({
});
await expect(dialog).toBeVisible();
await expect(
dialog.getByRole("button", { name: /关闭|Close/i }),
dialog.getByRole("button", { name: /取消|Cancel/i }),
).toHaveCount(1);
await expect(dialog.locator('[data-slot="dialog-close"]')).toHaveCount(0);

const suspendButton = dialog.getByRole("button", {
name: /^(封禁|Suspend)$/i,
Expand All @@ -245,7 +246,7 @@ test("/admin/moderation 移动端弹窗滚动体不遮挡封禁控件", async ({
),
);

await dialog.getByRole("button", { name: /关闭|Close/i }).click();
await dialog.getByRole("button", { name: /取消|Cancel/i }).click();
await expect(dialog).toBeHidden();
}
});
Expand Down
101 changes: 101 additions & 0 deletions tests/e2e/src/app/dashboard/todos/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,4 +474,105 @@ test.describe("仪表盘待办", () => {
await cleanupTodosByTitlePrefix(page, titlePrefix);
}
});

test("短视口待办新建和编辑弹窗保持标题、滚动体与操作可达", async ({
page,
}) => {
test.setTimeout(90_000);
const viewport = { width: 390, height: 600 } as const;
await page.setViewportSize(viewport);
await signInAsDebugUser(page, "/workspace/todos");

const titlePrefix = `e2e-dashboard-todo-short-${Date.now()}`;
const title = `${titlePrefix}-todo`;

async function assertDialogBounds(
dialog: import("@playwright/test").Locator,
) {
const dialogBox = await dialog.boundingBox();
const footer = dialog.locator('[data-slot="dialog-footer"]');
const closeButton = dialog.getByRole("button", { name: "Close" });
const [footerBox, closeBox] = await Promise.all([
footer.boundingBox(),
closeButton.boundingBox(),
]);
expect(dialogBox).not.toBeNull();
expect(footerBox).not.toBeNull();
expect(closeBox).not.toBeNull();
if (!dialogBox || !footerBox || !closeBox) {
throw new Error("Expected the short-viewport todo dialog bounds");
}
expect(dialogBox.y).toBeGreaterThanOrEqual(16);
expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(
viewport.height - 16,
);
expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(
viewport.height - 16,
);
expect(closeBox.width).toBeGreaterThanOrEqual(44);
expect(closeBox.height).toBeGreaterThanOrEqual(44);
await expect(footer).toBeInViewport();
await expect(closeButton).toBeInViewport();

const calendarButton = dialog.getByRole("button", {
name: /打开日历选择器|Open calendar picker/i,
});
const calendarBox = await calendarButton.boundingBox();
expect(calendarBox).not.toBeNull();
expect(calendarBox?.width ?? 0).toBeGreaterThanOrEqual(44);
expect(calendarBox?.height ?? 0).toBeGreaterThanOrEqual(44);
await expect(calendarButton).toBeInViewport();

const scrollViewport = dialog
.locator('[data-slot="scroll-area-viewport"]')
.first();
const scrollMetrics = await scrollViewport.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
expect(scrollMetrics.clientHeight).toBeGreaterThan(0);
expect(scrollMetrics.scrollHeight).toBeGreaterThanOrEqual(
scrollMetrics.clientHeight,
);
const scrollBox = await scrollViewport.boundingBox();
expect(scrollBox).not.toBeNull();
expect(scrollBox?.y ?? 0).toBeGreaterThanOrEqual(dialogBox.y);
expect(scrollBox?.y ?? 0).toBeLessThan(footerBox.y);
}

try {
await page.getByTestId("dashboard-todos-add").click();
const createDialog = page.getByRole("dialog", {
name: /新建待办|New Todo/i,
});
await expect(createDialog).toBeVisible();
await assertDialogBounds(createDialog);

await createDialog.getByLabel(/^(标题|Title)$/i).fill(title);
await createDialog
.getByRole("textbox", { name: /内容描述|Description/i })
.fill("short viewport regression content");
await createDialog
.getByRole("button", { name: /创建待办|Create Todo/i })
.click();
await expect(visibleText(page, title)).toBeVisible({ timeout: 15_000 });

await page.getByRole("button", { name: title, exact: true }).click();
const detailDialog = page.getByRole("dialog", { name: title });
await expect(detailDialog).toBeVisible();
await detailDialog
.getByRole("button", { name: /编辑待办|Edit Todo/i })
.click();

const editDialog = page.getByRole("dialog", {
name: /编辑待办|Edit Todo/i,
});
await expect(editDialog).toBeVisible();
await assertDialogBounds(editDialog);
await editDialog.getByRole("button", { name: /取消|Cancel/i }).click();
await expect(editDialog).toBeHidden();
} finally {
await cleanupTodosByTitlePrefix(page, titlePrefix);
}
});
});
Loading
Loading