Sort dropdowns
GitHub-style sort selector for list pages. A trigger button shows the active sort label;
clicking it opens a panel of options. Each option is a form-submit button so the page
re-renders server-side with the new sort applied, the cursor reset to page 1, and the
user's choice persisted to a cookie. URL ?sort=<token> always wins so
a shared link reproduces what the sender saw and refreshes the cookie at the same time.
Live example
Same shape used on Clients:
a button on the right of the sticky search header, panel anchored to the right, active option
marked with a check and bold text. In this static demo, opening the panel and picking an option
flips the trigger label client-side; in a real list the parent <form method="get">
submits and the server-rendered page comes back with the new active option marked.
Where it sits next to filters
Sort anchors to the right of the search header via md:ml-auto. Filter triggers
(when present) sit immediately right of search — both narrow the result set, so they read as
a group. Sort sits opposite — visually separating what's in the list from what
order it appears in. On narrow screens flex flex-wrap lets the sort drop to
its own line without any breakpoint-specific markup.
<form method="get" class="...">
<div class="flex flex-wrap items-center gap-3 py-3">
<input type="search" name="Query" ... />
<button type="button" ...>Filters (2)</button>
<div class="md:ml-auto">
<partial name="_ClientSortDropdown" model="Model.ActiveSort" />
</div>
</div>
</form>
Activity lists — deliberate exception
Activity timelines don't use this pattern. Their only sensible sort options are newest first (always the default) and oldest first (occasional narrative reads). The full filter popover already lives centred at the top of the activity card, and adding a sort dropdown there would clutter what's currently a clean two-state surface. If activity ever grows a third useful sort, promote it to the same dropdown shape used elsewhere.
Key rules
- Each option is a
type="submit"button whosenameis the sort query param andvalueis the sort token — no Alpine state needed for the submit - Cursor pagination must use a composite cursor matching the active sort's columns; an Id-only cursor breaks every non-Id sort silently (see ClientListCursor)
- Persist the user's choice in a cookie — URL
?sort=…wins and refreshes the cookie, so shared links work; bare loads use the cookie default; otherwise fall back to the page's natural default - Mark the cookie
HttpOnly = true— the dropdown reads its state from the server-rendered active label, not from JS - Sort is a shared component — when a second list adopts it, extract a
_FooSortDropdown.cshtmlpartial rather than duplicating the markup - Don't show the dropdown when the list has zero results from a bare load — there's nothing to sort