authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 13:37:11-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 13:37:11-03:00
log0118b52295264a73ef7cd045437181f7b3e5a99f
tree28cff062c5f56b53799b772748e0aaad67ced7df
parentf26198d799bb81bf754985d457a60f8a851ea895
signature Signed by PGP key B5690EEEBB952194

fix(web): keep sparse sidebar shelves at the bottom (#11595)


3 files changed, 93 insertions(+), 17 deletions(-)

apps/web/src/components/Sidebar.drag.test.ts+31
......@@ -253,6 +253,37 @@ describe("sidebar collision detection", () => {
253253});
254254
255255describe("sidebar drag projection", () => {
256 it.each([
257 ["a1", "a2"],
258 ["a1", sidebarMarkerId("settled-header")],
259 ["z", "a2"],
260 ])("keeps sparse shelves at the bottom when dragging %s over %s", (active, over) => {
261 const items = [
262 pinnedHeader,
263 divider,
264 thread("a1", "active"),
265 thread("a2", "active"),
266 marker("snoozed-header"),
267 thread("z", "snoozed"),
268 settledHeader,
269 thread("s", "settled"),
270 ];
271 const strategy = createSidebarSortingStrategy({
272 items,
273 settledOrder: over === sidebarMarkerId("settled-header") ? ["a1", "s"] : ["s"],
274 settledExpanded: true,
275 boundaryLabelHeight: 24,
276 snoozedThreadCount: 1,
277 });
278 const args = layout(items, active, over);
279 for (const rect of args.rects.slice(4)) {
280 rect.top += 400;
281 rect.bottom += 400;
282 }
283 const lastIndex = items.length - 1;
284 expect(strategy({ ...args, index: lastIndex })).toEqual(stationary);
285 });
286
256287 const pinned = [
257288 pinnedHeader,
258289 thread("p1", "pinned"),
apps/web/src/components/Sidebar.drag.ts+44-13
......@@ -194,27 +194,58 @@ export function createSidebarSortingStrategy(input: {
194194 }
195195 marker("settled-header");
196196 section("settled");
197 const result = items.map(() => hidden);
198 let top = rects[0].top;
199 for (const item of projected) {
197 const heights = projected.map((item) => {
200198 const index = indices.get(sidebarListItemId(item));
201199 const rect = index === undefined ? undefined : rects[index];
202 if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top };
203200 const fallback =
204201 item.kind === "thread" && (item.section === "pinned" || item.section === "active")
205202 ? cardHeight
206203 : slimHeight;
207204 const moved = item.kind === "thread" && item.key === active.key;
208 const height =
209 item.kind === "marker" &&
205 return item.kind === "marker" &&
210206 (item.marker === "pinned-header" || item.marker === "pinned-divider")
211 ? labelHeight
212 : item.kind === "marker" && item.marker.endsWith("placeholder")
213 ? slimHeight
214 : moved
215 ? fallback
216 : (rect?.height ?? fallback);
217 top += height + 1;
207 ? labelHeight
208 : item.kind === "marker" && item.marker.endsWith("placeholder")
209 ? slimHeight
210 : moved
211 ? fallback
212 : (rect?.height ?? fallback);
213 });
214 const firstShelf = items.findIndex(
215 (item) =>
216 item.kind === "marker" &&
217 (item.marker === "snoozed-header" || item.marker === "settled-header"),
218 );
219 const shelfRect = rects[firstShelf];
220 const beforeShelf = rects[firstShelf - 1];
221 const lastRect = rects.at(-1);
222 // Consume the shelf's auto margin as drag labels and resized rows need
223 // room, keeping the combined shelves at their measured bottom.
224 let shelfSpace =
225 !input.compact &&
226 shelfRect &&
227 beforeShelf &&
228 lastRect &&
229 shelfRect.top > beforeShelf.bottom + 1
230 ? Math.max(
231 0,
232 lastRect.bottom - rects[0].top - heights.reduce((sum, height) => sum + height + 1, -1),
233 )
234 : 0;
235 const result = items.map(() => hidden);
236 let top = rects[0].top;
237 for (const [projectedIndex, item] of projected.entries()) {
238 if (
239 item.kind === "marker" &&
240 (item.marker === "snoozed-header" || item.marker === "settled-header")
241 ) {
242 top += shelfSpace;
243 shelfSpace = 0;
244 }
245 const index = indices.get(sidebarListItemId(item));
246 const rect = index === undefined ? undefined : rects[index];
247 if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top };
248 top += heights[projectedIndex]! + 1;
218249 }
219250 result[activeIndex] = stationary;
220251 return result;
apps/web/src/components/Sidebar.tsx+18-4
......@@ -661,6 +661,7 @@ function SidebarDragBoundary(props: {
661661function SidebarSectionHeader(props: {
662662 marker: "snoozed-header" | "settled-header";
663663 label: string;
664 className?: string;
664665 // While dragging, the settled header reads at full strength and takes the
665666 // accent while the lifted row is over it.
666667 dragging?: boolean;
......@@ -712,7 +713,7 @@ function SidebarSectionHeader(props: {
712713 <SortableSidebarMarker
713714 marker={props.marker}
714715 data-testid={`sidebar-${props.marker}`}
715 className="mx-0.5 h-8"
716 className={cn("mx-0.5 h-8", props.className)}
716717 >
717718 <Tooltip disabled={!compact}>
718719 <TooltipTrigger
......@@ -4541,7 +4542,10 @@ export default function Sidebar() {
45414542 <>
45424543 <SidebarChromeHeader isElectron={isElectron} />
45434544 <SidebarContent
4544 className={cn("gap-0", compact && "group-data-[collapsible=icon]:overflow-visible")}
4545 className={cn(
4546 "gap-0",
4547 compact ? "group-data-[collapsible=icon]:overflow-visible" : "min-h-full",
4548 )}
45454549 fixedFooter={
45464550 compact && !isSearchingThreads && snoozedThreads.length > 0 ? (
45474551 <ul
......@@ -4717,7 +4721,12 @@ export default function Sidebar() {
47174721 </SidebarGroup>
47184722 }
47194723 >
4720 <SidebarGroup className="ps-[calc(var(--sidebar-content-inset)+1px)] pe-[var(--sidebar-content-inset)] pb-1 pt-0">
4724 <SidebarGroup
4725 className={cn(
4726 "ps-[calc(var(--sidebar-content-inset)+1px)] pe-[var(--sidebar-content-inset)] pb-1 pt-0",
4727 !compact && "flex-1",
4728 )}
4729 >
47214730 {isSearchingThreads ? (
47224731 threadSearchResults.length > 0 ? (
47234732 <TooltipProvider
......@@ -4806,7 +4815,10 @@ export default function Sidebar() {
48064815 <ul
48074816 ref={attachListMotionRef}
48084817 role="list"
4809 className="relative flex flex-col gap-px"
4818 className={cn(
4819 "relative flex flex-col gap-px",
4820 !compact && sidebarListItems.length > 0 && "flex-1",
4821 )}
48104822 >
48114823 {(() => {
48124824 const renderThreadRowInner = (
......@@ -5021,6 +5033,7 @@ export default function Sidebar() {
50215033 <SidebarSectionHeader
50225034 key="snoozed-shelf-header"
50235035 marker="snoozed-header"
5036 className={cn(!compact && "mt-auto")}
50245037 label={
50255038 snoozedShelfExpanded
50265039 ? "Snoozed"
......@@ -5038,6 +5051,7 @@ export default function Sidebar() {
50385051 <SidebarSectionHeader
50395052 key="settled-shelf-header"
50405053 marker="settled-header"
5054 className={cn(!compact && snoozedThreads.length === 0 && "mt-auto")}
50415055 label={
50425056 settledShelfExpanded
50435057 ? "Settled"