Skip to content

Commit c74e2cd

Browse files
refactor: Dependency dropdown in separate component
1 parent 15b0cdc commit c74e2cd

File tree

3 files changed

+275
-330
lines changed

3 files changed

+275
-330
lines changed

src/ui/Dependency.svelte

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<script lang="ts">
2+
import type {Task} from "../Task/Task";
3+
import {computePosition, flip, offset, shift, size} from "@floating-ui/dom";
4+
import type {EditableTask} from "./EditableTask";
5+
6+
export let task: Task;
7+
export let editableTask: EditableTask;
8+
export let allTasks: Task[];
9+
export let _onDescriptionKeyDown: (e: KeyboardEvent) => void;
10+
export let type: "blocking" | "blockedBy";
11+
export let accesskey: (key: string) => string | null;
12+
13+
let existingTasks = type === "blocking" ? editableTask.blocking : editableTask.blockedBy;
14+
15+
console.log(`blockedBy: ${editableTask.blockedBy.map(task => task.description)}`)
16+
17+
let search: string = '';
18+
let searchResults: Task[] | null = null;
19+
let searchIndex: number | null = 0;
20+
21+
let depInputWidth: number;
22+
23+
let inputFocused = false;
24+
25+
let displayResultsIfSearchEmpty = false;
26+
27+
function onFocused() {
28+
inputFocused = true;
29+
displayResultsIfSearchEmpty = true;
30+
}
31+
32+
33+
let dependencyInput: HTMLElement;
34+
let dependencyDropdown: HTMLElement;
35+
36+
function positionDropdown(ref: HTMLElement, content: HTMLElement) {
37+
if (!ref || !content) return;
38+
39+
computePosition(ref, content, {
40+
middleware: [
41+
offset(6),
42+
shift(),
43+
flip(),
44+
size({
45+
apply() {
46+
content && Object.assign(content.style, { width: `${depInputWidth}px` });
47+
},
48+
}),
49+
],
50+
}).then(({ x, y }) => {
51+
Object.assign(content.style, {
52+
left: `${x}px`,
53+
top: `${y}px`,
54+
});
55+
});
56+
}
57+
58+
const _displayableFilePath = (path: string) => {
59+
if (path === task.taskLocation.path) return "";
60+
61+
return path.slice(0,-3);
62+
}
63+
64+
$: {
65+
positionDropdown(dependencyInput, dependencyDropdown);
66+
}
67+
68+
$: {
69+
searchResults = inputFocused ? generateSearchResults(search) : null;
70+
}
71+
72+
function taskKeydown(e: KeyboardEvent) {
73+
if (searchResults === null) return;
74+
75+
switch(e.key) {
76+
case "ArrowUp":
77+
e.preventDefault();
78+
if (searchIndex === 0 || searchIndex === null) {
79+
searchIndex = searchResults.length - 1;
80+
} else {
81+
searchIndex -= 1;
82+
}
83+
break;
84+
case "ArrowDown":
85+
e.preventDefault();
86+
if (searchIndex === searchResults.length - 1 || searchIndex === null) {
87+
searchIndex = 0;
88+
} else {
89+
searchIndex += 1;
90+
}
91+
break;
92+
case "Enter":
93+
if (searchIndex !== null) {
94+
e.preventDefault();
95+
addTask(searchResults[searchIndex]);
96+
searchIndex = null;
97+
inputFocused = false
98+
} else {
99+
_onDescriptionKeyDown(e);
100+
}
101+
break;
102+
default:
103+
searchIndex = 0;
104+
break;
105+
}
106+
searchIndex = searchIndex;
107+
if (searchIndex !== null) {
108+
dependencyDropdown?.getElementsByTagName('li')[searchIndex]?.scrollIntoView(false)
109+
}
110+
}
111+
112+
function showDescriptionTooltip(element: HTMLElement, text: string) {
113+
const tooltip = element.createDiv();
114+
tooltip.addClasses(['tooltip', 'pop-up']);
115+
tooltip.innerText = text;
116+
117+
computePosition(element, tooltip, {
118+
placement: "top",
119+
middleware: [
120+
offset(-18),
121+
shift()
122+
]
123+
}).then(({x, y}) => {
124+
Object.assign(tooltip.style, {
125+
left: `${x}px`,
126+
top: `${y}px`,
127+
});
128+
});
129+
130+
element.addEventListener('mouseleave', () => {
131+
tooltip.remove();
132+
});
133+
}
134+
135+
136+
function generateSearchResults(search: string) {
137+
if (!search && !displayResultsIfSearchEmpty) return [];
138+
139+
displayResultsIfSearchEmpty = false;
140+
141+
let results = allTasks.filter(task => task.description.toLowerCase().includes(search.toLowerCase()));
142+
143+
// remove itself, and tasks this task already has a relationship with from results
144+
results = results.filter((item) => {
145+
// line number is unavailable for the task being edited
146+
// Known issue - filters out duplicate lines in task file
147+
const sameFile = item.description === task.description &&
148+
item.taskLocation.path === task.taskLocation.path &&
149+
item.originalMarkdown === task.originalMarkdown
150+
151+
return ![...editableTask.blockedBy, ...editableTask.blocking].includes(item) && !sameFile;
152+
});
153+
154+
// search results favour tasks from the same file as this task
155+
results.sort((a, b) => {
156+
const aInSamePath = a.taskLocation.path === task.taskLocation.path;
157+
const bInSamePath = b.taskLocation.path === task.taskLocation.path;
158+
159+
// prioritise tasks close to this task in the same file
160+
if (aInSamePath && bInSamePath) {
161+
return Math.abs(a.taskLocation.lineNumber - task.taskLocation.lineNumber)
162+
- Math.abs(b.taskLocation.lineNumber - task.taskLocation.lineNumber);
163+
} else if (aInSamePath) {
164+
return -1;
165+
} else if (bInSamePath) {
166+
return 1;
167+
} else {
168+
return 0;
169+
}
170+
});
171+
172+
return results.slice(0,20);
173+
}
174+
175+
function addTask(task: Task) {
176+
existingTasks = [...existingTasks, task];
177+
if (type === "blocking") {
178+
editableTask.blocking = existingTasks;
179+
} else {
180+
editableTask.blockedBy = existingTasks;
181+
}
182+
search = '';
183+
inputFocused = false;
184+
}
185+
186+
function removeTask(task: Task) {
187+
existingTasks = existingTasks.filter(item => item !== task);
188+
if (type === "blocking") {
189+
editableTask.blocking = existingTasks;
190+
} else {
191+
editableTask.blockedBy = existingTasks;
192+
}
193+
}
194+
</script>
195+
196+
<!-- svelte-ignore a11y-accesskey -->
197+
198+
<span class="input" bind:clientWidth={depInputWidth}>
199+
<input
200+
bind:this={dependencyInput}
201+
bind:value={search}
202+
on:keydown={(e) => taskKeydown(e)}
203+
on:focus={onFocused}
204+
on:blur={() => inputFocused = false}
205+
accesskey={accesskey("b")}
206+
id="{type}"
207+
class="input"
208+
type="text"
209+
placeholder="Type to search..."
210+
/>
211+
</span>
212+
{#if searchResults && searchResults.length !== 0}
213+
<ul class="suggested-tasks"
214+
bind:this={dependencyDropdown}
215+
on:mouseleave={() => searchIndex = null}>
216+
{#each searchResults as searchTask, index}
217+
{@const filepath = _displayableFilePath(searchTask.taskLocation.path)}
218+
<!-- svelte-ignore a11y-click-events-have-key-events -->
219+
<li on:mousedown={() => addTask(searchTask)}
220+
class:selected={search !== null && index === searchIndex}
221+
on:mouseenter={() => searchIndex = index}>
222+
<div class="{filepath ? 'dependency-name-shared' : 'dependency-name'}"
223+
on:mouseenter={(e) => showDescriptionTooltip(e.currentTarget, searchTask.descriptionWithoutTags)}>
224+
[{searchTask.status.symbol}] {searchTask.descriptionWithoutTags}
225+
</div>
226+
{#if filepath}
227+
<div class="dependency-location"
228+
on:mouseenter={(e) => showDescriptionTooltip(e.currentTarget, filepath)}>
229+
{filepath}
230+
</div>
231+
{/if}
232+
</li>
233+
{/each}
234+
</ul>
235+
{/if}
236+
<div class="chip-container results">
237+
{#each existingTasks as task}
238+
<div class="chip"
239+
on:mouseenter={(e) => showDescriptionTooltip(e.currentTarget, task.descriptionWithoutTags)}>
240+
<span class="chip-name">[{task.status.symbol}] {task.descriptionWithoutTags}</span>
241+
242+
<button on:click={() => removeTask(task)} type="button" class="chip-close">
243+
<svg style="display: block; margin: auto;" xmlns="http://www.w3.org/2000/svg" width="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
244+
</button>
245+
</div>
246+
{/each}
247+
</div>

0 commit comments

Comments
 (0)