Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.0] - 2026-09-27

Everything from 0.2.0 keeps working unchanged.

### Added

- `Table`, with cells as slots named after their column
- `GlobalSearch`, a search field opened with Ctrl or Cmd K, handed groups of
results the application has already narrowed

## [0.2.0] - 2026-09-01

The components the dashboard kept for itself, so both surfaces can share one
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ notices the two products no longer look like one.
## What is in it, and what is not

Tokens, and the pieces a screen is assembled from: `Avatar`, `Badge`, `Button`,
`Card`, `Field`, `Input`, `ItemCard`, `Logo`, `Modal`, `PageHead`, `Select`,
`Tabs`, `Textarea`, and `CodeGraph`.
`Card`, `Field`, `Input`, `ItemCard`, `Logo`, `Modal`,
`GlobalSearch`, `PageHead`, `Select`, `Table`, `Tabs`, `Textarea`, and `CodeGraph`. `src/index.js` is the list that is actually true.

Nothing that knows what a workspace, a review or a proposal is. Those belong to
whichever application has them, and a package that grew to hold them would be
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sourceant/design",
"version": "0.2.0",
"version": "0.3.0",
"description": "SourceAnt design system: tokens, primitives, and the graph renderer",
"license": "MIT",
"type": "module",
Expand Down
221 changes: 221 additions & 0 deletions src/components/GlobalSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ArrowUpRight, Loader2, Search, X } from 'lucide-vue-next'
import { cn } from '../lib/utils'

/** One field that finds anything, opened with Ctrl or Cmd K.
*
* Results are handed in, already narrowed, so the application decides what is
* searchable. This owns the field, the keys and the list.
*/

export interface GlobalSearchItem {
id: string
label: string
/** A second line, for what the label alone leaves ambiguous. */
detail?: string
}

export interface GlobalSearchGroup {
id: string
label: string
items: GlobalSearchItem[]
}

const props = withDefaults(defineProps<{
/** The query, so the application can narrow or ask for results itself. */
modelValue?: string
groups?: GlobalSearchGroup[]
open?: boolean
/** Said above the results, for where the search is looking. */
context?: string
placeholder?: string
/** Waiting on somebody else's answer. */
loading?: boolean
emptyLabel?: string
label?: string
class?: string
}>(), {
modelValue: '',
groups: () => [],
open: false,
placeholder: 'Search',
loading: false,
emptyLabel: 'No matches. Try another name or phrase.',
label: 'Search',
})

const emit = defineEmits<{
'update:modelValue': [string]
'update:open': [boolean]
select: [GlobalSearchItem, string]
}>()

const active = ref(0)
const root = ref<HTMLElement>()
const field = ref<HTMLInputElement>()
const trigger = ref<HTMLButtonElement>()

// One list under the headings, because the keys move through what is drawn
// rather than through the shape it is drawn in.
const flat = computed(() =>
props.groups.flatMap(group => group.items.map(item => ({ item, group: group.id }))),
)

watch(() => props.modelValue, () => { active.value = 0 })
watch(flat, () => { active.value = Math.min(active.value, Math.max(0, flat.value.length - 1)) })
watch(() => props.open, async (open) => {
if (open) {
await nextTick()
field.value?.focus()
} else {
await nextTick()
trigger.value?.focus()
}
})

function move(delta: number) {
if (flat.value.length) active.value = (active.value + delta + flat.value.length) % flat.value.length
}

function choose(index = active.value) {
const chosen = flat.value[index]
if (!chosen) return
emit('select', chosen.item, chosen.group)
emit('update:open', false)
}

function indexOf(group: GlobalSearchGroup, item: GlobalSearchItem) {
return flat.value.findIndex(one => one.group === group.id && one.item.id === item.id)
}

function shortcut(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
emit('update:open', !props.open)
}
}

function outside(event: MouseEvent) {
if (props.open && root.value && !event.composedPath().includes(root.value)) {
emit('update:open', false)
}
}

onMounted(() => {
document.addEventListener('keydown', shortcut)
document.addEventListener('click', outside, true)
})
onUnmounted(() => {
document.removeEventListener('keydown', shortcut)
document.removeEventListener('click', outside, true)
})
</script>

<template>
<div
ref="root"
:class="cn(
'z-50',
open
? 'absolute left-1/2 top-1/2 w-[calc(100%-1rem)] max-w-xl -translate-x-1/2 -translate-y-1/2'
: 'shrink-0',
props.class,
)"
>
<button
v-if="!open"
ref="trigger"
type="button"
class="relative rounded-full p-2 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40"
:aria-label="label"
title="Search (Ctrl / ⌘ K)"
aria-haspopup="listbox"
:aria-expanded="open"
@click.stop="$emit('update:open', true)"
>
<Search class="h-4 w-4" />
<span v-if="modelValue" class="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary" />
</button>

<div v-else class="rounded-lg border bg-card shadow-lg">
<div class="flex items-center gap-2 px-3">
<Loader2 v-if="loading" class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<Search v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref="field"
:value="modelValue"
role="combobox"
aria-autocomplete="list"
aria-controls="global-search-results"
:aria-expanded="open"
:aria-activedescendant="flat.length ? `global-search-${active}` : undefined"
:aria-label="label"
:placeholder="placeholder"
class="h-10 min-w-0 flex-1 bg-transparent text-sm outline-none"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="choose()"
@keydown.esc.stop.prevent="$emit('update:open', false)"
>
<button
type="button"
aria-label="Close search"
class="rounded p-1 text-muted-foreground hover:bg-muted"
@click="$emit('update:open', false)"
>
<X class="h-4 w-4" />
</button>
</div>

<div class="absolute left-0 right-0 top-full mt-1 overflow-hidden rounded-lg border bg-card shadow-lg">
<p v-if="context" class="border-b px-3 py-2 text-xs text-muted-foreground">{{ context }}</p>
<ul
id="global-search-results"
role="listbox"
:aria-label="label"
class="max-h-[60vh] overflow-y-auto py-1"
>
<template v-for="group in groups" :key="group.id">
<li
v-if="group.items.length"
role="presentation"
class="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
>
{{ group.label }}
</li>
<li
v-for="item in group.items"
:id="`global-search-${indexOf(group, item)}`"
:key="`${group.id}:${item.id}`"
role="option"
:aria-selected="indexOf(group, item) === active"
>
<button
type="button"
tabindex="-1"
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm"
:class="indexOf(group, item) === active ? 'bg-muted' : 'hover:bg-muted/50'"
@mouseenter="active = indexOf(group, item)"
@mousedown.prevent
@click="choose(indexOf(group, item))"
>
<span class="min-w-0 flex-1">
<span class="block truncate">{{ item.label }}</span>
<span v-if="item.detail" class="block truncate text-xs text-muted-foreground">
{{ item.detail }}
</span>
</span>
<ArrowUpRight class="h-3 w-3 shrink-0 text-muted-foreground" />
</button>
</li>
</template>
</ul>
<p v-if="!flat.length && !loading" role="status" class="px-3 py-5 text-sm text-muted-foreground">
{{ emptyLabel }}
</p>
</div>
</div>
</div>
</template>
81 changes: 81 additions & 0 deletions src/components/Table.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { cn } from '../lib/utils'

/**
* Rows of the same shape, read down a column.
*
* Cells are slots named after their column, so what a value looks like stays
* with the application that knows what it means.
*/

export interface TableColumn {
id: string
label: string
/** Numbers and controls read better against their own edge. */
align?: 'left' | 'right'
/** Held to a width, for a column whose content would otherwise set it. */
width?: string
/** Dropped on a narrow screen, where every column cannot fit. */
narrow?: boolean
}

const props = withDefaults(defineProps<{
columns: TableColumn[]
rows: Record<string, unknown>[]
/** What makes a row itself, so redrawing does not shuffle them. */
rowKey?: string | ((row: Record<string, unknown>, index: number) => string)
/** A name for whoever is not looking at it. */
label?: string
/** Tighter rows, for a list somebody scans rather than reads. */
dense?: boolean
class?: string
}>(), { rowKey: 'id', dense: false })

const keyOf = (row: Record<string, unknown>, index: number) =>
typeof props.rowKey === 'function' ? props.rowKey(row, index) : String(row[props.rowKey] ?? index)
</script>

<template>
<div :class="cn('w-full overflow-x-auto rounded-lg border', props.class)">
<table class="w-full border-collapse text-sm">
<caption v-if="label" class="sr-only">{{ label }}</caption>
<thead>
<tr class="border-b bg-muted/30">
<th
v-for="column in columns"
:key="column.id"
scope="col"
:style="column.width ? { width: column.width } : undefined"
:class="cn(
'px-2.5 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground',
column.align === 'right' ? 'text-right' : 'text-left',
column.narrow && 'hidden sm:table-cell',
)"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in rows"
:key="keyOf(row, index)"
class="border-b last:border-0 transition-colors hover:bg-muted/40"
>
<td
v-for="column in columns"
:key="column.id"
:class="cn(
'px-2.5 align-middle',
dense ? 'py-1' : 'py-1.5',
column.align === 'right' ? 'text-right' : 'text-left',
column.narrow && 'hidden sm:table-cell',
)"
>
<slot :name="column.id" :row="row" :index="index">{{ row[column.id] }}</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ export { default as Badge } from './components/Badge.vue'
export { default as Button } from './components/Button.vue'
export { default as Card } from './components/Card.vue'
export { default as CodeGraph } from './components/CodeGraph.vue'
export { default as GlobalSearch } from './components/GlobalSearch.vue'
export { default as Field } from './components/Field.vue'
export { default as Input } from './components/Input.vue'
export { default as ItemCard } from './components/ItemCard.vue'
export { default as PageHead } from './components/PageHead.vue'
export { default as Select } from './components/Select.vue'
export { default as SearchableSelect } from './components/SearchableSelect.vue'
export { default as Table } from './components/Table.vue'
export { default as Tabs } from './components/Tabs.vue'
export { default as Textarea } from './components/Textarea.vue'
export { default as ListInput } from './components/ListInput.vue'
Expand Down
Loading