@kernhq/module-tracker 0.10.1 → 0.11.1
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 +19 -6
- 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,75 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { navigation, Skeleton, session } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
5
|
+
import { statusStyle } from '../index.js'
|
|
6
|
+
import { trackerKeys } from '../query.js'
|
|
7
|
+
import StatusIcon from './StatusIcon.svelte'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How an issue appears when another module refers to one: a chat message that mentions KRN-12, a
|
|
11
|
+
* document that embeds it, a search result. Registered as the tracker's object presenter, so those
|
|
12
|
+
* surfaces do not need to know anything about issues beyond the id.
|
|
13
|
+
*/
|
|
14
|
+
interface Props {
|
|
15
|
+
/** issue id or key */
|
|
16
|
+
id: string
|
|
17
|
+
workspaceId?: string
|
|
18
|
+
}
|
|
19
|
+
let { id, workspaceId }: Props = $props()
|
|
20
|
+
|
|
21
|
+
const api = getTrackerApi()
|
|
22
|
+
const slug = $derived(navigation.workspaceSlug)
|
|
23
|
+
const wsId = $derived(workspaceId ?? session.workspaces.find((w) => w.slug === slug)?.id ?? '')
|
|
24
|
+
const isKey = $derived(/^[A-Z][A-Z0-9]{1,9}-\d+$/.test(id))
|
|
25
|
+
|
|
26
|
+
const issueQuery = createQuery(() => ({
|
|
27
|
+
queryKey: trackerKeys.issue(wsId, id),
|
|
28
|
+
queryFn: () =>
|
|
29
|
+
isKey
|
|
30
|
+
? api.issues.getByKey({ workspaceId: wsId, key: id })
|
|
31
|
+
: api.issues.get({ workspaceId: wsId, issueId: id }),
|
|
32
|
+
enabled: Boolean(wsId),
|
|
33
|
+
}))
|
|
34
|
+
const issue = $derived(issueQuery.data ?? null)
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
{#if !issue}
|
|
38
|
+
<span class="kinline loading"><Skeleton class="h-[14px] w-[140px]" /></span>
|
|
39
|
+
{:else}
|
|
40
|
+
<a class="kinline" href="/{slug}/tracker?issue={issue.key}" style:--tint={statusStyle(issue.statusCategory, issue.statusId).tint}>
|
|
41
|
+
<StatusIcon category={issue.statusCategory} statusId={issue.statusId} size={14} />
|
|
42
|
+
<span class="key">{issue.key}</span>
|
|
43
|
+
<span class="title">{issue.title}</span>
|
|
44
|
+
</a>
|
|
45
|
+
{/if}
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.kinline {
|
|
49
|
+
display: inline-flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 6px;
|
|
52
|
+
max-width: 100%;
|
|
53
|
+
padding: 2px 7px;
|
|
54
|
+
border-radius: var(--kern-r-md);
|
|
55
|
+
background: var(--tint, var(--kern-surface-chip));
|
|
56
|
+
font-size: 12.5px;
|
|
57
|
+
color: var(--kern-ink-800);
|
|
58
|
+
vertical-align: baseline;
|
|
59
|
+
}
|
|
60
|
+
.kinline:hover {
|
|
61
|
+
color: var(--kern-ink-900);
|
|
62
|
+
}
|
|
63
|
+
.key {
|
|
64
|
+
font-family: var(--kern-font-mono);
|
|
65
|
+
font-size: 11.5px;
|
|
66
|
+
letter-spacing: -0.01em;
|
|
67
|
+
color: var(--kern-ink-350);
|
|
68
|
+
}
|
|
69
|
+
.title {
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
text-overflow: ellipsis;
|
|
72
|
+
white-space: nowrap;
|
|
73
|
+
max-width: 28ch;
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { EmptyState } from '@kernhq/ui'
|
|
3
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
4
|
+
import { t } from '../i18n.js'
|
|
5
|
+
import { type GroupBy, type GroupKey, groupIssues, type Issue } from '../index.js'
|
|
6
|
+
import { describeGroup } from '../labels.js'
|
|
7
|
+
import GroupHeader from './GroupHeader.svelte'
|
|
8
|
+
import IssueRow from './IssueRow.svelte'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The issues list (DESIGN.md 3.2).
|
|
12
|
+
*
|
|
13
|
+
* Group headings sit flush under the toolbar and stick while you scroll; empty groups are hidden.
|
|
14
|
+
* Rows are real buttons, so tabbing and screen readers work without any extra wiring, and the page
|
|
15
|
+
* forwards j/k/x/Enter here through `handleKey` so the keyboard works before anything has focus.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
issues: Issue[]
|
|
19
|
+
/** a built-in group key or `cf.<key>` */
|
|
20
|
+
groupBy: string
|
|
21
|
+
selection: Set<string>
|
|
22
|
+
loading?: boolean
|
|
23
|
+
onopen: (issue: Issue) => void
|
|
24
|
+
onselect: (issue: Issue) => void
|
|
25
|
+
oncreate?: (groupKey: GroupKey) => void
|
|
26
|
+
}
|
|
27
|
+
let { issues, groupBy, selection, loading = false, onopen, onselect, oncreate }: Props = $props()
|
|
28
|
+
|
|
29
|
+
const cat = getTrackerCatalogue()
|
|
30
|
+
|
|
31
|
+
let collapsed = $state<string[]>([])
|
|
32
|
+
let cursor = $state(0)
|
|
33
|
+
let listEl = $state<HTMLDivElement | null>(null)
|
|
34
|
+
|
|
35
|
+
const groups = $derived(
|
|
36
|
+
groupIssues(issues, groupBy, {
|
|
37
|
+
statusOrder: cat.statusOrder,
|
|
38
|
+
statusCategory: cat.statusCategory,
|
|
39
|
+
order: (_by, key) => {
|
|
40
|
+
const i = cat.projects.findIndex((p) => p.id === key)
|
|
41
|
+
return i < 0 ? 500 : i
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const keyId = (key: GroupKey) => key ?? '__none__'
|
|
47
|
+
const isCollapsed = (key: GroupKey) => collapsed.includes(keyId(key))
|
|
48
|
+
|
|
49
|
+
/** Rows in visual order, so the cursor moves the way the eye does. */
|
|
50
|
+
const visible = $derived(groups.filter((g) => !isCollapsed(g.key)).flatMap((g) => g.items))
|
|
51
|
+
const indexOf = $derived(new Map(visible.map((issue, i) => [issue.id, i])))
|
|
52
|
+
|
|
53
|
+
function toggleGroup(key: GroupKey) {
|
|
54
|
+
const id = keyId(key)
|
|
55
|
+
collapsed = collapsed.includes(id) ? collapsed.filter((c) => c !== id) : [...collapsed, id]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function move(delta: number) {
|
|
59
|
+
if (visible.length === 0) return
|
|
60
|
+
cursor = Math.max(0, Math.min(visible.length - 1, cursor + delta))
|
|
61
|
+
const key = visible[cursor]?.key
|
|
62
|
+
// rows are looked up by key rather than held in an array of refs: groups collapse and the query
|
|
63
|
+
// changes underneath the cursor, and a stale ref would focus a row that is no longer there
|
|
64
|
+
if (key) listEl?.querySelector<HTMLButtonElement>(`[data-issue-key="${CSS.escape(key)}"]`)?.focus()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Page-level keys for the list. Returns true when the event was consumed, so the page can leave
|
|
69
|
+
* everything else alone.
|
|
70
|
+
*/
|
|
71
|
+
export function handleKey(event: KeyboardEvent): boolean {
|
|
72
|
+
const current = visible[cursor]
|
|
73
|
+
switch (event.key) {
|
|
74
|
+
case 'j':
|
|
75
|
+
case 'ArrowDown':
|
|
76
|
+
move(1)
|
|
77
|
+
return true
|
|
78
|
+
case 'k':
|
|
79
|
+
case 'ArrowUp':
|
|
80
|
+
move(-1)
|
|
81
|
+
return true
|
|
82
|
+
case 'x':
|
|
83
|
+
if (!current) return false
|
|
84
|
+
onselect(current)
|
|
85
|
+
return true
|
|
86
|
+
case 'Enter':
|
|
87
|
+
if (!current) return false
|
|
88
|
+
onopen(current)
|
|
89
|
+
return true
|
|
90
|
+
default:
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// keep the cursor inside the list when filtering or grouping changes the rows underneath it
|
|
96
|
+
$effect(() => {
|
|
97
|
+
if (cursor > visible.length - 1) cursor = Math.max(0, visible.length - 1)
|
|
98
|
+
})
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<div class="klist" bind:this={listEl}>
|
|
102
|
+
{#if groups.length === 0}
|
|
103
|
+
<div class="empty">
|
|
104
|
+
<EmptyState
|
|
105
|
+
icon="square-check-big"
|
|
106
|
+
title={loading ? t('common.loading') : t('empty_title')}
|
|
107
|
+
description={loading ? undefined : t('empty_body')}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
{:else}
|
|
111
|
+
{#each groups as group (keyId(group.key))}
|
|
112
|
+
{@const described = describeGroup(group.key, groupBy, cat)}
|
|
113
|
+
<section>
|
|
114
|
+
<GroupHeader
|
|
115
|
+
label={described.label}
|
|
116
|
+
badge={described.badge}
|
|
117
|
+
count={group.items.length}
|
|
118
|
+
estimate={group.estimate}
|
|
119
|
+
open={!isCollapsed(group.key)}
|
|
120
|
+
onToggle={() => toggleGroup(group.key)}
|
|
121
|
+
onAdd={oncreate ? () => oncreate(group.key) : undefined}
|
|
122
|
+
/>
|
|
123
|
+
{#if !isCollapsed(group.key)}
|
|
124
|
+
{#each group.items as issue (issue.id)}
|
|
125
|
+
{@const index = indexOf.get(issue.id) ?? 0}
|
|
126
|
+
<IssueRow
|
|
127
|
+
{issue}
|
|
128
|
+
selected={selection.has(issue.id)}
|
|
129
|
+
active={index === cursor}
|
|
130
|
+
onopen={(i) => {
|
|
131
|
+
cursor = index
|
|
132
|
+
onopen(i)
|
|
133
|
+
}}
|
|
134
|
+
ontoggle={(i) => {
|
|
135
|
+
cursor = index
|
|
136
|
+
onselect(i)
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
{/each}
|
|
140
|
+
{/if}
|
|
141
|
+
</section>
|
|
142
|
+
{/each}
|
|
143
|
+
{/if}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<style>
|
|
147
|
+
.klist {
|
|
148
|
+
padding-bottom: 40px;
|
|
149
|
+
}
|
|
150
|
+
.empty {
|
|
151
|
+
padding: 40px 28px;
|
|
152
|
+
}
|
|
153
|
+
</style>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Spinner } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import type { Issue } from '../index.js'
|
|
7
|
+
import { trackerKeys } from '../query.js'
|
|
8
|
+
import StatusIcon from './StatusIcon.svelte'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find an issue by key or title.
|
|
12
|
+
*
|
|
13
|
+
* Searching goes through KQL (`text ~ "…"`), the same reader the query box uses, so what is
|
|
14
|
+
* matchable here is exactly what is matchable there — one search behaviour, not two.
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
workspaceId: string
|
|
18
|
+
projectId?: string | null
|
|
19
|
+
/** never offer these — an issue cannot block or parent itself */
|
|
20
|
+
exclude?: string[]
|
|
21
|
+
placeholder?: string
|
|
22
|
+
onpick: (issue: Issue) => void
|
|
23
|
+
oncancel?: () => void
|
|
24
|
+
}
|
|
25
|
+
let { workspaceId, projectId = null, exclude = [], placeholder, onpick, oncancel }: Props = $props()
|
|
26
|
+
|
|
27
|
+
let term = $state('')
|
|
28
|
+
let active = $state(0)
|
|
29
|
+
let box = $state<HTMLInputElement | null>(null)
|
|
30
|
+
|
|
31
|
+
/** Quote for KQL: a title with a `"` in it must not end the string early. */
|
|
32
|
+
const quoted = $derived(term.trim().replace(/["\\]/g, '\\$&'))
|
|
33
|
+
|
|
34
|
+
const results = createQuery(() => ({
|
|
35
|
+
queryKey: trackerKeys.issues(workspaceId, `picker:${projectId ?? 'all'}:${quoted}`),
|
|
36
|
+
queryFn: () =>
|
|
37
|
+
api.issues.query({
|
|
38
|
+
workspaceId,
|
|
39
|
+
kql: `text ~ "${quoted}"`,
|
|
40
|
+
...(projectId ? { projectIds: [projectId] } : {}),
|
|
41
|
+
limit: 8,
|
|
42
|
+
includeArchived: false,
|
|
43
|
+
include: { total: false, groupCounts: false, full: false },
|
|
44
|
+
}),
|
|
45
|
+
enabled: quoted.length >= 2,
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
const api = getTrackerApi()
|
|
49
|
+
const excluded = $derived(new Set(exclude))
|
|
50
|
+
const items = $derived((results.data?.items ?? []).filter((i) => !excluded.has(i.id)))
|
|
51
|
+
|
|
52
|
+
function onkeydown(event: KeyboardEvent) {
|
|
53
|
+
if (event.key === 'ArrowDown') {
|
|
54
|
+
event.preventDefault()
|
|
55
|
+
active = items.length ? (active + 1) % items.length : 0
|
|
56
|
+
} else if (event.key === 'ArrowUp') {
|
|
57
|
+
event.preventDefault()
|
|
58
|
+
active = items.length ? (active - 1 + items.length) % items.length : 0
|
|
59
|
+
} else if (event.key === 'Enter') {
|
|
60
|
+
event.preventDefault()
|
|
61
|
+
const chosen = items[active]
|
|
62
|
+
if (chosen) onpick(chosen)
|
|
63
|
+
} else if (event.key === 'Escape') {
|
|
64
|
+
event.preventDefault()
|
|
65
|
+
oncancel?.()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
$effect(() => {
|
|
70
|
+
box?.focus()
|
|
71
|
+
})
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<div class="picker">
|
|
75
|
+
<input
|
|
76
|
+
bind:this={box}
|
|
77
|
+
bind:value={term}
|
|
78
|
+
type="search"
|
|
79
|
+
placeholder={placeholder ?? t('pick_issue')}
|
|
80
|
+
aria-label={placeholder ?? t('pick_issue')}
|
|
81
|
+
data-testid="issue-picker"
|
|
82
|
+
{onkeydown}
|
|
83
|
+
oninput={() => (active = 0)}
|
|
84
|
+
/>
|
|
85
|
+
{#if quoted.length >= 2}
|
|
86
|
+
<ul class="results" role="listbox">
|
|
87
|
+
{#if results.isPending}
|
|
88
|
+
<li class="state"><Spinner size={14} /></li>
|
|
89
|
+
{:else if !items.length}
|
|
90
|
+
<li class="state">{t('pick_issue_none')}</li>
|
|
91
|
+
{:else}
|
|
92
|
+
{#each items as issue, i (issue.id)}
|
|
93
|
+
<li>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
role="option"
|
|
97
|
+
aria-selected={i === active}
|
|
98
|
+
class:active={i === active}
|
|
99
|
+
onclick={() => onpick(issue)}
|
|
100
|
+
onmouseenter={() => (active = i)}
|
|
101
|
+
>
|
|
102
|
+
<StatusIcon category={issue.statusCategory} statusId={issue.statusId} size={13} />
|
|
103
|
+
<span class="ikey">{issue.key}</span>
|
|
104
|
+
<span class="ititle">{issue.title}</span>
|
|
105
|
+
</button>
|
|
106
|
+
</li>
|
|
107
|
+
{/each}
|
|
108
|
+
{/if}
|
|
109
|
+
</ul>
|
|
110
|
+
{/if}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<style>
|
|
114
|
+
.picker {
|
|
115
|
+
position: relative;
|
|
116
|
+
}
|
|
117
|
+
input {
|
|
118
|
+
width: 100%;
|
|
119
|
+
padding: 5px 8px;
|
|
120
|
+
border: 1px solid var(--kern-border);
|
|
121
|
+
border-radius: var(--kern-r-sm);
|
|
122
|
+
background: var(--kern-surface);
|
|
123
|
+
color: inherit;
|
|
124
|
+
font: inherit;
|
|
125
|
+
font-size: 13px;
|
|
126
|
+
}
|
|
127
|
+
input:focus-visible {
|
|
128
|
+
outline: none;
|
|
129
|
+
border-color: var(--kern-accent);
|
|
130
|
+
box-shadow: none;
|
|
131
|
+
}
|
|
132
|
+
.results {
|
|
133
|
+
position: absolute;
|
|
134
|
+
inset-inline: 0;
|
|
135
|
+
top: calc(100% + 4px);
|
|
136
|
+
z-index: 20;
|
|
137
|
+
max-height: 220px;
|
|
138
|
+
overflow-y: auto;
|
|
139
|
+
margin: 0;
|
|
140
|
+
padding: 4px;
|
|
141
|
+
list-style: none;
|
|
142
|
+
border: 1px solid var(--kern-border);
|
|
143
|
+
border-radius: var(--kern-r-sm);
|
|
144
|
+
background: var(--kern-surface);
|
|
145
|
+
box-shadow: var(--kern-shadow-popover);
|
|
146
|
+
}
|
|
147
|
+
.results button {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 6px;
|
|
151
|
+
width: 100%;
|
|
152
|
+
padding: 4px 6px;
|
|
153
|
+
border: 0;
|
|
154
|
+
border-radius: var(--kern-r-sm);
|
|
155
|
+
background: none;
|
|
156
|
+
color: inherit;
|
|
157
|
+
font: inherit;
|
|
158
|
+
font-size: 13px;
|
|
159
|
+
text-align: start;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
}
|
|
162
|
+
.results button.active {
|
|
163
|
+
background: var(--kern-surface-active);
|
|
164
|
+
}
|
|
165
|
+
.ikey {
|
|
166
|
+
font-family: var(--kern-font-mono);
|
|
167
|
+
font-size: 12px;
|
|
168
|
+
color: var(--kern-ink-350);
|
|
169
|
+
}
|
|
170
|
+
.ititle {
|
|
171
|
+
overflow: hidden;
|
|
172
|
+
text-overflow: ellipsis;
|
|
173
|
+
white-space: nowrap;
|
|
174
|
+
}
|
|
175
|
+
.state {
|
|
176
|
+
padding: 6px;
|
|
177
|
+
font-size: 12px;
|
|
178
|
+
color: var(--kern-ink-350);
|
|
179
|
+
}
|
|
180
|
+
</style>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Avatar, AvatarStack, Icon } from '@kernhq/ui'
|
|
3
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
4
|
+
import { t } from '../i18n.js'
|
|
5
|
+
import type { Issue } from '../index.js'
|
|
6
|
+
import { estimateLabel } from '../labels.js'
|
|
7
|
+
import DueDate from './DueDate.svelte'
|
|
8
|
+
import PriorityGlyph from './PriorityGlyph.svelte'
|
|
9
|
+
import StatusIcon from './StatusIcon.svelte'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One row of the issues list (DESIGN.md 3.2).
|
|
13
|
+
*
|
|
14
|
+
* The grid is fixed so every row lines up down the page: priority, key, status, title, project,
|
|
15
|
+
* estimate, due date, assignee. Selecting swaps the priority cell for a tick, which keeps the
|
|
16
|
+
* columns from shifting when you multi-select.
|
|
17
|
+
*/
|
|
18
|
+
interface Props {
|
|
19
|
+
issue: Issue
|
|
20
|
+
selected?: boolean
|
|
21
|
+
active?: boolean
|
|
22
|
+
onopen: (issue: Issue) => void
|
|
23
|
+
ontoggle: (issue: Issue, event: MouseEvent | KeyboardEvent) => void
|
|
24
|
+
}
|
|
25
|
+
let { issue, selected = false, active = false, onopen, ontoggle }: Props = $props()
|
|
26
|
+
|
|
27
|
+
const cat = getTrackerCatalogue()
|
|
28
|
+
const project = $derived(cat.project(issue.projectId))
|
|
29
|
+
const status = $derived(cat.status(issue.statusId))
|
|
30
|
+
const assignees = $derived(issue.assigneeIds.map((id) => cat.person(id)).filter((p) => p !== undefined))
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
class="krow"
|
|
36
|
+
class:selected
|
|
37
|
+
class:active
|
|
38
|
+
data-testid="issue-row"
|
|
39
|
+
data-issue-key={issue.key}
|
|
40
|
+
aria-current={active ? 'true' : undefined}
|
|
41
|
+
onclick={(e) => (e.metaKey || e.ctrlKey || e.shiftKey ? ontoggle(issue, e) : onopen(issue))}
|
|
42
|
+
>
|
|
43
|
+
<span class="cell mark">
|
|
44
|
+
{#if selected}
|
|
45
|
+
<Icon name="check" size={14} strokeWidth={2} />
|
|
46
|
+
{:else}
|
|
47
|
+
<PriorityGlyph priority={issue.priority} size={15} />
|
|
48
|
+
{/if}
|
|
49
|
+
</span>
|
|
50
|
+
|
|
51
|
+
<span class="cell key">{issue.key}</span>
|
|
52
|
+
|
|
53
|
+
<span class="cell st">
|
|
54
|
+
<StatusIcon
|
|
55
|
+
category={issue.statusCategory}
|
|
56
|
+
statusId={issue.statusId}
|
|
57
|
+
name={status?.name}
|
|
58
|
+
size={15}
|
|
59
|
+
label={status?.name}
|
|
60
|
+
/>
|
|
61
|
+
</span>
|
|
62
|
+
|
|
63
|
+
<span class="cell title">{issue.title}</span>
|
|
64
|
+
|
|
65
|
+
{#if project}
|
|
66
|
+
<span class="cell chip">{project.name}</span>
|
|
67
|
+
{:else}
|
|
68
|
+
<span class="cell"></span>
|
|
69
|
+
{/if}
|
|
70
|
+
|
|
71
|
+
<span class="cell est">
|
|
72
|
+
{#if issue.estimate !== null}{estimateLabel(issue.estimate, issue.estimateUnit)}{/if}
|
|
73
|
+
</span>
|
|
74
|
+
|
|
75
|
+
<span class="cell due">
|
|
76
|
+
{#if issue.dueDate}<DueDate date={issue.dueDate} />{/if}
|
|
77
|
+
</span>
|
|
78
|
+
|
|
79
|
+
<span class="cell people">
|
|
80
|
+
{#if assignees.length === 1}
|
|
81
|
+
<Avatar name={assignees[0]?.name} src={assignees[0]?.avatarUrl} id={assignees[0]?.id} size={22} />
|
|
82
|
+
{:else if assignees.length > 1}
|
|
83
|
+
<AvatarStack people={assignees} size={22} max={2} />
|
|
84
|
+
{/if}
|
|
85
|
+
</span>
|
|
86
|
+
</button>
|
|
87
|
+
|
|
88
|
+
<style>
|
|
89
|
+
.krow {
|
|
90
|
+
display: grid;
|
|
91
|
+
/* the last column is 24px for one avatar and grows for a stack, rather than overlapping the due date */
|
|
92
|
+
grid-template-columns: 18px 62px 18px minmax(0, 1fr) auto auto auto minmax(24px, auto);
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 10px;
|
|
95
|
+
width: 100%;
|
|
96
|
+
height: 40px;
|
|
97
|
+
padding: 0 16px;
|
|
98
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
99
|
+
text-align: start;
|
|
100
|
+
transition: background-color 80ms;
|
|
101
|
+
}
|
|
102
|
+
.krow:hover {
|
|
103
|
+
background: var(--kern-surface-raised);
|
|
104
|
+
}
|
|
105
|
+
/* the keyboard cursor is only drawn while the list has focus, so the list has no extra resting state */
|
|
106
|
+
.krow:focus-visible {
|
|
107
|
+
outline: none;
|
|
108
|
+
background: var(--kern-surface-raised);
|
|
109
|
+
box-shadow: inset 2px 0 0 var(--kern-accent);
|
|
110
|
+
}
|
|
111
|
+
:global([dir='rtl']) .krow:focus-visible {
|
|
112
|
+
box-shadow: inset -2px 0 0 var(--kern-accent);
|
|
113
|
+
}
|
|
114
|
+
.krow.selected {
|
|
115
|
+
background: var(--kern-accent-tint);
|
|
116
|
+
}
|
|
117
|
+
.cell {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
min-width: 0;
|
|
121
|
+
}
|
|
122
|
+
.mark {
|
|
123
|
+
justify-content: center;
|
|
124
|
+
color: var(--kern-accent-deep);
|
|
125
|
+
}
|
|
126
|
+
.key {
|
|
127
|
+
font-family: var(--kern-font-mono);
|
|
128
|
+
letter-spacing: -0.01em;
|
|
129
|
+
font-size: 13px;
|
|
130
|
+
color: var(--kern-ink-350);
|
|
131
|
+
white-space: nowrap;
|
|
132
|
+
overflow: hidden;
|
|
133
|
+
text-overflow: ellipsis;
|
|
134
|
+
}
|
|
135
|
+
.title {
|
|
136
|
+
font-size: 14px;
|
|
137
|
+
color: var(--kern-ink-800);
|
|
138
|
+
white-space: nowrap;
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
text-overflow: ellipsis;
|
|
141
|
+
display: block;
|
|
142
|
+
}
|
|
143
|
+
.chip {
|
|
144
|
+
font-size: 12px;
|
|
145
|
+
padding: 3px 9px;
|
|
146
|
+
border-radius: var(--kern-r-md);
|
|
147
|
+
background: var(--kern-surface-chip);
|
|
148
|
+
color: var(--kern-ink-520);
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
}
|
|
151
|
+
.est,
|
|
152
|
+
.due {
|
|
153
|
+
font-size: 12.5px;
|
|
154
|
+
color: var(--kern-ink-350);
|
|
155
|
+
white-space: nowrap;
|
|
156
|
+
}
|
|
157
|
+
.people {
|
|
158
|
+
justify-content: flex-end;
|
|
159
|
+
}
|
|
160
|
+
@media (max-width: 768px) {
|
|
161
|
+
.krow {
|
|
162
|
+
grid-template-columns: 18px 58px minmax(0, 1fr) auto minmax(24px, auto);
|
|
163
|
+
padding: 0 12px;
|
|
164
|
+
}
|
|
165
|
+
.st,
|
|
166
|
+
.chip,
|
|
167
|
+
.est {
|
|
168
|
+
display: none;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
</style>
|