mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-17 01:17:48 +00:00
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Progress } from './Progress.vue'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ToggleGroup } from './ToggleGroup.vue'
|
||||
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user