diff --git a/.changeset/fix-content-list-sortable-headers.md b/.changeset/fix-content-list-sortable-headers.md
new file mode 100644
index 000000000..080a7a4d3
--- /dev/null
+++ b/.changeset/fix-content-list-sortable-headers.md
@@ -0,0 +1,10 @@
+---
+"emdash": patch
+"@emdash-cms/admin": patch
+---
+
+Make the admin collection list column headers sortable. `Title`, `Status`, `Locale`, and `Date` are now clickable buttons that toggle direction; the current sort state is exposed via `aria-sort` on the `
` so screen readers announce it correctly.
+
+The server's `orderBy` field whitelist now accepts `status`, `locale`, and `name` alongside the existing date fields — unchanged from a security standpoint, the repo still rejects unknown field names to prevent column enumeration.
+
+Callers of `` that don't pass `onSortChange` render the previous static-label headers, so legacy integrations (e.g. the content picker) are unaffected.
diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx
index a3f7fdbcc..7a0aef050 100644
--- a/packages/admin/src/components/ContentList.tsx
+++ b/packages/admin/src/components/ContentList.tsx
@@ -11,6 +11,9 @@ import {
MagnifyingGlass,
CaretLeft,
CaretRight,
+ CaretUp,
+ CaretDown,
+ CaretUpDown,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import * as React from "react";
@@ -20,6 +23,13 @@ import { contentUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { LocaleSwitcher } from "./LocaleSwitcher";
+/** Sortable content list columns. Maps to the server's order field whitelist. */
+export type ContentListSortField = "title" | "status" | "locale" | "updatedAt";
+export interface ContentListSort {
+ field: ContentListSortField;
+ direction: "asc" | "desc";
+}
+
export interface ContentListProps {
collection: string;
collectionLabel: string;
@@ -44,6 +54,14 @@ export interface ContentListProps {
onLocaleChange?: (locale: string) => void;
/** URL pattern for published content links (e.g. `/blog/{slug}`) */
urlPattern?: string;
+ /**
+ * Controlled sort state. When `onSortChange` is also provided, the column
+ * headers become sort controls that invoke it. Uncontrolled sort keeps
+ * the backward-compatible "static headers, server-default ordering"
+ * behavior for callers that haven't opted in yet.
+ */
+ sort?: ContentListSort;
+ onSortChange?: (sort: ContentListSort) => void;
}
type ViewTab = "all" | "trash";
@@ -84,6 +102,8 @@ export function ContentList({
activeLocale,
onLocaleChange,
urlPattern,
+ sort,
+ onSortChange,
}: ContentListProps) {
const { t } = useLingui();
const [activeTab, setActiveTab] = React.useState("all");
@@ -186,20 +206,32 @@ export function ContentList({
-
- {t`Title`}
-
-
- {t`Status`}
-
+
+
{i18n && (
-
- {t`Locale`}
-
+
)}
-
- {t`Date`}
-
+
{t`Actions`}
@@ -345,6 +377,72 @@ export function ContentList({
);
}
+interface SortableThProps {
+ field: ContentListSortField;
+ sort: ContentListSort | undefined;
+ onSortChange: ((sort: ContentListSort) => void) | undefined;
+ label: string;
+}
+
+/**
+ * Table header that doubles as a sort control when the parent opted in by
+ * passing `onSortChange`. When no callback is provided we fall back to a
+ * plain `
` so legacy callers (and screen readers) see exactly the same
+ * markup as before this change.
+ *
+ * The button's accessible name is just the column label — the sort state
+ * is conveyed via `aria-sort` on the
, which screen readers announce
+ * automatically. Adding a verbose aria-label would make each header re-read
+ * the sort instruction on every focus, which is noisy.
+ */
+function SortableTh({ field, sort, onSortChange, label }: SortableThProps) {
+ const isActive = sort?.field === field;
+ const direction = isActive ? sort?.direction : undefined;
+
+ if (!onSortChange) {
+ return (
+