feat: tooltips, two-column timer, font selector, tray behavior, icons, readme
- 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
This commit is contained in:
@@ -12,6 +12,7 @@ interface Props {
|
||||
disabled?: boolean
|
||||
placeholderValue?: any
|
||||
searchable?: boolean
|
||||
ariaLabelledby?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -27,6 +28,7 @@ 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)
|
||||
@@ -80,7 +82,10 @@ function isSelected(item: any): boolean {
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { estimatedHeight: 280 })
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, {
|
||||
estimatedHeight: 280,
|
||||
panelEl: panelRef.value,
|
||||
})
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -108,6 +113,8 @@ function open() {
|
||||
|
||||
nextTick(() => {
|
||||
scrollHighlightedIntoView()
|
||||
// Reposition with actual panel height (fixes above-flip offset)
|
||||
updatePosition()
|
||||
})
|
||||
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
@@ -220,6 +227,12 @@ onBeforeUnmount(() => {
|
||||
<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"
|
||||
@@ -238,13 +251,16 @@ onBeforeUnmount(() => {
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
|
||||
class="truncate"
|
||||
>
|
||||
{{ selectedLabel ?? placeholder }}
|
||||
</span>
|
||||
<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"
|
||||
@@ -252,7 +268,7 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<!-- Dropdown panel -->
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
@@ -265,15 +281,19 @@ onBeforeUnmount(() => {
|
||||
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">
|
||||
<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"
|
||||
@@ -284,9 +304,12 @@ onBeforeUnmount(() => {
|
||||
'text-text-primary': !item._isPlaceholder,
|
||||
}"
|
||||
>
|
||||
<span class="truncate">{{ getOptionLabel(item) }}</span>
|
||||
<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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user