feat: add GitHub repository dashboard with Prometheus metrics

ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics.

The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary.

The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
2026-10-02 04:28:52 +01:00
commit 5313b56774
110 changed files with 10967 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ ratio: number; severity: 'warn' | 'crit' }>()
const SEGMENTS = 10
const filled = computed(() => Math.min(SEGMENTS, Math.max(1, Math.ceil(props.ratio * SEGMENTS))))
</script>
<template>
<span
class="inline-flex gap-px"
role="img"
:aria-label="`${Math.round(Math.min(ratio, 1) * 100)}% of the critical age`"
>
<span
v-for="i in SEGMENTS"
:key="i"
class="h-3 w-1 rounded-[1px]"
:class="i <= filled ? (severity === 'crit' ? 'bg-crit' : 'bg-warn') : 'bg-border'"
/>
</span>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { faArrowsRotate, faSatelliteDish } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { ageLabel, ageSince, isZeroTime } from '@/lib/format'
const props = defineProps<{
lastRefresh: string
nowMs: number
intervalSeconds: number
loaded: boolean
scanning: boolean
manualRefresh: boolean
}>()
defineEmits<{ rescan: [] }>()
const updated = computed(() =>
props.loaded && !isZeroTime(props.lastRefresh) ? ageSince(props.lastRefresh, props.nowMs) : '',
)
const nextIn = computed(() => {
if (!updated.value) return ''
const left = Math.floor(
(Date.parse(props.lastRefresh) + props.intervalSeconds * 1000 - props.nowMs) / 1000,
)
return left > 0 ? ageLabel(left) : 'now'
})
</script>
<template>
<header class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span
class="border-border bg-surface text-link flex size-9 items-center justify-center rounded-md border"
>
<FontAwesomeIcon :icon="faSatelliteDish" aria-hidden="true" />
</span>
<span class="text-lg font-semibold tracking-tight">ghtracker</span>
</div>
<div class="text-muted-foreground flex items-center gap-5 text-sm">
<span v-if="updated" class="hidden sm:inline" data-test="updated">
Scanned {{ updated }} ago
<span class="text-muted-foreground/70"> · next in {{ nextIn }}</span>
</span>
<span v-else>Loading</span>
<Button
v-if="manualRefresh"
variant="outline"
size="sm"
:disabled="scanning"
data-test="rescan"
@click="$emit('rescan')"
>
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}
</Button>
</div>
</header>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref } from 'vue'
import FilterBar from '@/components/FilterBar.vue'
import RepoGroup from '@/components/RepoGroup.vue'
import type { Facets, Filters } from '@/lib/filters'
import type { Block } from '@/lib/types'
defineProps<{
blocks: Block[]
nowMs: number
critSeconds: number
facets: Facets
owners: string[]
active: number
}>()
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
const bar = ref<InstanceType<typeof FilterBar> | null>(null)
defineExpose({ focusSearch: () => bar.value?.focus() })
</script>
<template>
<section aria-labelledby="attention-title">
<h2 id="attention-title" class="mb-4 text-lg font-semibold tracking-tight">Needs attention</h2>
<FilterBar
ref="bar"
v-model:filters="filters"
:facets="facets"
:owners="owners"
:active="active"
class="mb-5"
@clear="$emit('clear')"
/>
<div
v-if="blocks.length === 0"
class="border-border text-muted-foreground flex items-center gap-3 rounded-lg border border-dashed p-6"
data-test="empty"
>
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
Nothing matches the current filters.
</div>
<div v-else class="flex flex-col gap-4">
<RepoGroup
v-for="block in blocks"
:key="block.repo.fullName"
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</div>
</section>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AgeMeter from '@/components/AgeMeter.vue'
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
import { iconFor } from '@/lib/kinds'
import type { Row } from '@/lib/types'
const props = defineProps<{ row: Row; nowMs: number; critSeconds: number }>()
const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.critSeconds)
</script>
<template>
<li class="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-x-3 py-3" data-test="row">
<span
class="flex size-8 items-center justify-center rounded-md"
:class="row.severity === 'crit' ? 'bg-crit/12 text-crit' : 'bg-warn/12 text-warn'"
>
<FontAwesomeIcon :icon="iconFor(row.kind)" aria-hidden="true" />
<span class="sr-only">{{ row.severity === 'crit' ? 'Critical' : 'Warning' }}</span>
</span>
<div class="min-w-0">
<a
:href="row.url"
class="hover:text-link leading-snug font-medium wrap-anywhere underline-offset-4 hover:underline"
>
{{ shorten(row.subject) }}
</a>
<p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere">
{{ shorten(row.detail, 120) }}
</p>
</div>
<div class="flex flex-col items-end gap-1.5 pt-0.5">
<span class="text-muted-foreground font-mono text-xs whitespace-nowrap">
{{ isZeroTime(row.since) ? '' : ageSince(row.since, nowMs) }}
</span>
<AgeMeter
v-if="row.kind === 'pr_waiting' && !isZeroTime(row.since)"
:ratio="ratio"
:severity="row.severity"
/>
</div>
</li>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import {
faCircleDot,
faCodePullRequest,
faGears,
faMagnifyingGlass,
faTriangleExclamation,
faXmark,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { KIND_GROUPS, type Facets, type Filters, type KindGroup } from '@/lib/filters'
import type { SeverityFilter } from '@/lib/filters'
defineProps<{ facets: Facets; owners: string[]; active: number }>()
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
const KIND_META: Record<KindGroup, { label: string; icon: IconDefinition }> = {
workflow: { label: 'Workflows', icon: faGears },
pr: { label: 'Pull requests', icon: faCodePullRequest },
issue: { label: 'Issues', icon: faCircleDot },
data: { label: 'Data', icon: faTriangleExclamation },
}
const search = ref<InstanceType<typeof Input> | null>(null)
defineExpose({ focus: () => (search.value?.$el as HTMLInputElement | undefined)?.focus() })
function patch(change: Partial<Filters>): void {
filters.value = { ...filters.value, ...change }
}
function onSeverity(value: unknown): void {
if (value === 'all' || value === 'crit' || value === 'warn')
patch({ severity: value as SeverityFilter })
else patch({ severity: 'all' })
}
function onKinds(value: unknown): void {
patch({ kinds: (Array.isArray(value) ? value : []) as KindGroup[] })
}
function onOwners(value: unknown): void {
patch({ owners: (Array.isArray(value) ? value : []) as string[] })
}
</script>
<template>
<div class="flex flex-col gap-3" data-test="filter-bar">
<div class="flex flex-wrap items-center gap-3">
<div class="relative min-w-56 flex-1">
<FontAwesomeIcon
:icon="faMagnifyingGlass"
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
aria-hidden="true"
/>
<Input
ref="search"
:model-value="filters.query"
type="search"
placeholder="Search items, projects and versions"
aria-label="Search"
class="bg-surface pr-9 pl-9"
data-test="search"
@update:model-value="patch({ query: String($event ?? '') })"
@keydown.esc="patch({ query: '' })"
/>
<kbd
v-if="!filters.query"
class="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded border px-1.5 font-mono text-[11px]"
aria-hidden="true"
>/</kbd
>
</div>
<ToggleGroup
type="single"
variant="outline"
size="sm"
:model-value="filters.severity"
aria-label="Severity"
@update:model-value="onSeverity"
>
<ToggleGroupItem value="all" data-test="sev-all"
>All {{ facets.severity.all }}</ToggleGroupItem
>
<ToggleGroupItem value="crit" data-test="sev-crit"
>Critical {{ facets.severity.crit }}</ToggleGroupItem
>
<ToggleGroupItem value="warn" data-test="sev-warn"
>Warnings {{ facets.severity.warn }}</ToggleGroupItem
>
</ToggleGroup>
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleGroup
type="multiple"
variant="outline"
size="sm"
:model-value="filters.kinds"
aria-label="Kind"
@update:model-value="onKinds"
>
<ToggleGroupItem
v-for="kind in KIND_GROUPS"
:key="kind"
:value="kind"
:disabled="facets.kinds[kind] === 0 && !filters.kinds.includes(kind)"
:data-test="`kind-${kind}`"
>
<FontAwesomeIcon :icon="KIND_META[kind].icon" aria-hidden="true" />
{{ KIND_META[kind].label }}
<span class="text-muted-foreground tabular-nums">{{ facets.kinds[kind] }}</span>
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
v-if="owners.length > 1"
type="multiple"
variant="outline"
size="sm"
:model-value="filters.owners"
aria-label="Owner"
@update:model-value="onOwners"
>
<ToggleGroupItem
v-for="owner in owners"
:key="owner"
:value="owner"
:disabled="facets.owners[owner] === 0 && !filters.owners.includes(owner)"
:data-test="`owner-${owner}`"
>
{{ owner }}
<span class="text-muted-foreground tabular-nums">{{ facets.owners[owner] }}</span>
</ToggleGroupItem>
</ToggleGroup>
<Button
v-if="active > 0"
variant="ghost"
size="sm"
class="ml-auto"
data-test="clear-all"
@click="$emit('clear')"
>
<FontAwesomeIcon :icon="faXmark" aria-hidden="true" />
Clear {{ active }} {{ active === 1 ? 'filter' : 'filters' }}
</Button>
</div>
</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import {
faCodePullRequest,
faCircleDot,
faGears,
faTriangleExclamation,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import FleetStrip from '@/components/FleetStrip.vue'
import type { Repo } from '@/lib/types'
import type { Facts, RepoCounts } from '@/stores/tracker'
const props = defineProps<{
crit: number
warn: number
blockCount: number
repos: Repo[]
counts: Map<string, RepoCounts>
facts: Facts
focused: string | null
matching: Set<string>
}>()
defineEmits<{ select: [fullName: string] }>()
const rows = computed<{ label: string; value: number; icon: IconDefinition; tone: string }[]>(
() => [
{
label: 'Workflows failing',
value: props.facts.failingWorkflows,
icon: faGears,
tone: 'text-crit',
},
{
label: 'Pull requests waiting',
value: props.facts.waitingPrs,
icon: faCodePullRequest,
tone: 'text-crit',
},
{
label: 'PRs with failing checks',
value: props.facts.failingChecks,
icon: faCodePullRequest,
tone: 'text-warn',
},
{ label: 'Stale issues', value: props.facts.staleIssues, icon: faCircleDot, tone: 'text-warn' },
{
label: 'Data problems',
value: props.facts.dataProblems,
icon: faTriangleExclamation,
tone: 'text-warn',
},
],
)
</script>
<template>
<section
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]"
aria-label="Fleet status"
>
<div>
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
<template v-if="blockCount > 0"
>{{ blockCount }} of {{ repos.length }} projects need attention</template
>
<template v-else>All {{ repos.length }} projects are healthy</template>
</p>
<h1
class="flex flex-wrap items-baseline gap-x-10 gap-y-1 font-semibold tracking-tight"
data-test="headline"
>
<template v-if="crit > 0 || warn > 0">
<span v-if="crit > 0" class="text-crit">
<span class="text-7xl leading-none tabular-nums">{{ crit }}</span>
<span class="ml-2 text-xl font-medium">critical</span>
</span>
<span v-if="warn > 0" class="text-warn">
<span class="text-7xl leading-none tabular-nums">{{ warn }}</span>
<span class="ml-2 text-xl font-medium">{{ warn === 1 ? 'warning' : 'warnings' }}</span>
</span>
</template>
<span v-else class="text-ok text-7xl leading-none">All clear</span>
</h1>
<div class="mt-8">
<FleetStrip
:repos="repos"
:counts="counts"
:focused="focused"
:matching="matching"
@select="$emit('select', $event)"
/>
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="flex items-center gap-1.5"
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
>
<span class="flex items-center gap-1.5"
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
>
<span class="flex items-center gap-1.5"
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
>
<span class="ml-auto">Select a segment to focus a project</span>
</p>
</div>
</div>
<dl class="border-border divide-border divide-y border-y text-sm" data-test="facts">
<div v-for="row in rows" :key="row.label" class="flex items-center gap-3 py-2.5">
<FontAwesomeIcon :icon="row.icon" class="text-muted-foreground w-4" aria-hidden="true" />
<dt class="flex-1">{{ row.label }}</dt>
<dd
class="font-mono text-base tabular-nums"
:class="row.value > 0 ? row.tone : 'text-muted-foreground'"
>
{{ row.value }}
</dd>
</div>
</dl>
</section>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { Repo } from '@/lib/types'
import type { RepoCounts } from '@/stores/tracker'
defineProps<{
repos: Repo[]
counts: Map<string, RepoCounts>
focused: string | null
matching: Set<string>
}>()
defineEmits<{ select: [fullName: string] }>()
const SEGMENT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok/30 hover:bg-ok/60' } as const
function summary(c: RepoCounts | undefined): string {
if (!c) return 'healthy'
const parts = []
if (c.crit) parts.push(`${c.crit} critical`)
if (c.warn) parts.push(`${c.warn} ${c.warn === 1 ? 'warning' : 'warnings'}`)
return parts.join(' · ')
}
</script>
<template>
<TooltipProvider :delay-duration="80">
<div class="flex gap-1" role="group" aria-label="Project health" data-test="strip">
<Tooltip v-for="repo in repos" :key="repo.fullName">
<TooltipTrigger as-child>
<button
type="button"
:aria-label="`${repo.fullName}: ${summary(counts.get(repo.fullName))}`"
:aria-pressed="focused === repo.fullName"
class="h-14 min-w-0 flex-1 rounded-[3px] transition-colors focus-visible:outline-offset-1"
:class="[
SEGMENT[repo.health],
focused === repo.fullName
? 'ring-foreground ring-2 ring-offset-2 ring-offset-background'
: '',
]"
data-test="segment"
@click="$emit('select', repo.fullName)"
/>
</TooltipTrigger>
<TooltipContent>
<p class="font-medium">{{ repo.fullName }}</p>
<p class="opacity-80">{{ summary(counts.get(repo.fullName)) }}</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ageSince, isZeroTime } from '@/lib/format'
import type { Repo } from '@/lib/types'
defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
defineEmits<{ select: [fullName: string] }>()
const DOT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok' } as const
const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
</script>
<template>
<section aria-labelledby="projects-title">
<h2 id="projects-title" class="mb-4 text-lg font-semibold tracking-tight">
Projects
<span class="text-muted-foreground text-sm font-normal" data-test="project-count">{{
repos.length === total ? total : `${repos.length} of ${total}`
}}</span>
</h2>
<p
v-if="repos.length === 0"
class="text-muted-foreground border-border rounded-lg border border-dashed p-4 text-sm"
data-test="no-projects"
>
No projects match the search.
</p>
<ul v-else class="border-border bg-surface divide-border divide-y rounded-lg border">
<li
v-for="repo in repos"
:key="repo.fullName"
class="px-3 py-2.5"
:class="focused === repo.fullName ? 'bg-accent' : ''"
data-test="project"
>
<div class="flex items-center gap-2.5">
<span class="size-2 shrink-0 rounded-full" :class="DOT[repo.health]"></span>
<span class="sr-only">{{ LABEL[repo.health] }}:</span>
<button
type="button"
class="hover:text-link min-w-0 flex-1 truncate text-left text-sm font-medium"
:aria-pressed="focused === repo.fullName"
@click="$emit('select', repo.fullName)"
>
{{ repo.fullName.split('/')[1] }}
<span class="text-muted-foreground font-normal">{{ repo.fullName.split('/')[0] }}</span>
</button>
<a
v-if="repo.release"
:href="repo.release.url"
class="text-muted-foreground hover:text-link font-mono text-xs"
>
{{ repo.release.tag }}
</a>
<a v-else :href="repo.releasesUrl" class="text-muted-foreground hover:text-link text-xs"
>no release</a
>
</div>
<div
class="text-muted-foreground mt-1 flex items-center gap-4 pl-[18px] text-xs tabular-nums"
>
<a
:href="`${repo.url}/actions`"
class="hover:text-link flex items-center gap-1.5"
:class="repo.workflows.failing ? 'text-crit' : ''"
>
<FontAwesomeIcon :icon="faGears" aria-hidden="true" />
{{
repo.workflows.failing
? `${repo.workflows.failing}/${repo.workflows.total} failing`
: repo.workflows.total
}}
<span class="sr-only">workflows</span>
</a>
<a :href="`${repo.url}/pulls`" class="hover:text-link flex items-center gap-1.5">
<FontAwesomeIcon :icon="faCodePullRequest" aria-hidden="true" />{{ repo.prs.open }}
<span class="sr-only">pull requests</span>
</a>
<a :href="`${repo.url}/issues`" class="hover:text-link flex items-center gap-1.5">
<FontAwesomeIcon :icon="faCircleDot" aria-hidden="true" />{{ repo.issues.open }}
<span class="sr-only">issues</span>
</a>
<span v-if="repo.release && !isZeroTime(repo.release.publishedAt)" class="ml-auto">
released {{ ageSince(repo.release.publishedAt, nowMs) }} ago
</span>
</div>
</li>
</ul>
</section>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { faTag } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AttentionRow from '@/components/AttentionRow.vue'
import { Badge } from '@/components/ui/badge'
import type { Block } from '@/lib/types'
defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
</script>
<template>
<section class="border-border bg-surface rounded-lg border" data-test="repo-block">
<header class="border-border flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-3">
<h3 class="font-semibold">
<a :href="block.repo.url" class="hover:text-link underline-offset-4 hover:underline">
<span class="text-muted-foreground font-normal"
>{{ block.repo.fullName.split('/')[0] }}/</span
>{{ block.repo.fullName.split('/')[1] }}
</a>
</h3>
<Badge v-if="block.repo.release" variant="secondary" as-child class="font-mono">
<a :href="block.repo.release.url">
<FontAwesomeIcon :icon="faTag" aria-hidden="true" class="text-[0.7em]" />
{{ block.repo.release.tag }}
</a>
</Badge>
<span class="text-muted-foreground ml-auto text-xs">
{{ block.rows.length }} {{ block.rows.length === 1 ? 'item' : 'items' }}
</span>
</header>
<ul class="divide-border divide-y px-4">
<AttentionRow
v-for="row in block.rows"
:key="row.url"
:row="row"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</ul>
</section>
</template>
+306
View File
@@ -0,0 +1,306 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AgeMeter from './AgeMeter.vue'
import AppHeader from './AppHeader.vue'
import AttentionList from './AttentionList.vue'
import FilterBar from './FilterBar.vue'
import AttentionRow from './AttentionRow.vue'
import FleetOverview from './FleetOverview.vue'
import ProjectList from './ProjectList.vue'
import RepoGroup from './RepoGroup.vue'
import { activeFilterCount, emptyFilters, facetCounts, type Filters } from '@/lib/filters'
import { buildBlocks, computeFacts, countByRepo } from '@/stores/tracker'
import { hoursAgo, mixedState, NOW, repo } from '@/test/fixtures'
import type { Row } from '@/lib/types'
const nowMs = Date.parse(NOW)
const CRIT_SECONDS = 604800
const text = (w: { text(): string }) => w.text().replace(/\s+/g, ' ')
describe('AppHeader', () => {
const base = {
lastRefresh: '2026-10-02T11:00:00Z',
nowMs,
intervalSeconds: 21600,
loaded: true,
scanning: false,
}
it('shows when data was fetched and when the next refresh is due', () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: false } })
expect(text(w.get('[data-test=updated]'))).toBe('Scanned 1h ago · next in 5h')
expect(w.find('[data-test=rescan]').exists()).toBe(false)
})
it('shows Loading before the first refresh finishes', () => {
const w = mount(AppHeader, { props: { ...base, loaded: false, manualRefresh: false } })
expect(w.text()).toContain('Loading')
})
it('emits rescan and shows progress while a scan runs', async () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: true } })
expect(w.get('[data-test=rescan]').text()).toBe('Rescan repositories')
await w.get('[data-test=rescan]').trigger('click')
expect(w.emitted('rescan')).toHaveLength(1)
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
expect(busy.get('[data-test=rescan]').text()).toBe('Rescanning…')
})
})
describe('FleetOverview', () => {
const s = mixedState()
const props = {
crit: 2,
warn: 1,
blockCount: 1,
repos: s.repos,
counts: countByRepo(s),
facts: computeFacts(s),
focused: null,
matching: new Set(['o/r', 'o/fine']),
}
it('leads with the critical and warning counts and the scope', () => {
const w = mount(FleetOverview, { props })
const headline = w.get('[data-test=headline]').text()
expect(headline).toMatch(/2\s*critical/)
expect(headline).toMatch(/1\s*warning/)
expect(w.get('[data-test=scope]').text()).toBe('1 of 2 projects need attention')
})
it('lists the headline facts, dimming zeros', () => {
const w = mount(FleetOverview, { props })
const facts = Object.fromEntries(
w.findAll('[data-test=facts] > div').map((d) => [d.get('dt').text(), d.get('dd').text()]),
)
expect(facts).toMatchObject({
'Workflows failing': '1',
'Stale issues': '2',
'Data problems': '0',
})
})
it('says all clear when nothing is wrong', () => {
const w = mount(FleetOverview, { props: { ...props, crit: 0, warn: 0, blockCount: 0 } })
expect(w.get('[data-test=headline]').text()).toBe('All clear')
expect(w.get('[data-test=scope]').text()).toBe('All 2 projects are healthy')
})
it('draws one segment per project, labelled for screen readers, and emits on click', async () => {
const w = mount(FleetOverview, { props: { ...props, focused: 'o/r' } })
const segments = w.findAll('[data-test=segment]')
expect(segments).toHaveLength(2)
expect(segments[0].attributes('aria-label')).toBe('o/r: 2 critical · 1 warning')
expect(segments[0].attributes('aria-pressed')).toBe('true')
expect(segments[1].attributes('aria-label')).toBe('o/fine: healthy')
await segments[1].trigger('click')
expect(w.emitted('select')?.[0]).toEqual(['o/fine'])
})
})
describe('AgeMeter', () => {
it('fills proportionally and always shows at least one segment', () => {
const filled = (ratio: number) =>
mount(AgeMeter, { props: { ratio, severity: 'crit' } })
.findAll('span span')
.filter((s) => s.classes().includes('bg-crit')).length
expect(filled(0)).toBe(1)
expect(filled(0.5)).toBe(5)
expect(filled(3)).toBe(10)
})
it('uses the warning colour for warnings', () => {
const w = mount(AgeMeter, { props: { ratio: 0.2, severity: 'warn' } })
expect(w.find('.bg-warn').exists()).toBe(true)
expect(w.find('.bg-crit').exists()).toBe(false)
})
})
describe('AttentionRow', () => {
const row = (over: Partial<Row> = {}): Row => ({
kind: 'pr_waiting',
subject: '#5 <b>bold</b> title',
detail: 'open 9d',
url: 'https://github.com/o/r/pull/5',
since: hoursAgo(216),
severity: 'crit',
...over,
})
it('links the subject, shows the age and escapes markup', () => {
const w = mount(AttentionRow, { props: { row: row(), nowMs, critSeconds: CRIT_SECONDS } })
expect(w.get('a').attributes('href')).toBe('https://github.com/o/r/pull/5')
expect(w.text()).toContain('<b>bold</b>')
expect(w.find('b').exists()).toBe(false)
expect(w.text()).toContain('9d')
expect(w.text()).toContain('Critical')
})
it('shows the age meter only for waiting pull requests', () => {
const pr = mount(AttentionRow, { props: { row: row(), nowMs, critSeconds: CRIT_SECONDS } })
expect(pr.find('[aria-label$="critical age"]').exists()).toBe(true)
const wf = mount(AttentionRow, {
props: { row: row({ kind: 'workflow_failed' }), nowMs, critSeconds: CRIT_SECONDS },
})
expect(wf.find('[aria-label$="critical age"]').exists()).toBe(false)
})
it('omits the age for an unknown timestamp', () => {
const w = mount(AttentionRow, {
props: {
row: row({ since: '0001-01-01T00:00:00Z', kind: 'issues_stale' }),
nowMs,
critSeconds: CRIT_SECONDS,
},
})
expect(w.find('.font-mono').text()).toBe('')
})
})
describe('RepoGroup', () => {
const block = buildBlocks(mixedState())[0]
it('shows the repo, its version link and an item count', () => {
const w = mount(RepoGroup, { props: { block, nowMs, critSeconds: CRIT_SECONDS } })
expect(w.text()).toContain('o/')
expect(w.get('a[href="https://github.com/o/r"]').text()).toContain('r')
expect(w.get('a[href="https://github.com/o/r/releases/tag/v1.0.0"]').text()).toBe('v1.0.0')
expect(w.text()).toContain('2 items')
expect(w.findAll('[data-test=row]')).toHaveLength(2)
})
it('omits the version badge without a release and uses the singular for one item', () => {
const one = { repo: repo({ release: null }), rows: [block.rows[0]] }
const w = mount(RepoGroup, { props: { block: one, nowMs, critSeconds: CRIT_SECONDS } })
expect(w.find('a[href*="/releases/"]').exists()).toBe(false)
expect(w.text()).toContain('1 item')
})
})
describe('AttentionList', () => {
const blocks = buildBlocks(mixedState())
const props = {
blocks,
nowMs,
critSeconds: CRIT_SECONDS,
facets: facetCounts(blocks, emptyFilters(), ['o']),
owners: ['o'],
active: 0,
filters: emptyFilters(),
}
it('renders one group per repo', () => {
const w = mount(AttentionList, { props })
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
})
it('bubbles filter changes up', async () => {
const w = mount(AttentionList, { props })
await w.get('[data-test=sev-crit]').trigger('click')
const [emitted] = w.emitted('update:filters')!.at(-1) as [Filters]
expect(emitted.severity).toBe('crit')
})
it('forwards clear', async () => {
const w = mount(AttentionList, { props: { ...props, active: 2 } })
await w.get('[data-test=clear-all]').trigger('click')
expect(w.emitted('clear')).toHaveLength(1)
})
it('explains an empty selection', () => {
const w = mount(AttentionList, { props: { ...props, blocks: [] } })
expect(w.get('[data-test=empty]').text()).toContain('Nothing matches')
})
})
describe('FilterBar', () => {
const blocks = buildBlocks(mixedState())
const base = (filters: Filters = emptyFilters(), owners = ['o', 'p']) => ({
facets: facetCounts(blocks, filters, owners),
owners,
active: activeFilterCount(filters),
filters,
})
it('shows live counts in every facet', () => {
const w = mount(FilterBar, { props: base() })
expect(w.get('[data-test=sev-all]').text()).toBe('All 2')
expect(w.get('[data-test=sev-crit]').text()).toBe('Critical 2')
expect(w.get('[data-test=sev-warn]').text()).toBe('Warnings 0')
expect(w.get('[data-test=kind-workflow]').text()).toContain('1')
expect(w.get('[data-test=kind-pr]').text()).toContain('1')
expect(w.get('[data-test=owner-o]').text()).toContain('2')
})
it('disables facet values that would match nothing, but not selected ones', () => {
const w = mount(FilterBar, { props: base() })
expect(w.get('[data-test=kind-issue]').attributes('disabled')).toBeDefined()
expect(w.get('[data-test=owner-p]').attributes('disabled')).toBeDefined()
expect(w.get('[data-test=kind-pr]').attributes('disabled')).toBeUndefined()
const sel = mount(FilterBar, { props: base({ ...emptyFilters(), kinds: ['issue'] }) })
expect(sel.get('[data-test=kind-issue]').attributes('disabled')).toBeUndefined()
})
it('narrows the other facets when one is selected', () => {
const w = mount(FilterBar, { props: base({ ...emptyFilters(), kinds: ['workflow'] }) })
expect(w.get('[data-test=sev-all]').text()).toBe('All 1')
expect(w.get('[data-test=kind-pr]').text()).toContain('1')
})
it('hides the owner filter when there is only one owner', () => {
const w = mount(FilterBar, { props: base(emptyFilters(), ['o']) })
expect(w.find('[data-test=owner-o]').exists()).toBe(false)
})
it('emits typed search text and clears it with Escape', async () => {
const w = mount(FilterBar, { props: base() })
const input = w.get('[data-test=search]')
await input.setValue('grpc')
expect((w.emitted('update:filters')!.at(-1) as [Filters])[0].query).toBe('grpc')
const typed = mount(FilterBar, { props: base({ ...emptyFilters(), query: 'grpc' }) })
await typed.get('[data-test=search]').trigger('keydown', { key: 'Escape' })
expect((typed.emitted('update:filters')!.at(-1) as [Filters])[0].query).toBe('')
})
it('offers to clear active filters with a count', async () => {
const f = { ...emptyFilters(), query: 'x', kinds: ['pr' as const] }
const w = mount(FilterBar, { props: base(f) })
expect(w.get('[data-test=clear-all]').text()).toBe('Clear 2 filters')
await w.get('[data-test=clear-all]').trigger('click')
expect(w.emitted('clear')).toHaveLength(1)
expect(mount(FilterBar, { props: base() }).find('[data-test=clear-all]').exists()).toBe(false)
})
})
describe('ProjectList', () => {
const repos = mixedState().repos
it('lists every project with release, counts and links', () => {
const w = mount(ProjectList, { props: { repos, total: 2, focused: null, nowMs } })
const rows = w.findAll('[data-test=project]')
expect(rows).toHaveLength(2)
expect(rows[0].text()).toContain('v1.0.0')
expect(rows[0].text()).toContain('released 3d ago')
expect(rows[0].text()).toContain('1/3 failing')
expect(rows[0].get('a[href="https://github.com/o/r/pulls"]').text()).toContain('2')
expect(rows[0].get('a[href="https://github.com/o/r/issues"]').text()).toContain('4')
expect(rows[1].get('a[href="https://github.com/o/fine/releases"]').text()).toBe('no release')
})
it('shows how many projects match and a hint when none do', () => {
const some = mount(ProjectList, {
props: { repos: repos.slice(0, 1), total: 2, focused: null, nowMs },
})
expect(some.get('[data-test=project-count]').text()).toBe('1 of 2')
const none = mount(ProjectList, { props: { repos: [], total: 2, focused: null, nowMs } })
expect(none.find('[data-test=no-projects]').exists()).toBe(true)
})
it('emits the repo when its name is chosen and highlights the focused one', async () => {
const w = mount(ProjectList, { props: { repos, total: 2, focused: 'o/fine', nowMs } })
await w.findAll('[data-test=project] button')[0].trigger('click')
expect(w.emitted('select')?.[0]).toEqual(['o/r'])
expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent')
})
})
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+37
View File
@@ -0,0 +1,37 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './Input.vue'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ProgressRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ProgressIndicator, ProgressRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(), {
modelValue: 0,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', props.class)"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Progress } from './Progress.vue'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'horizontal',
decorative: true,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="separator"
v-bind="delegatedProps"
:class="
cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
props.class,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tbody data-slot="table-body" :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<caption data-slot="table-caption" :class="cn('text-muted-foreground mt-4 text-sm', props.class)">
<slot />
</caption>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
props.class,
)
"
>
<slot />
</td>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
colspan?: number
}>(),
{
colspan: 1,
},
)
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TableRow>
<TableCell
:class="cn('p-4 whitespace-nowrap align-middle text-sm text-foreground', props.class)"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<th
data-slot="table-head"
:class="
cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
props.class,
)
"
>
<slot />
</th>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="
cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)
"
>
<slot />
</tr>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { toggleVariants } from '@/components/ui/toggle'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import { provide } from 'vue'
import { cn } from '@/lib/utils'
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const props = withDefaults(
defineProps<
ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
spacing?: number
}
>(),
{
spacing: 0,
},
)
const emits = defineEmits<ToggleGroupRootEmits>()
provide('toggleGroup', {
variant: props.variant,
size: props.size,
spacing: props.spacing,
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
data-slot="toggle-group"
:data-size="size"
:data-variant="variant"
:data-spacing="spacing"
:style="{
'--gap': spacing,
}"
v-bind="forwarded"
:class="
cn(
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
type ToggleGroupVariants = VariantProps<typeof toggleVariants> & {
spacing?: number
}
const props = defineProps<
ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
}
>()
const context = inject<ToggleGroupVariants>('toggleGroup')
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
data-slot="toggle-group-item"
:data-variant="context?.variant || variant"
:data-size="context?.size || size"
:data-spacing="context?.spacing"
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size,
}),
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as ToggleGroup } from './ToggleGroup.vue'
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ToggleEmits, ToggleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ToggleVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Toggle, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { toggleVariants } from '.'
const props = withDefaults(
defineProps<
ToggleProps & {
class?: HTMLAttributes['class']
variant?: ToggleVariants['variant']
size?: ToggleVariants['size']
}
>(),
{
variant: 'default',
size: 'default',
disabled: false,
},
)
const emits = defineEmits<ToggleEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<Toggle
v-slot="slotProps"
data-slot="toggle"
v-bind="forwarded"
:class="cn(toggleVariants({ variant, size }), props.class)"
>
<slot v-bind="slotProps" />
</Toggle>
</template>
+28
View File
@@ -0,0 +1,28 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Toggle } from './Toggle.vue'
export const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ToggleVariants = VariantProps<typeof toggleVariants>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-slot="slotProps" data-slot="tooltip" v-bind="forwarded">
<slot v-bind="slotProps" />
</TooltipRoot>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
props.class,
)
"
>
<slot />
<TooltipArrow
class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs"
/>
</TooltipContent>
</TooltipPortal>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,
})
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger data-slot="tooltip-trigger" v-bind="props">
<slot />
</TooltipTrigger>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'