- Custom tooltip directive (WCAG AAA) on every button in the app - Two-column timer layout with sticky hero and recent entries sidebar - Timer font selector with 16 monospace Google Fonts and live preview - UI font selector with 15+ Google Fonts - Close-to-tray and minimize-to-tray settings - New app icons (no-glow variants), platform icon set - Mini timer pop-out window - Favorites strip with drag-reorder and inline actions - Comprehensive README with feature documentation - Remove tracked files that belong in gitignore
323 lines
9.1 KiB
Vue
323 lines
9.1 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import { ChevronDown, Check } from 'lucide-vue-next'
|
|
import { computeDropdownPosition } from '../utils/dropdown'
|
|
|
|
interface Props {
|
|
modelValue: any
|
|
options: any[]
|
|
labelKey?: string
|
|
valueKey?: string
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
placeholderValue?: any
|
|
searchable?: boolean
|
|
ariaLabelledby?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
labelKey: 'name',
|
|
valueKey: 'id',
|
|
placeholder: 'Select...',
|
|
disabled: false,
|
|
placeholderValue: undefined,
|
|
searchable: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: any]
|
|
}>()
|
|
|
|
const listboxId = 'appselect-lb-' + Math.random().toString(36).slice(2, 9)
|
|
const isOpen = ref(false)
|
|
const highlightedIndex = ref(-1)
|
|
const triggerRef = ref<HTMLButtonElement | null>(null)
|
|
const panelRef = ref<HTMLDivElement | null>(null)
|
|
const panelStyle = ref<Record<string, string>>({})
|
|
const searchQuery = ref('')
|
|
const searchInputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
// Build the full list: placeholder + real options
|
|
const allItems = computed(() => {
|
|
const placeholderItem = {
|
|
_isPlaceholder: true,
|
|
[props.valueKey]: props.placeholderValue,
|
|
[props.labelKey]: props.placeholder,
|
|
}
|
|
return [placeholderItem, ...props.options]
|
|
})
|
|
|
|
const filteredItems = computed(() => {
|
|
if (!props.searchable || !searchQuery.value) return allItems.value
|
|
const q = searchQuery.value.toLowerCase()
|
|
return allItems.value.filter(item => {
|
|
if (item._isPlaceholder) return true
|
|
return getOptionLabel(item).toLowerCase().includes(q)
|
|
})
|
|
})
|
|
|
|
const selectedLabel = computed(() => {
|
|
const option = props.options.find(
|
|
(o) => o[props.valueKey] === props.modelValue
|
|
)
|
|
return option ? option[props.labelKey] : null
|
|
})
|
|
|
|
const isPlaceholderSelected = computed(() => {
|
|
return selectedLabel.value === null
|
|
})
|
|
|
|
function getOptionValue(item: any): any {
|
|
return item._isPlaceholder ? props.placeholderValue : item[props.valueKey]
|
|
}
|
|
|
|
function getOptionLabel(item: any): string {
|
|
return item[props.labelKey]
|
|
}
|
|
|
|
function isSelected(item: any): boolean {
|
|
const val = getOptionValue(item)
|
|
return val === props.modelValue
|
|
}
|
|
|
|
function updatePosition() {
|
|
if (!triggerRef.value) return
|
|
panelStyle.value = computeDropdownPosition(triggerRef.value, {
|
|
estimatedHeight: 280,
|
|
panelEl: panelRef.value,
|
|
})
|
|
}
|
|
|
|
function toggle() {
|
|
if (props.disabled) return
|
|
if (isOpen.value) {
|
|
close()
|
|
} else {
|
|
open()
|
|
}
|
|
}
|
|
|
|
function open() {
|
|
if (props.disabled) return
|
|
isOpen.value = true
|
|
updatePosition()
|
|
|
|
// Set highlighted index to the currently selected item
|
|
const selectedIdx = allItems.value.findIndex((item) => isSelected(item))
|
|
highlightedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
|
|
|
|
if (props.searchable) {
|
|
searchQuery.value = ''
|
|
nextTick(() => searchInputRef.value?.focus())
|
|
}
|
|
|
|
nextTick(() => {
|
|
scrollHighlightedIntoView()
|
|
// Reposition with actual panel height (fixes above-flip offset)
|
|
updatePosition()
|
|
})
|
|
|
|
document.addEventListener('click', onClickOutside, true)
|
|
document.addEventListener('scroll', onScrollOrResize, true)
|
|
window.addEventListener('resize', onScrollOrResize)
|
|
}
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
highlightedIndex.value = -1
|
|
document.removeEventListener('click', onClickOutside, true)
|
|
document.removeEventListener('scroll', onScrollOrResize, true)
|
|
window.removeEventListener('resize', onScrollOrResize)
|
|
}
|
|
|
|
function select(item: any) {
|
|
emit('update:modelValue', getOptionValue(item))
|
|
close()
|
|
}
|
|
|
|
function onClickOutside(e: MouseEvent) {
|
|
const target = e.target as Node
|
|
if (
|
|
triggerRef.value?.contains(target) ||
|
|
panelRef.value?.contains(target)
|
|
) {
|
|
return
|
|
}
|
|
close()
|
|
}
|
|
|
|
function onScrollOrResize() {
|
|
if (isOpen.value) {
|
|
updatePosition()
|
|
}
|
|
}
|
|
|
|
function scrollHighlightedIntoView() {
|
|
if (!panelRef.value) return
|
|
const items = panelRef.value.querySelectorAll('[data-option]')
|
|
const item = items[highlightedIndex.value] as HTMLElement | undefined
|
|
if (item) {
|
|
item.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (!isOpen.value) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
open()
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
highlightedIndex.value = Math.min(
|
|
highlightedIndex.value + 1,
|
|
filteredItems.value.length - 1
|
|
)
|
|
nextTick(() => scrollHighlightedIntoView())
|
|
break
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
|
nextTick(() => scrollHighlightedIntoView())
|
|
break
|
|
case 'Enter':
|
|
case ' ':
|
|
e.preventDefault()
|
|
if (highlightedIndex.value >= 0) {
|
|
select(filteredItems.value[highlightedIndex.value])
|
|
}
|
|
break
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
close()
|
|
triggerRef.value?.focus()
|
|
break
|
|
case 'Tab':
|
|
close()
|
|
break
|
|
}
|
|
}
|
|
|
|
function onSearchKeydown(e: KeyboardEvent) {
|
|
if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
|
|
onKeydown(e)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Nothing needed on mount since listeners are added when opened
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
// Clean up all listeners in case component is destroyed while open
|
|
document.removeEventListener('click', onClickOutside, true)
|
|
document.removeEventListener('scroll', onScrollOrResize, true)
|
|
window.removeEventListener('resize', onScrollOrResize)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative">
|
|
<!-- Trigger button -->
|
|
<button
|
|
ref="triggerRef"
|
|
type="button"
|
|
role="combobox"
|
|
:aria-expanded="isOpen"
|
|
aria-haspopup="listbox"
|
|
:aria-activedescendant="isOpen && highlightedIndex >= 0 ? 'appselect-option-' + highlightedIndex : undefined"
|
|
:aria-labelledby="ariaLabelledby"
|
|
:aria-controls="isOpen ? listboxId : undefined"
|
|
:disabled="disabled"
|
|
@click="toggle"
|
|
@keydown="onKeydown"
|
|
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left transition-colors"
|
|
:class="{
|
|
'opacity-40 cursor-not-allowed': disabled,
|
|
'cursor-pointer': !disabled,
|
|
}"
|
|
:style="
|
|
isOpen
|
|
? {
|
|
borderColor: 'var(--color-accent)',
|
|
boxShadow: '0 0 0 2px var(--color-accent-muted)',
|
|
outline: 'none',
|
|
}
|
|
: {}
|
|
"
|
|
>
|
|
<slot name="selected" :label="selectedLabel ?? placeholder" :is-placeholder="isPlaceholderSelected">
|
|
<span
|
|
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
|
|
class="truncate"
|
|
>
|
|
{{ selectedLabel ?? placeholder }}
|
|
</span>
|
|
</slot>
|
|
<ChevronDown
|
|
aria-hidden="true"
|
|
class="w-4 h-4 text-text-secondary shrink-0 transition-transform duration-200"
|
|
:class="{ 'rotate-180': isOpen }"
|
|
:stroke-width="2"
|
|
/>
|
|
</button>
|
|
|
|
<!-- Dropdown panel -->
|
|
<Teleport to="#app">
|
|
<Transition name="dropdown">
|
|
<div
|
|
v-if="isOpen"
|
|
ref="panelRef"
|
|
:style="panelStyle"
|
|
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
|
|
>
|
|
<div v-if="searchable" class="px-2 pt-2 pb-1">
|
|
<input
|
|
ref="searchInputRef"
|
|
v-model="searchQuery"
|
|
type="text"
|
|
aria-label="Search options"
|
|
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
|
|
placeholder="Search..."
|
|
@keydown="onSearchKeydown"
|
|
/>
|
|
</div>
|
|
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" :id="listboxId">
|
|
<div
|
|
v-for="(item, index) in filteredItems"
|
|
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
|
|
role="option"
|
|
:id="'appselect-option-' + index"
|
|
:aria-selected="isSelected(item)"
|
|
data-option
|
|
@click="select(item)"
|
|
@mouseenter="highlightedIndex = index"
|
|
class="flex items-center justify-between gap-2 px-3 py-2 text-[0.8125rem] cursor-pointer transition-colors"
|
|
:class="{
|
|
'bg-bg-elevated': highlightedIndex === index,
|
|
'text-text-tertiary': item._isPlaceholder,
|
|
'text-text-primary': !item._isPlaceholder,
|
|
}"
|
|
>
|
|
<slot name="option" :item="item" :label="getOptionLabel(item)" :selected="isSelected(item)">
|
|
<span class="truncate">{{ getOptionLabel(item) }}</span>
|
|
</slot>
|
|
<Check
|
|
v-if="isSelected(item)"
|
|
aria-hidden="true"
|
|
class="w-4 h-4 text-accent shrink-0"
|
|
:stroke-width="2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|