Skip to content

Commit 2a4e590

Browse files
authored
refactor(ui): replace stickyHeader action with new sticky action and update consumers (#9867)
1 parent a6f9d4a commit 2a4e590

File tree

11 files changed

+321
-99
lines changed

11 files changed

+321
-99
lines changed

apps/desktop/src/components/BranchesView.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@
135135
return () => unsub?.();
136136
}
137137
});
138+
139+
let selectionPreviewScrollContainer: HTMLDivElement | undefined = $state();
138140
</script>
139141

140142
<Modal
@@ -403,8 +405,12 @@
403405

404406
{#if !isNonLocalPr}
405407
<div class="preview-selection">
406-
<ConfigurableScrollableContainer zIndex="var(--z-lifted)">
408+
<ConfigurableScrollableContainer
409+
bind:viewport={selectionPreviewScrollContainer}
410+
zIndex="var(--z-lifted)"
411+
>
407412
<SelectionView
413+
scrollContainer={selectionPreviewScrollContainer}
408414
testId={TestId.BranchesSelectionView}
409415
{projectId}
410416
{selectionId}

apps/desktop/src/components/FileListItemWrapper.svelte

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import { computeChangeStatus } from '$lib/utils/fileStatus';
1515
import { inject } from '@gitbutler/shared/context';
1616
import { FileListItem, FileViewHeader, TestId } from '@gitbutler/ui';
17-
import { stickyHeader } from '@gitbutler/ui/utils/stickyHeader';
17+
import { sticky as stickyAction } from '@gitbutler/ui/utils/sticky';
1818
import type { ConflictEntriesObj } from '$lib/files/conflicts';
1919
import type { Rename } from '$lib/hunks/change';
2020
import type { UnifiedDiff } from '$lib/hunks/diff';
@@ -36,10 +36,12 @@
3636
showCheckbox?: boolean;
3737
draggable?: boolean;
3838
transparent?: boolean;
39+
sticky?: boolean;
3940
onclick?: (e: MouseEvent) => void;
4041
onkeydown?: (e: KeyboardEvent) => void;
4142
onCloseClick?: () => void;
4243
conflictEntries?: ConflictEntriesObj;
44+
scrollContainer?: HTMLDivElement;
4345
hideBorder?: boolean;
4446
}
4547
@@ -62,7 +64,8 @@
6264
onclick,
6365
onkeydown,
6466
onCloseClick,
65-
hideBorder
67+
hideBorder,
68+
scrollContainer
6669
}: Props = $props();
6770
6871
const idSelection = inject(ID_SELECTION);
@@ -72,6 +75,7 @@
7275
7376
let contextMenu = $state<ReturnType<typeof FileContextMenu>>();
7477
let draggableEl: HTMLDivElement | undefined = $state();
78+
let isStuck = $state(false);
7579
7680
const previousTooltipText = $derived(
7781
(change.status.subject as Rename).previousPath
@@ -129,13 +133,11 @@
129133

130134
<div
131135
data-testid={TestId.FileListItem}
132-
use:stickyHeader={{
133-
disabled: !isHeader
134-
}}
135136
class="filelistitem-wrapper"
136137
data-remove-from-panning
137138
class:filelistitem-header={isHeader}
138139
class:transparent
140+
class:stuck={isHeader && isStuck}
139141
bind:this={draggableEl}
140142
use:draggableChips={{
141143
label: getFilename(change.path),
@@ -148,6 +150,13 @@
148150
dropzoneRegistry,
149151
dragStateService
150152
}}
153+
use:stickyAction={{
154+
enabled: isHeader,
155+
scrollContainer,
156+
onStuck: (stuck) => {
157+
isStuck = stuck;
158+
}
159+
}}
151160
>
152161
<FileContextMenu
153162
bind:this={contextMenu}
@@ -211,9 +220,14 @@
211220
&.transparent {
212221
background-color: transparent;
213222
}
223+
224+
&.stuck {
225+
border-bottom: 1px solid var(--clr-border-2);
226+
background-color: var(--clr-bg-1);
227+
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.1);
228+
}
214229
}
215230
.filelistitem-header {
216231
z-index: var(--z-lifted);
217-
background-color: var(--clr-bg-1);
218232
}
219233
</style>

apps/desktop/src/components/SelectionView.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
diffOnly?: boolean;
2020
onclose?: () => void;
2121
testId?: string;
22+
scrollContainer?: HTMLDivElement;
2223
bottomBorder?: boolean;
2324
};
2425
@@ -29,6 +30,7 @@
2930
diffOnly,
3031
onclose,
3132
testId,
33+
scrollContainer,
3234
bottomBorder
3335
}: Props = $props();
3436
@@ -75,6 +77,7 @@
7577
<FileListItemWrapper
7678
selectionId={selectedFile}
7779
projectId={env.projectId}
80+
{scrollContainer}
7881
{change}
7982
{diff}
8083
{draggable}

apps/desktop/src/components/StackView.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@
220220
}
221221
}
222222
});
223+
224+
let selectionPreviewScrollContainer: HTMLDivElement | undefined = $state();
223225
</script>
224226

225227
<!-- ATTENTION -->
@@ -237,6 +239,7 @@
237239
<SelectionView
238240
testId={TestId.WorktreeSelectionView}
239241
{projectId}
242+
scrollContainer={selectionPreviewScrollContainer}
240243
selectionId={createWorktreeSelection({ stackId })}
241244
onclose={() => {
242245
idSelection.clear(createWorktreeSelection({ stackId: stackId }));
@@ -541,7 +544,10 @@
541544

542545
<div class="file-preview-section">
543546
{#if assignedStackId}
544-
<ConfigurableScrollableContainer zIndex="var(--z-lifted)">
547+
<ConfigurableScrollableContainer
548+
zIndex="var(--z-lifted)"
549+
bind:viewport={selectionPreviewScrollContainer}
550+
>
545551
{@render assignedChangePreview(assignedStackId)}
546552
</ConfigurableScrollableContainer>
547553
{:else if selectedFile}

apps/desktop/src/components/WorkspaceView.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,19 +130,25 @@
130130
}
131131
});
132132
}
133+
134+
let selectionPreviewScrollContainer: HTMLDivElement | undefined = $state();
133135
</script>
134136

135137
{#snippet right()}
136138
<Feed {projectId} onCloseClick={() => uiState.project(projectId).showActions.set(false)} />
137139
{/snippet}
138140

139141
{#snippet leftPreview()}
140-
<ConfigurableScrollableContainer zIndex="var(--z-lifted)">
142+
<ConfigurableScrollableContainer
143+
bind:viewport={selectionPreviewScrollContainer}
144+
zIndex="var(--z-lifted)"
145+
>
141146
<SelectionView
142147
bottomBorder
143148
{projectId}
144149
{selectionId}
145150
draggableFiles
151+
scrollContainer={selectionPreviewScrollContainer}
146152
onclose={() => {
147153
idSelection.clear(selectionId);
148154
}}

apps/desktop/src/routes/[projectId]/history/+page.svelte

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
import { ID_SELECTION } from '$lib/selection/idSelection.svelte';
1414
import { createSnapshotSelection, type SelectionId } from '$lib/selection/key';
1515
import { inject } from '@gitbutler/shared/context';
16-
import { EmptyStatePlaceholder, Icon, FileViewHeader } from '@gitbutler/ui';
17-
import { stickyHeader } from '@gitbutler/ui/utils/stickyHeader';
16+
import { EmptyStatePlaceholder, Icon } from '@gitbutler/ui';
1817
import type { Snapshot } from '$lib/history/types';
1918
2019
// TODO: Refactor so we don't need non-null assertion.
@@ -80,6 +79,8 @@
8079
idSelection.set(path, selectionId, fileIndex);
8180
currentSelectionId = selectionId;
8281
}
82+
83+
let scrollContainer: HTMLDivElement | undefined = $state();
8384
</script>
8485

8586
{#snippet historyEntries()}
@@ -199,18 +200,8 @@
199200
<div class="history-view__preview dotted-pattern">
200201
{#if selectedFile}
201202
<div class="history-view__preview-file">
202-
<ConfigurableScrollableContainer>
203-
<div use:stickyHeader class="history-view__file-header">
204-
<FileViewHeader
205-
filePath={selectedFile.path}
206-
draggable={false}
207-
oncloseclick={() => {
208-
currentSelectionId = undefined;
209-
}}
210-
/>
211-
</div>
212-
213-
<SelectionView {projectId} diffOnly selectionId={currentSelectionId} />
203+
<ConfigurableScrollableContainer bind:viewport={scrollContainer}>
204+
<SelectionView {projectId} {scrollContainer} selectionId={currentSelectionId} />
214205
</ConfigurableScrollableContainer>
215206
</div>
216207
{:else}
@@ -319,6 +310,7 @@
319310
display: flex;
320311
flex-direction: column;
321312
overflow: hidden;
313+
background-color: var(--clr-bg-1);
322314
}
323315
324316
.history-view__file-header {

packages/ui/src/lib/components/file/FileViewHeader.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@
102102
width: 100%;
103103
padding: 12px 10px 12px 14px;
104104
gap: 12px;
105-
background-color: var(--clr-bg-1);
106105
107106
&.transparent {
108107
background-color: transparent;

packages/ui/src/lib/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,3 @@ export {
141141
export { default as FormattingBar } from '$lib/richText/tools/FormattingBar.svelte';
142142
export { default as FormattingButton } from '$lib/richText/tools/FormattingButton.svelte';
143143
export * from '$lib/utils/testIds';
144-
145-
// Utilities and other exports

packages/ui/src/lib/utils/sticky.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { Action } from 'svelte/action';
2+
3+
interface StickyOptions {
4+
enabled?: boolean;
5+
onStuck?: (isStuck: boolean) => void;
6+
scrollThreshold?: number;
7+
scrollContainer?: Element | Window;
8+
}
9+
10+
export function sticky(
11+
element: HTMLElement,
12+
options: StickyOptions
13+
): ReturnType<Action<HTMLElement, StickyOptions>> {
14+
let { enabled = false, onStuck, scrollThreshold = 4, scrollContainer } = options;
15+
16+
let isStuck = false;
17+
18+
function cleanup() {
19+
if (scrollContainer) {
20+
scrollContainer.removeEventListener('scroll', handleScroll);
21+
}
22+
}
23+
24+
function handleScroll() {
25+
if (!onStuck || !scrollContainer) return;
26+
27+
let newIsStuck = false;
28+
29+
// Get scroll position - works for both window and elements
30+
const scrollTop =
31+
scrollContainer === window ? window.scrollY : (scrollContainer as Element).scrollTop;
32+
const hasScrolled = scrollTop > scrollThreshold;
33+
34+
if (hasScrolled) {
35+
// Only do expensive rect calculations when needed
36+
const containerRect =
37+
scrollContainer === window
38+
? { top: 0 }
39+
: (scrollContainer as Element).getBoundingClientRect();
40+
const elementRect = element.getBoundingClientRect();
41+
newIsStuck = Math.abs(elementRect.top - containerRect.top) <= 2;
42+
}
43+
44+
if (newIsStuck !== isStuck) {
45+
isStuck = newIsStuck;
46+
onStuck(newIsStuck);
47+
}
48+
}
49+
50+
function setup() {
51+
if (!enabled) return;
52+
53+
// Apply styles in one batch to avoid layout thrashing
54+
Object.assign(element.style, {
55+
position: 'sticky',
56+
top: '0px',
57+
zIndex: 'var(--z-lifted)'
58+
});
59+
60+
// Only setup scroll listener when callback is provided and scroll container exists
61+
if (onStuck && scrollContainer) {
62+
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
63+
handleScroll(); // Check initial state
64+
}
65+
}
66+
67+
setup();
68+
69+
return {
70+
update(newOptions: StickyOptions) {
71+
const wasEnabled = enabled;
72+
const oldScrollContainer = scrollContainer;
73+
74+
// Merge options efficiently
75+
Object.assign(options, newOptions);
76+
({ enabled = true, onStuck, scrollThreshold = 4, scrollContainer } = options);
77+
78+
if (!enabled) {
79+
cleanup();
80+
// Clear styles in one batch
81+
Object.assign(element.style, {
82+
position: '',
83+
top: '',
84+
zIndex: ''
85+
});
86+
return;
87+
}
88+
89+
if (!wasEnabled) {
90+
setup();
91+
} else if (scrollContainer !== oldScrollContainer) {
92+
// Scroll container changed - update listener
93+
if (oldScrollContainer) {
94+
oldScrollContainer.removeEventListener('scroll', handleScroll);
95+
}
96+
if (onStuck && scrollContainer) {
97+
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
98+
handleScroll();
99+
}
100+
}
101+
},
102+
destroy() {
103+
cleanup();
104+
// Clear styles in one batch
105+
Object.assign(element.style, {
106+
position: '',
107+
top: '',
108+
zIndex: ''
109+
});
110+
}
111+
};
112+
}

0 commit comments

Comments
 (0)