mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
af8f55976f
Add a Style setting beside mode, accent, radius and background. Nostalgia restyles the app through tokens only (neutral surface palettes, system type, flat shell, top glow) and pins background, opacity and brightness. Add a top glow reach slider for both styles. Tidy the theme layer first: charts read live tokens, bare `rounded` follows the radius scale, one shared sign-in toolbar. Also carries the settings page header redesign.
158 lines
5.1 KiB
Vue
158 lines
5.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onUnmounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { ChevronsUpDown, X } from '@lucide/vue'
|
|
import type { AnnotationHubBookFacet } from '@bookorbit/types'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const props = defineProps<{
|
|
searchFn: (q: string) => Promise<AnnotationHubBookFacet[]>
|
|
placeholder?: string
|
|
}>()
|
|
|
|
const model = defineModel<number | 'all'>({ required: true })
|
|
const selectedLabel = defineModel<string | null>('selectedLabel', { required: true })
|
|
|
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
const query = ref('')
|
|
const options = ref<AnnotationHubBookFacet[]>([])
|
|
const showDropdown = ref(false)
|
|
const isFocused = ref(false)
|
|
const loading = ref(false)
|
|
const activeIndex = ref(-1)
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
let latestRequestId = 0
|
|
|
|
const hasSelection = computed(() => model.value !== 'all')
|
|
const inputValue = computed(() => (isFocused.value ? query.value : (selectedLabel.value ?? '')))
|
|
const showEmpty = computed(() => showDropdown.value && !loading.value && options.value.length === 0)
|
|
|
|
async function runSearch(q: string) {
|
|
loading.value = true
|
|
const requestId = ++latestRequestId
|
|
try {
|
|
const result = await props.searchFn(q)
|
|
if (requestId !== latestRequestId) return
|
|
options.value = result
|
|
activeIndex.value = -1
|
|
} finally {
|
|
if (requestId === latestRequestId) loading.value = false
|
|
}
|
|
}
|
|
|
|
function scheduleSearch(q: string) {
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(() => void runSearch(q), 200)
|
|
}
|
|
|
|
function onFocus() {
|
|
isFocused.value = true
|
|
query.value = ''
|
|
showDropdown.value = true
|
|
void runSearch('')
|
|
}
|
|
|
|
function onInput(event: Event) {
|
|
query.value = (event.target as HTMLInputElement).value
|
|
showDropdown.value = true
|
|
scheduleSearch(query.value)
|
|
}
|
|
|
|
function select(option: AnnotationHubBookFacet) {
|
|
model.value = option.bookId
|
|
selectedLabel.value = option.bookTitle ?? t('annotations.unknownBook')
|
|
query.value = ''
|
|
showDropdown.value = false
|
|
isFocused.value = false
|
|
inputRef.value?.blur()
|
|
}
|
|
|
|
function clear() {
|
|
model.value = 'all'
|
|
selectedLabel.value = null
|
|
query.value = ''
|
|
options.value = []
|
|
showDropdown.value = false
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
activeIndex.value = Math.min(activeIndex.value + 1, options.value.length - 1)
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
activeIndex.value = Math.max(activeIndex.value - 1, -1)
|
|
} else if (event.key === 'Enter' && activeIndex.value >= 0) {
|
|
event.preventDefault()
|
|
const option = options.value[activeIndex.value]
|
|
if (option) select(option)
|
|
} else if (event.key === 'Escape') {
|
|
showDropdown.value = false
|
|
}
|
|
}
|
|
|
|
function onBlur() {
|
|
setTimeout(() => {
|
|
isFocused.value = false
|
|
showDropdown.value = false
|
|
query.value = ''
|
|
}, 150)
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
latestRequestId = -1
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative w-full sm:w-[14rem]">
|
|
<input
|
|
ref="inputRef"
|
|
:value="inputValue"
|
|
type="text"
|
|
role="combobox"
|
|
:aria-label="t('annotations.combobox.filterByBook')"
|
|
:aria-expanded="showDropdown"
|
|
:placeholder="placeholder ?? t('annotations.combobox.allBooks')"
|
|
class="h-9 w-full rounded-md border border-border bg-background pl-2.5 pr-8 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
|
@focus="onFocus"
|
|
@input="onInput"
|
|
@keydown="onKeydown"
|
|
@blur="onBlur"
|
|
/>
|
|
<button
|
|
v-if="hasSelection"
|
|
type="button"
|
|
:aria-label="t('annotations.combobox.clearBookFilter')"
|
|
class="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
@mousedown.prevent="clear"
|
|
>
|
|
<X :size="14" />
|
|
</button>
|
|
<ChevronsUpDown v-else :size="14" class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
<div
|
|
v-if="showDropdown"
|
|
class="absolute top-full left-0 mt-1 z-50 w-full min-w-[16rem] max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
|
>
|
|
<p v-if="loading && options.length === 0" class="px-3 py-2 text-sm text-muted-foreground">{{ t('annotations.combobox.searching') }}</p>
|
|
<p v-else-if="showEmpty" class="px-3 py-2 text-sm text-muted-foreground">{{ t('annotations.combobox.noBooksFound') }}</p>
|
|
<button
|
|
v-for="(option, index) in options"
|
|
:key="option.bookId"
|
|
type="button"
|
|
role="option"
|
|
:aria-selected="option.bookId === model"
|
|
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-accent"
|
|
:class="{ 'bg-accent': index === activeIndex }"
|
|
@mousedown.prevent="select(option)"
|
|
>
|
|
<span class="min-w-0 flex-1 truncate">{{ option.bookTitle ?? t('annotations.unknownBook') }}</span>
|
|
<span class="shrink-0 text-xs text-muted-foreground">{{ option.count }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|