@kernhq/module-tracker 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -3
- package/src/client/api-instance.ts +34 -0
- package/src/client/components/BoardCard.svelte +182 -0
- package/src/client/components/BoardView.svelte +248 -0
- package/src/client/components/CommentComposer.svelte +219 -0
- package/src/client/components/CommentThread.svelte +389 -0
- package/src/client/components/CustomField.svelte +333 -0
- package/src/client/components/DueDate.svelte +44 -0
- package/src/client/components/FilterMenu.svelte +91 -0
- package/src/client/components/GroupHeader.svelte +137 -0
- package/src/client/components/HomeLinks.svelte +38 -0
- package/src/client/components/IssueApprovals.svelte +217 -0
- package/src/client/components/IssueConnections.svelte +424 -0
- package/src/client/components/IssueDetailPanel.svelte +1337 -0
- package/src/client/components/IssueInline.svelte +75 -0
- package/src/client/components/IssueListView.svelte +153 -0
- package/src/client/components/IssuePicker.svelte +180 -0
- package/src/client/components/IssueRow.svelte +171 -0
- package/src/client/components/IssueTime.svelte +306 -0
- package/src/client/components/KqlInput.svelte +295 -0
- package/src/client/components/NewIssueDialog.svelte +210 -0
- package/src/client/components/NewProjectDialog.svelte +235 -0
- package/src/client/components/PriorityGlyph.svelte +52 -0
- package/src/client/components/SaveViewDialog.svelte +153 -0
- package/src/client/components/SidebarProjects.svelte +263 -0
- package/src/client/components/SidebarViews.svelte +336 -0
- package/src/client/components/StatusIcon.svelte +50 -0
- package/src/client/components/TrackerControls.svelte +109 -0
- package/src/client/components/TrackerSidebar.svelte +96 -0
- package/src/client/context.svelte.ts +105 -0
- package/src/client/core-api.ts +35 -0
- package/src/client/csv.test.ts +50 -0
- package/src/client/csv.ts +60 -0
- package/src/client/filters.ts +94 -0
- package/src/client/i18n.ts +3260 -0
- package/src/client/index.ts +12 -0
- package/src/client/labels.ts +200 -0
- package/src/client/mock.ts +2729 -0
- package/src/client/module.ts +491 -0
- package/src/client/nav.test.ts +59 -0
- package/src/client/nav.ts +84 -0
- package/src/client/pages/IntakePage.svelte +287 -0
- package/src/client/pages/IssuesPage.svelte +701 -0
- package/src/client/pages/ProjectPage.svelte +29 -0
- package/src/client/pages/ReportsPage.svelte +365 -0
- package/src/client/permissions.ts +33 -0
- package/src/client/planning/PlanningSections.svelte +259 -0
- package/src/client/project/ComponentsPage.svelte +423 -0
- package/src/client/project/CyclesPage.svelte +488 -0
- package/src/client/project/MilestonesPage.svelte +342 -0
- package/src/client/project/PlanCard.svelte +191 -0
- package/src/client/project/ProjectShell.svelte +93 -0
- package/src/client/project/TemplatesPage.svelte +321 -0
- package/src/client/project/context.svelte.ts +58 -0
- package/src/client/query.ts +50 -0
- package/src/client/recurrence.test.ts +37 -0
- package/src/client/recurrence.ts +89 -0
- package/src/client/richtext.ts +155 -0
- package/src/client/settings/CycleList.svelte +422 -0
- package/src/client/settings/FieldEditor.svelte +324 -0
- package/src/client/settings/FieldsSettings.svelte +273 -0
- package/src/client/settings/ImportSettings.svelte +410 -0
- package/src/client/settings/LayoutEditor.svelte +263 -0
- package/src/client/settings/PlanningList.svelte +249 -0
- package/src/client/settings/PlanningSettings.svelte +179 -0
- package/src/client/settings/ProjectsSettings.svelte +462 -0
- package/src/client/settings/RepeatingSettings.svelte +293 -0
- package/src/client/settings/TypeEditor.svelte +214 -0
- package/src/client/settings/TypesSettings.svelte +384 -0
- package/src/client/settings/WorkflowsSettings.svelte +645 -0
- package/src/client/settings/mutations.ts +19 -0
- package/src/client/time.test.ts +39 -0
- package/src/client/time.ts +34 -0
- package/src/client/tracker.test.ts +399 -0
- package/src/client/views.ts +27 -0
- package/src/client/widgets/AssignedCountWidget.svelte +14 -0
- package/src/client/widgets/CountWidget.svelte +56 -0
- package/src/client/widgets/CycleWidget.svelte +85 -0
- package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
- package/src/client/widgets/IssuesWidget.svelte +222 -0
- package/src/client/widgets/ThroughputWidget.svelte +68 -0
- package/src/client/widgets/TimerWidget.svelte +116 -0
- package/src/client/widgets/VelocityWidget.svelte +55 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Icon, IconButton, Input, relativeTime, session, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
5
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
6
|
+
import { t } from '../i18n.js'
|
|
7
|
+
import { formatDuration, type Timer, type Worklog } from '../index.js'
|
|
8
|
+
import { canTracker } from '../permissions.js'
|
|
9
|
+
import { trackerKeys } from '../query.js'
|
|
10
|
+
import { parseDuration } from '../time.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Time spent on an issue: a running timer, and the work already logged.
|
|
14
|
+
*
|
|
15
|
+
* The server has had both since the module existed and nothing offered either, so `timeSpentSec`
|
|
16
|
+
* on an issue could only ever be zero. A timer belongs to a person rather than to an issue —
|
|
17
|
+
* starting one anywhere stops the one you had running — so the control says which issue it is on.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
workspaceId: string
|
|
21
|
+
issueId: string
|
|
22
|
+
canLog: boolean
|
|
23
|
+
}
|
|
24
|
+
let { workspaceId, issueId, canLog }: Props = $props()
|
|
25
|
+
|
|
26
|
+
const api = getTrackerApi()
|
|
27
|
+
const cat = getTrackerCatalogue()
|
|
28
|
+
const queryClient = useQueryClient()
|
|
29
|
+
|
|
30
|
+
let manual = $state('')
|
|
31
|
+
let adding = $state(false)
|
|
32
|
+
|
|
33
|
+
const worklogsQuery = createQuery(() => ({
|
|
34
|
+
queryKey: trackerKeys.worklogs(workspaceId, issueId),
|
|
35
|
+
queryFn: () => api.worklogs.list({ workspaceId, issueId }),
|
|
36
|
+
}))
|
|
37
|
+
const timerQuery = createQuery(() => ({
|
|
38
|
+
queryKey: trackerKeys.timer(workspaceId),
|
|
39
|
+
queryFn: () => api.worklogs.timers.current({ workspaceId }),
|
|
40
|
+
// A timer runs while nobody is looking, so its elapsed time has to be refreshed rather than read
|
|
41
|
+
// once and left to drift.
|
|
42
|
+
refetchInterval: 30_000,
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
const worklogs = $derived(worklogsQuery.data ?? [])
|
|
46
|
+
const timer = $derived((timerQuery.data ?? null) as Timer | null)
|
|
47
|
+
const onThisIssue = $derived(timer?.issueId === issueId)
|
|
48
|
+
const total = $derived(worklogs.reduce((sum, w) => sum + w.durationSec, 0))
|
|
49
|
+
|
|
50
|
+
const refresh = () => {
|
|
51
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.worklogs(workspaceId, issueId) })
|
|
52
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.timer(workspaceId) })
|
|
53
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.issue(workspaceId, issueId) })
|
|
54
|
+
}
|
|
55
|
+
const fail = (error: Error) => toast.error(error.message)
|
|
56
|
+
|
|
57
|
+
const start = createMutation(() => ({
|
|
58
|
+
mutationFn: () => api.worklogs.timers.start({ workspaceId, issueId }),
|
|
59
|
+
onSuccess: refresh,
|
|
60
|
+
onError: fail,
|
|
61
|
+
}))
|
|
62
|
+
const stop = createMutation(() => ({
|
|
63
|
+
mutationFn: (discard: boolean) => api.worklogs.timers.stop({ workspaceId, discard }),
|
|
64
|
+
onSuccess: refresh,
|
|
65
|
+
onError: fail,
|
|
66
|
+
}))
|
|
67
|
+
const log = createMutation(() => ({
|
|
68
|
+
mutationFn: (durationSec: number) =>
|
|
69
|
+
api.worklogs.create({ workspaceId, issueId, durationSec, adjustRemaining: 'auto' }),
|
|
70
|
+
onSuccess: () => {
|
|
71
|
+
manual = ''
|
|
72
|
+
adding = false
|
|
73
|
+
refresh()
|
|
74
|
+
},
|
|
75
|
+
onError: fail,
|
|
76
|
+
}))
|
|
77
|
+
const remove = createMutation(() => ({
|
|
78
|
+
mutationFn: (id: string) => api.worklogs.delete({ workspaceId, id }),
|
|
79
|
+
onSuccess: refresh,
|
|
80
|
+
onError: fail,
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Correcting an entry.
|
|
85
|
+
*
|
|
86
|
+
* `worklogs.update` existed from the start and nothing called it, so "2h" typed where "20m" was
|
|
87
|
+
* meant could only be deleted and logged again — which moves the entry to now and loses the day it
|
|
88
|
+
* was worked.
|
|
89
|
+
*/
|
|
90
|
+
const edit = createMutation(() => ({
|
|
91
|
+
mutationFn: (input: { id: string; durationSec: number }) =>
|
|
92
|
+
api.worklogs.update({ workspaceId, id: input.id, patch: { durationSec: input.durationSec } }),
|
|
93
|
+
onSuccess: () => {
|
|
94
|
+
editingId = null
|
|
95
|
+
refresh()
|
|
96
|
+
},
|
|
97
|
+
onError: fail,
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
let editingId = $state<string | null>(null)
|
|
101
|
+
let editDraft = $state('')
|
|
102
|
+
const editParsed = $derived(parseDuration(editDraft))
|
|
103
|
+
|
|
104
|
+
const startEdit = (entry: { id: string; durationSec: number }) => {
|
|
105
|
+
editingId = entry.id
|
|
106
|
+
editDraft = formatDuration(entry.durationSec)
|
|
107
|
+
}
|
|
108
|
+
const commitEdit = () => {
|
|
109
|
+
const id = editingId
|
|
110
|
+
if (!id || editParsed === null) return
|
|
111
|
+
edit.mutate({ id, durationSec: editParsed })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parsed = $derived(parseDuration(manual))
|
|
115
|
+
const elapsed = $derived(
|
|
116
|
+
timer ? Math.max(0, Math.floor((Date.now() - Date.parse(timer.startedAt)) / 1000)) : 0,
|
|
117
|
+
)
|
|
118
|
+
const who = (userId: string) =>
|
|
119
|
+
userId === session.user?.id ? t('time_you') : (cat.person(userId)?.name ?? '')
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<section class="time" data-testid="issue-time">
|
|
123
|
+
<div class="thead">
|
|
124
|
+
<span class="kern-sublabel">{t('time_title')}</span>
|
|
125
|
+
{#if total > 0}<span class="total">{formatDuration(total)}</span>{/if}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{#if canLog}
|
|
129
|
+
<div class="row">
|
|
130
|
+
{#if onThisIssue}
|
|
131
|
+
<Button size="sm" onclick={() => stop.mutate(false)} data-testid="timer-stop">
|
|
132
|
+
<Icon name="clock" size={13} strokeWidth={1.9} />
|
|
133
|
+
{t('time_stop', { elapsed: formatDuration(elapsed) })}
|
|
134
|
+
</Button>
|
|
135
|
+
<Button size="sm" variant="ghost" onclick={() => stop.mutate(true)}>
|
|
136
|
+
{t('time_discard')}
|
|
137
|
+
</Button>
|
|
138
|
+
{:else}
|
|
139
|
+
<Button size="sm" variant="secondary" onclick={() => start.mutate()} data-testid="timer-start">
|
|
140
|
+
<Icon name="clock" size={13} strokeWidth={1.9} />
|
|
141
|
+
{t('time_start')}
|
|
142
|
+
</Button>
|
|
143
|
+
{#if timer}
|
|
144
|
+
<!-- A timer belongs to a person, so starting one here stops the one running elsewhere. -->
|
|
145
|
+
<span class="elsewhere">{t('time_running_elsewhere')}</span>
|
|
146
|
+
{/if}
|
|
147
|
+
{/if}
|
|
148
|
+
|
|
149
|
+
{#if adding}
|
|
150
|
+
<Input
|
|
151
|
+
bind:value={manual}
|
|
152
|
+
placeholder={t('time_placeholder')}
|
|
153
|
+
data-testid="time-amount"
|
|
154
|
+
onkeydown={(e: KeyboardEvent) => {
|
|
155
|
+
if (e.key === 'Enter' && parsed) log.mutate(parsed)
|
|
156
|
+
if (e.key === 'Escape') adding = false
|
|
157
|
+
}}
|
|
158
|
+
/>
|
|
159
|
+
<Button size="sm" disabled={!parsed} onclick={() => parsed && log.mutate(parsed)} data-testid="time-log">
|
|
160
|
+
{t('time_log')}
|
|
161
|
+
</Button>
|
|
162
|
+
{:else}
|
|
163
|
+
<button type="button" class="manual" onclick={() => (adding = true)}>
|
|
164
|
+
{t('time_log_manually')}
|
|
165
|
+
</button>
|
|
166
|
+
{/if}
|
|
167
|
+
</div>
|
|
168
|
+
{/if}
|
|
169
|
+
|
|
170
|
+
{#if worklogs.length}
|
|
171
|
+
<ul class="logs">
|
|
172
|
+
{#each worklogs as entry (entry.id)}
|
|
173
|
+
<li>
|
|
174
|
+
{#if editingId === entry.id}
|
|
175
|
+
<input
|
|
176
|
+
class="edur"
|
|
177
|
+
value={editDraft}
|
|
178
|
+
aria-label={t('time_edit')}
|
|
179
|
+
data-testid="worklog-edit"
|
|
180
|
+
oninput={(e) => (editDraft = e.currentTarget.value)}
|
|
181
|
+
onblur={commitEdit}
|
|
182
|
+
onkeydown={(e) => {
|
|
183
|
+
if (e.key === 'Enter') commitEdit()
|
|
184
|
+
if (e.key === 'Escape') editingId = null
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
{:else if canLog && entry.userId === session.user?.id}
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
class="dur edit"
|
|
191
|
+
title={t('time_edit')}
|
|
192
|
+
onclick={() => startEdit(entry)}
|
|
193
|
+
data-testid="worklog-duration"
|
|
194
|
+
>
|
|
195
|
+
{formatDuration(entry.durationSec)}
|
|
196
|
+
</button>
|
|
197
|
+
{:else}
|
|
198
|
+
<span class="dur">{formatDuration(entry.durationSec)}</span>
|
|
199
|
+
{/if}
|
|
200
|
+
<span class="by">{who(entry.userId)}</span>
|
|
201
|
+
<time datetime={entry.startedAt}>{relativeTime(entry.startedAt)}</time>
|
|
202
|
+
{#if entry.note}<span class="note">{entry.note}</span>{/if}
|
|
203
|
+
{#if canLog && entry.userId === session.user?.id}
|
|
204
|
+
<IconButton
|
|
205
|
+
icon="x"
|
|
206
|
+
size={22}
|
|
207
|
+
label={t('time_remove')}
|
|
208
|
+
onclick={() => remove.mutate(entry.id)}
|
|
209
|
+
/>
|
|
210
|
+
{/if}
|
|
211
|
+
</li>
|
|
212
|
+
{/each}
|
|
213
|
+
</ul>
|
|
214
|
+
{:else if !canLog}
|
|
215
|
+
<p class="empty">{t('time_empty')}</p>
|
|
216
|
+
{/if}
|
|
217
|
+
</section>
|
|
218
|
+
|
|
219
|
+
<style>
|
|
220
|
+
.time {
|
|
221
|
+
margin-top: 16px;
|
|
222
|
+
}
|
|
223
|
+
.thead {
|
|
224
|
+
display: flex;
|
|
225
|
+
align-items: baseline;
|
|
226
|
+
justify-content: space-between;
|
|
227
|
+
}
|
|
228
|
+
.edit {
|
|
229
|
+
padding: 1px 4px;
|
|
230
|
+
margin-inline-start: -4px;
|
|
231
|
+
border: 0;
|
|
232
|
+
border-radius: var(--kern-r-sm);
|
|
233
|
+
background: none;
|
|
234
|
+
color: inherit;
|
|
235
|
+
font: inherit;
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
}
|
|
238
|
+
.edit:hover {
|
|
239
|
+
background: var(--kern-surface-hover);
|
|
240
|
+
}
|
|
241
|
+
.edur {
|
|
242
|
+
width: 72px;
|
|
243
|
+
padding: 1px 4px;
|
|
244
|
+
border: 1px solid var(--kern-border);
|
|
245
|
+
border-radius: var(--kern-r-sm);
|
|
246
|
+
background: var(--kern-surface);
|
|
247
|
+
color: inherit;
|
|
248
|
+
font: inherit;
|
|
249
|
+
}
|
|
250
|
+
.total {
|
|
251
|
+
font-size: 13px;
|
|
252
|
+
font-weight: 600;
|
|
253
|
+
}
|
|
254
|
+
.row {
|
|
255
|
+
display: flex;
|
|
256
|
+
align-items: center;
|
|
257
|
+
gap: 6px;
|
|
258
|
+
margin-top: 6px;
|
|
259
|
+
flex-wrap: wrap;
|
|
260
|
+
}
|
|
261
|
+
.manual,
|
|
262
|
+
.elsewhere {
|
|
263
|
+
border: 0;
|
|
264
|
+
background: none;
|
|
265
|
+
color: var(--kern-ink-350);
|
|
266
|
+
font: inherit;
|
|
267
|
+
font-size: 12px;
|
|
268
|
+
padding: 2px 4px;
|
|
269
|
+
}
|
|
270
|
+
.manual {
|
|
271
|
+
cursor: pointer;
|
|
272
|
+
}
|
|
273
|
+
.manual:hover {
|
|
274
|
+
color: var(--kern-ink-900);
|
|
275
|
+
}
|
|
276
|
+
.logs {
|
|
277
|
+
list-style: none;
|
|
278
|
+
margin: 8px 0 0;
|
|
279
|
+
padding: 0;
|
|
280
|
+
display: flex;
|
|
281
|
+
flex-direction: column;
|
|
282
|
+
gap: 3px;
|
|
283
|
+
}
|
|
284
|
+
.logs li {
|
|
285
|
+
display: flex;
|
|
286
|
+
align-items: center;
|
|
287
|
+
gap: 8px;
|
|
288
|
+
font-size: 12.5px;
|
|
289
|
+
}
|
|
290
|
+
.dur {
|
|
291
|
+
font-weight: 600;
|
|
292
|
+
min-width: 42px;
|
|
293
|
+
}
|
|
294
|
+
.by {
|
|
295
|
+
color: var(--kern-ink-550);
|
|
296
|
+
}
|
|
297
|
+
.note,
|
|
298
|
+
time {
|
|
299
|
+
color: var(--kern-ink-350);
|
|
300
|
+
}
|
|
301
|
+
.empty {
|
|
302
|
+
margin: 6px 0 0;
|
|
303
|
+
font-size: 13px;
|
|
304
|
+
color: var(--kern-ink-350);
|
|
305
|
+
}
|
|
306
|
+
</style>
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Icon } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import type { KqlField, KqlSuggestion } from '../index.js'
|
|
5
|
+
import { parseKql } from '../index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The KQL box in the toolbar.
|
|
9
|
+
*
|
|
10
|
+
* It validates as you type with the same reader the server uses, so a mistake is reported next to
|
|
11
|
+
* the caret instead of after a round trip, and it offers the next token: a field, then an operator,
|
|
12
|
+
* then the values that field actually has. Enter applies the query; a query that does not parse is
|
|
13
|
+
* never applied, so the list cannot silently show the wrong thing.
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
/** the applied query; only updated on submit or clear */
|
|
17
|
+
value: string
|
|
18
|
+
fields: KqlField[]
|
|
19
|
+
onapply: (kql: string) => void
|
|
20
|
+
}
|
|
21
|
+
let { value, fields, onapply }: Props = $props()
|
|
22
|
+
|
|
23
|
+
let draft = $state('')
|
|
24
|
+
let cursor = $state(0)
|
|
25
|
+
let focused = $state(false)
|
|
26
|
+
let highlighted = $state(0)
|
|
27
|
+
let input = $state<HTMLInputElement | null>(null)
|
|
28
|
+
let box = $state<HTMLDivElement | null>(null)
|
|
29
|
+
/**
|
|
30
|
+
* The toolbar clips its own overflow, so the error and the suggestions are positioned against the
|
|
31
|
+
* viewport and measured from the field rather than being laid out inside it.
|
|
32
|
+
*/
|
|
33
|
+
let anchor = $state({ top: 0, left: 0, width: 0 })
|
|
34
|
+
function measure() {
|
|
35
|
+
const r = box?.getBoundingClientRect()
|
|
36
|
+
if (r) anchor = { top: r.bottom + 6, left: r.left, width: r.width }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// adopt the applied query when it changes from elsewhere (a preset tab, a cleared filter)
|
|
40
|
+
$effect(() => {
|
|
41
|
+
draft = value
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const parsed = $derived(parseKql(draft, { fields, cursor }))
|
|
45
|
+
const suggestions = $derived(focused ? parsed.suggestions.slice(0, 8) : [])
|
|
46
|
+
const problem = $derived(draft.trim() && !parsed.ok ? (parsed.errors[0] ?? null) : null)
|
|
47
|
+
/**
|
|
48
|
+
* A half-typed query is not a mistake. While there is something useful to offer, the box guides;
|
|
49
|
+
* only when nothing fits does it say what is wrong. Enter refuses either way until it parses.
|
|
50
|
+
*/
|
|
51
|
+
const error = $derived(problem && suggestions.length === 0 ? problem : null)
|
|
52
|
+
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (highlighted > suggestions.length - 1) highlighted = 0
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function accept(suggestion: KqlSuggestion) {
|
|
58
|
+
// replace the partial token under the caret rather than appending to it
|
|
59
|
+
const before = draft.slice(0, cursor)
|
|
60
|
+
const tokenStart = before.search(/[\w.]+$/)
|
|
61
|
+
const head = tokenStart >= 0 ? before.slice(0, tokenStart) : before
|
|
62
|
+
draft = head + suggestion.insertText + draft.slice(cursor)
|
|
63
|
+
const next = (head + suggestion.insertText).length
|
|
64
|
+
queueMicrotask(() => {
|
|
65
|
+
input?.setSelectionRange(next, next)
|
|
66
|
+
input?.focus()
|
|
67
|
+
cursor = next
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function onkeydown(event: KeyboardEvent) {
|
|
72
|
+
if (suggestions.length && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
|
73
|
+
event.preventDefault()
|
|
74
|
+
const delta = event.key === 'ArrowDown' ? 1 : -1
|
|
75
|
+
highlighted = (highlighted + delta + suggestions.length) % suggestions.length
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (event.key === 'Tab' && suggestions.length) {
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
const picked = suggestions[highlighted]
|
|
81
|
+
if (picked) accept(picked)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (event.key === 'Enter') {
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
if (suggestions.length && highlighted > 0) {
|
|
87
|
+
const picked = suggestions[highlighted]
|
|
88
|
+
if (picked) accept(picked)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (parsed.ok) {
|
|
92
|
+
onapply(draft.trim())
|
|
93
|
+
input?.blur()
|
|
94
|
+
}
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (event.key === 'Escape') {
|
|
98
|
+
if (focused) {
|
|
99
|
+
event.stopPropagation()
|
|
100
|
+
draft = value
|
|
101
|
+
input?.blur()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function track(event: Event) {
|
|
107
|
+
cursor = (event.currentTarget as HTMLInputElement).selectionStart ?? draft.length
|
|
108
|
+
measure()
|
|
109
|
+
}
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<svelte:window onresize={() => focused && measure()} />
|
|
113
|
+
|
|
114
|
+
<div class="kkql" class:invalid={Boolean(error)} bind:this={box}>
|
|
115
|
+
<Icon name="search" size={14} strokeWidth={1.7} />
|
|
116
|
+
<input
|
|
117
|
+
bind:this={input}
|
|
118
|
+
bind:value={draft}
|
|
119
|
+
type="text"
|
|
120
|
+
spellcheck="false"
|
|
121
|
+
autocomplete="off"
|
|
122
|
+
data-testid="kql-input"
|
|
123
|
+
placeholder={t('kql_placeholder')}
|
|
124
|
+
aria-label={t('kql_label')}
|
|
125
|
+
aria-invalid={error ? 'true' : undefined}
|
|
126
|
+
aria-describedby={error ? 'kql-error' : undefined}
|
|
127
|
+
onfocus={() => {
|
|
128
|
+
focused = true
|
|
129
|
+
measure()
|
|
130
|
+
}}
|
|
131
|
+
onblur={() => setTimeout(() => (focused = false), 120)}
|
|
132
|
+
oninput={track}
|
|
133
|
+
onclick={track}
|
|
134
|
+
onkeyup={track}
|
|
135
|
+
{onkeydown}
|
|
136
|
+
/>
|
|
137
|
+
{#if draft}
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
class="clear"
|
|
141
|
+
aria-label={t('kql_clear')}
|
|
142
|
+
onclick={() => {
|
|
143
|
+
draft = ''
|
|
144
|
+
onapply('')
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<Icon name="x" size={12} strokeWidth={2} />
|
|
148
|
+
</button>
|
|
149
|
+
{/if}
|
|
150
|
+
|
|
151
|
+
{#if focused && suggestions.length}
|
|
152
|
+
<ul class="sug" style:top="{anchor.top}px" style:left="{anchor.left}px" style:width="{anchor.width}px">
|
|
153
|
+
{#each suggestions as suggestion, i (suggestion.kind + suggestion.label)}
|
|
154
|
+
<li>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
class:on={i === highlighted}
|
|
158
|
+
onmousedown={(e) => {
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
accept(suggestion)
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
<span class="kind">{suggestion.kind}</span>
|
|
164
|
+
<span class="lbl">{suggestion.label}</span>
|
|
165
|
+
{#if suggestion.detail}<span class="det">{suggestion.detail}</span>{/if}
|
|
166
|
+
</button>
|
|
167
|
+
</li>
|
|
168
|
+
{/each}
|
|
169
|
+
</ul>
|
|
170
|
+
{:else if error}
|
|
171
|
+
<p
|
|
172
|
+
class="err"
|
|
173
|
+
id="kql-error"
|
|
174
|
+
role="status"
|
|
175
|
+
style:top="{anchor.top}px"
|
|
176
|
+
style:left="{anchor.left}px"
|
|
177
|
+
style:width="{anchor.width}px"
|
|
178
|
+
>
|
|
179
|
+
{error.message}
|
|
180
|
+
</p>
|
|
181
|
+
{/if}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<style>
|
|
185
|
+
.kkql {
|
|
186
|
+
position: relative;
|
|
187
|
+
display: flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
gap: 8px;
|
|
190
|
+
height: 30px;
|
|
191
|
+
min-width: 180px;
|
|
192
|
+
flex: 1 1 240px;
|
|
193
|
+
max-width: 460px;
|
|
194
|
+
padding: 0 10px;
|
|
195
|
+
border: 1px solid var(--kern-border);
|
|
196
|
+
border-radius: var(--kern-r-lg);
|
|
197
|
+
background: var(--kern-surface-input);
|
|
198
|
+
color: var(--kern-ink-250);
|
|
199
|
+
}
|
|
200
|
+
.kkql:focus-within {
|
|
201
|
+
border-color: var(--kern-accent);
|
|
202
|
+
box-shadow: 0 0 0 3px var(--kern-ring);
|
|
203
|
+
}
|
|
204
|
+
.kkql.invalid {
|
|
205
|
+
border-color: var(--kern-danger);
|
|
206
|
+
}
|
|
207
|
+
input {
|
|
208
|
+
flex: 1;
|
|
209
|
+
min-width: 0;
|
|
210
|
+
border: 0;
|
|
211
|
+
background: transparent;
|
|
212
|
+
font-family: var(--kern-font-mono);
|
|
213
|
+
font-size: 12.5px;
|
|
214
|
+
letter-spacing: -0.01em;
|
|
215
|
+
color: var(--kern-ink-800);
|
|
216
|
+
}
|
|
217
|
+
input:focus-visible {
|
|
218
|
+
box-shadow: none;
|
|
219
|
+
}
|
|
220
|
+
.clear {
|
|
221
|
+
display: grid;
|
|
222
|
+
place-items: center;
|
|
223
|
+
color: var(--kern-ink-350);
|
|
224
|
+
}
|
|
225
|
+
.clear:hover {
|
|
226
|
+
color: var(--kern-ink-900);
|
|
227
|
+
}
|
|
228
|
+
.err,
|
|
229
|
+
.sug {
|
|
230
|
+
position: fixed;
|
|
231
|
+
z-index: 30;
|
|
232
|
+
margin: 0;
|
|
233
|
+
background: var(--kern-surface-raised);
|
|
234
|
+
border-radius: var(--kern-r-2xl);
|
|
235
|
+
box-shadow: var(--kern-shadow-popover);
|
|
236
|
+
animation: kfade 0.12s ease-out;
|
|
237
|
+
}
|
|
238
|
+
.err {
|
|
239
|
+
padding: 8px 12px;
|
|
240
|
+
font-size: 12.5px;
|
|
241
|
+
color: var(--kern-danger);
|
|
242
|
+
}
|
|
243
|
+
.sug {
|
|
244
|
+
list-style: none;
|
|
245
|
+
padding: 4px;
|
|
246
|
+
/* the field is narrow; the list is not bound by it */
|
|
247
|
+
min-width: 340px;
|
|
248
|
+
max-width: min(520px, 90vw);
|
|
249
|
+
max-height: 260px;
|
|
250
|
+
overflow-y: auto;
|
|
251
|
+
}
|
|
252
|
+
.sug button {
|
|
253
|
+
display: flex;
|
|
254
|
+
align-items: baseline;
|
|
255
|
+
gap: 9px;
|
|
256
|
+
width: 100%;
|
|
257
|
+
padding: 6px 9px;
|
|
258
|
+
border-radius: var(--kern-r-md);
|
|
259
|
+
text-align: start;
|
|
260
|
+
}
|
|
261
|
+
.sug button:hover,
|
|
262
|
+
.sug button.on {
|
|
263
|
+
background: var(--kern-surface-popover-hover);
|
|
264
|
+
}
|
|
265
|
+
.kind {
|
|
266
|
+
font-family: var(--kern-font-mono);
|
|
267
|
+
font-size: 10px;
|
|
268
|
+
text-transform: uppercase;
|
|
269
|
+
letter-spacing: 0.1em;
|
|
270
|
+
color: var(--kern-ink-250);
|
|
271
|
+
width: 58px;
|
|
272
|
+
flex: none;
|
|
273
|
+
}
|
|
274
|
+
.lbl {
|
|
275
|
+
font-family: var(--kern-font-mono);
|
|
276
|
+
font-size: 12.5px;
|
|
277
|
+
color: var(--kern-ink-800);
|
|
278
|
+
white-space: nowrap;
|
|
279
|
+
}
|
|
280
|
+
.det {
|
|
281
|
+
font-size: 12px;
|
|
282
|
+
color: var(--kern-ink-350);
|
|
283
|
+
margin-inline-start: auto;
|
|
284
|
+
padding-inline-start: 12px;
|
|
285
|
+
min-width: 0;
|
|
286
|
+
overflow: hidden;
|
|
287
|
+
text-overflow: ellipsis;
|
|
288
|
+
white-space: nowrap;
|
|
289
|
+
}
|
|
290
|
+
@media (max-width: 900px) {
|
|
291
|
+
.kkql {
|
|
292
|
+
display: none;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
</style>
|