1104 lines
31 KiB
Markdown
1104 lines
31 KiB
Markdown
# Custom Dropdowns & Date Pickers Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace all 6 native `<select>` and 6 native `<input type="date">` elements with custom Vue 3 components matching ZeroClock's dark UI.
|
|
|
|
**Architecture:** Two new components (`AppSelect`, `AppDatePicker`) using `<Teleport to="body">` for overflow-safe positioning. Each renders a styled trigger + a floating panel positioned via `getBoundingClientRect()`. Click-outside detection and keyboard navigation built in. Then swap every native element across 5 views.
|
|
|
|
**Tech Stack:** Vue 3 Composition API (`<script setup lang="ts">`), Tailwind CSS v4 with `@theme` tokens, Lucide Vue icons, `<Teleport>`.
|
|
|
|
---
|
|
|
|
### Task 1: Create AppSelect Component
|
|
|
|
**Files:**
|
|
- Create: `src/components/AppSelect.vue`
|
|
|
|
**Context:** This component replaces all 6 native `<select>` elements. It needs to support: `v-model` with any value type (number, string, null, undefined), an array of option objects with configurable label/value keys, a placeholder, and a disabled state. The dropdown panel uses `<Teleport to="body">` to avoid overflow clipping from modals and scrollable containers.
|
|
|
|
**Existing patterns to follow:**
|
|
- Input styling: `bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-text-primary`
|
|
- Focus: `border-color: var(--color-accent)` with `box-shadow: 0 0 0 2px var(--color-accent-muted)` (defined in `main.css:60-64`)
|
|
- Icons: Import from `lucide-vue-next` (see `Timer.vue:121`)
|
|
- Animations: 200ms ease-out (see `main.css:107-119` for modal-enter pattern)
|
|
|
|
**Step 1: Create the component file**
|
|
|
|
Write `src/components/AppSelect.vue` with this complete implementation:
|
|
|
|
```vue
|
|
<template>
|
|
<div ref="containerRef" class="relative">
|
|
<!-- Trigger button -->
|
|
<button
|
|
ref="triggerRef"
|
|
type="button"
|
|
@click="toggle"
|
|
@keydown="handleTriggerKeydown"
|
|
:disabled="disabled"
|
|
class="w-full flex items-center justify-between px-3 py-2 bg-bg-inset border rounded-xl text-[0.8125rem] text-left transition-colors duration-150 focus:outline-none"
|
|
:class="[
|
|
isOpen ? 'border-accent shadow-[0_0_0_2px_var(--color-accent-muted)]' : 'border-border-subtle',
|
|
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer hover:border-border-visible'
|
|
]"
|
|
>
|
|
<span :class="selectedLabel ? 'text-text-primary' : 'text-text-tertiary'">
|
|
{{ selectedLabel || placeholder }}
|
|
</span>
|
|
<ChevronDown
|
|
class="w-3.5 h-3.5 text-text-tertiary shrink-0 ml-2 transition-transform duration-150"
|
|
:class="{ 'rotate-180': isOpen }"
|
|
:stroke-width="2"
|
|
/>
|
|
</button>
|
|
|
|
<!-- Dropdown panel (teleported to body) -->
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="isOpen"
|
|
ref="panelRef"
|
|
class="fixed z-[100] py-1 bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-y-auto animate-dropdown-enter"
|
|
:style="panelStyle"
|
|
@keydown="handlePanelKeydown"
|
|
>
|
|
<button
|
|
v-for="(option, index) in allOptions"
|
|
:key="getOptionValue(option)"
|
|
type="button"
|
|
@click="selectOption(option)"
|
|
@mouseenter="highlightedIndex = index"
|
|
class="w-full flex items-center justify-between px-3 py-2 text-[0.8125rem] text-left transition-colors duration-100"
|
|
:class="[
|
|
index === highlightedIndex ? 'bg-bg-elevated' : '',
|
|
isSelected(option) ? 'text-accent-text' : 'text-text-primary'
|
|
]"
|
|
>
|
|
<span>{{ getOptionLabel(option) }}</span>
|
|
<Check
|
|
v-if="isSelected(option)"
|
|
class="w-3.5 h-3.5 text-accent shrink-0 ml-2"
|
|
:stroke-width="2"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
|
import { ChevronDown, Check } from 'lucide-vue-next'
|
|
|
|
interface Props {
|
|
modelValue: any
|
|
options: any[]
|
|
labelKey?: string
|
|
valueKey?: string
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
placeholderValue?: any
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
labelKey: 'name',
|
|
valueKey: 'id',
|
|
placeholder: 'Select...',
|
|
disabled: false,
|
|
placeholderValue: undefined
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: any]
|
|
}>()
|
|
|
|
const containerRef = ref<HTMLElement>()
|
|
const triggerRef = ref<HTMLButtonElement>()
|
|
const panelRef = ref<HTMLElement>()
|
|
const isOpen = ref(false)
|
|
const highlightedIndex = ref(0)
|
|
const panelStyle = ref<Record<string, string>>({})
|
|
|
|
// Build full options list (placeholder option + real options)
|
|
const allOptions = computed(() => {
|
|
if (props.placeholder) {
|
|
return [{ __placeholder: true, [props.labelKey]: props.placeholder, [props.valueKey]: props.placeholderValue }, ...props.options]
|
|
}
|
|
return props.options
|
|
})
|
|
|
|
// Get option value
|
|
function getOptionValue(option: any): any {
|
|
if (option.__placeholder) return props.placeholderValue
|
|
return option[props.valueKey]
|
|
}
|
|
|
|
// Get option label
|
|
function getOptionLabel(option: any): string {
|
|
return option[props.labelKey] || ''
|
|
}
|
|
|
|
// Check if option is selected
|
|
function isSelected(option: any): boolean {
|
|
return getOptionValue(option) === props.modelValue
|
|
}
|
|
|
|
// Selected label for trigger display
|
|
const selectedLabel = computed(() => {
|
|
if (props.modelValue === null || props.modelValue === undefined || props.modelValue === props.placeholderValue) {
|
|
return ''
|
|
}
|
|
const found = props.options.find(o => o[props.valueKey] === props.modelValue)
|
|
return found ? found[props.labelKey] : ''
|
|
})
|
|
|
|
// Position the panel below the trigger
|
|
function positionPanel() {
|
|
if (!triggerRef.value) return
|
|
const rect = triggerRef.value.getBoundingClientRect()
|
|
panelStyle.value = {
|
|
top: `${rect.bottom + 4}px`,
|
|
left: `${rect.left}px`,
|
|
width: `${rect.width}px`,
|
|
maxHeight: '240px'
|
|
}
|
|
}
|
|
|
|
// Toggle open/close
|
|
function toggle() {
|
|
if (props.disabled) return
|
|
if (isOpen.value) {
|
|
close()
|
|
} else {
|
|
open()
|
|
}
|
|
}
|
|
|
|
function open() {
|
|
isOpen.value = true
|
|
// Set highlighted to current selection
|
|
const idx = allOptions.value.findIndex(o => isSelected(o))
|
|
highlightedIndex.value = idx >= 0 ? idx : 0
|
|
nextTick(() => {
|
|
positionPanel()
|
|
document.addEventListener('click', handleClickOutside, true)
|
|
document.addEventListener('scroll', positionPanel, true)
|
|
window.addEventListener('resize', positionPanel)
|
|
})
|
|
}
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
document.removeEventListener('click', handleClickOutside, true)
|
|
document.removeEventListener('scroll', positionPanel, true)
|
|
window.removeEventListener('resize', positionPanel)
|
|
}
|
|
|
|
function selectOption(option: any) {
|
|
emit('update:modelValue', getOptionValue(option))
|
|
close()
|
|
triggerRef.value?.focus()
|
|
}
|
|
|
|
// Click outside detection
|
|
function handleClickOutside(e: MouseEvent) {
|
|
const target = e.target as Node
|
|
if (
|
|
containerRef.value && !containerRef.value.contains(target) &&
|
|
panelRef.value && !panelRef.value.contains(target)
|
|
) {
|
|
close()
|
|
}
|
|
}
|
|
|
|
// Keyboard handling on trigger
|
|
function handleTriggerKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
if (!isOpen.value) {
|
|
open()
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
close()
|
|
}
|
|
}
|
|
|
|
// Keyboard handling on panel
|
|
function handlePanelKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
highlightedIndex.value = Math.min(highlightedIndex.value + 1, allOptions.value.length - 1)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
selectOption(allOptions.value[highlightedIndex.value])
|
|
} else if (e.key === 'Escape') {
|
|
close()
|
|
triggerRef.value?.focus()
|
|
}
|
|
}
|
|
|
|
// Cleanup on unmount
|
|
onBeforeUnmount(() => {
|
|
close()
|
|
})
|
|
</script>
|
|
```
|
|
|
|
**Step 2: Add dropdown animation to main.css**
|
|
|
|
In `src/styles/main.css`, add after the existing `animate-modal-enter` block (after line 120):
|
|
|
|
```css
|
|
/* Dropdown animations */
|
|
@keyframes dropdown-enter {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-4px) scale(0.98);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
.animate-dropdown-enter {
|
|
animation: dropdown-enter 150ms ease-out;
|
|
}
|
|
```
|
|
|
|
**Step 3: Verify it builds**
|
|
|
|
Run: `npx vite build`
|
|
Expected: Build succeeds (component isn't used yet, but it must compile)
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/components/AppSelect.vue src/styles/main.css
|
|
git commit -m "feat: add AppSelect custom dropdown component"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create AppDatePicker Component
|
|
|
|
**Files:**
|
|
- Create: `src/components/AppDatePicker.vue`
|
|
|
|
**Context:** This component replaces all 6 native `<input type="date">` elements. The v-model is a `YYYY-MM-DD` string (same format as native date input). The calendar popover is teleported to body. It needs month navigation, today highlighting, and selected-day highlighting.
|
|
|
|
**Step 1: Create the component file**
|
|
|
|
Write `src/components/AppDatePicker.vue` with this complete implementation:
|
|
|
|
```vue
|
|
<template>
|
|
<div ref="containerRef" class="relative">
|
|
<!-- Trigger -->
|
|
<button
|
|
ref="triggerRef"
|
|
type="button"
|
|
@click="toggle"
|
|
@keydown.escape="close"
|
|
class="w-full flex items-center justify-between px-3 py-2 bg-bg-inset border rounded-xl text-[0.8125rem] text-left transition-colors duration-150 focus:outline-none"
|
|
:class="[
|
|
isOpen ? 'border-accent shadow-[0_0_0_2px_var(--color-accent-muted)]' : 'border-border-subtle',
|
|
'cursor-pointer hover:border-border-visible'
|
|
]"
|
|
>
|
|
<span :class="modelValue ? 'text-text-primary' : 'text-text-tertiary'">
|
|
{{ displayValue || placeholder }}
|
|
</span>
|
|
<CalendarIcon
|
|
class="w-3.5 h-3.5 text-text-tertiary shrink-0 ml-2"
|
|
:stroke-width="2"
|
|
/>
|
|
</button>
|
|
|
|
<!-- Calendar popover (teleported to body) -->
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="isOpen"
|
|
ref="panelRef"
|
|
class="fixed z-[100] p-3 bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] animate-dropdown-enter"
|
|
:style="panelStyle"
|
|
>
|
|
<!-- Month/Year header -->
|
|
<div class="flex items-center justify-between mb-3">
|
|
<button
|
|
type="button"
|
|
@click="prevMonth"
|
|
class="p-1 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-bg-elevated transition-colors"
|
|
>
|
|
<ChevronLeft class="w-4 h-4" :stroke-width="2" />
|
|
</button>
|
|
<span class="text-[0.8125rem] font-medium text-text-primary">
|
|
{{ monthNames[viewMonth] }} {{ viewYear }}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
@click="nextMonth"
|
|
class="p-1 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-bg-elevated transition-colors"
|
|
>
|
|
<ChevronRight class="w-4 h-4" :stroke-width="2" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Day-of-week headers -->
|
|
<div class="grid grid-cols-7 mb-1">
|
|
<div
|
|
v-for="day in dayHeaders"
|
|
:key="day"
|
|
class="text-center text-[0.625rem] text-text-tertiary uppercase tracking-wider py-1"
|
|
>
|
|
{{ day }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Day grid -->
|
|
<div class="grid grid-cols-7">
|
|
<button
|
|
v-for="(cell, index) in calendarCells"
|
|
:key="index"
|
|
type="button"
|
|
@click="cell.currentMonth ? selectDay(cell.day) : undefined"
|
|
class="w-8 h-8 flex items-center justify-center text-[0.75rem] rounded-lg transition-colors duration-100"
|
|
:class="getDayCellClasses(cell)"
|
|
:disabled="!cell.currentMonth"
|
|
>
|
|
{{ cell.day }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Today shortcut -->
|
|
<div class="mt-2 pt-2 border-t border-border-subtle">
|
|
<button
|
|
type="button"
|
|
@click="selectToday"
|
|
class="w-full text-center text-[0.75rem] text-text-secondary hover:text-accent-text py-1 transition-colors"
|
|
>
|
|
Today
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
|
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
|
|
|
interface Props {
|
|
modelValue: string // YYYY-MM-DD format
|
|
placeholder?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
placeholder: 'Select date'
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string]
|
|
}>()
|
|
|
|
const containerRef = ref<HTMLElement>()
|
|
const triggerRef = ref<HTMLButtonElement>()
|
|
const panelRef = ref<HTMLElement>()
|
|
const isOpen = ref(false)
|
|
const panelStyle = ref<Record<string, string>>({})
|
|
|
|
// Calendar view state
|
|
const today = new Date()
|
|
const viewYear = ref(today.getFullYear())
|
|
const viewMonth = ref(today.getMonth())
|
|
|
|
const monthNames = [
|
|
'January', 'February', 'March', 'April', 'May', 'June',
|
|
'July', 'August', 'September', 'October', 'November', 'December'
|
|
]
|
|
|
|
const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
|
|
|
// Display value for trigger
|
|
const displayValue = computed(() => {
|
|
if (!props.modelValue) return ''
|
|
const [year, month, day] = props.modelValue.split('-').map(Number)
|
|
const date = new Date(year, month - 1, day)
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
})
|
|
})
|
|
|
|
// When modelValue changes, update the view to show that month
|
|
watch(() => props.modelValue, (val) => {
|
|
if (val) {
|
|
const [year, month] = val.split('-').map(Number)
|
|
viewYear.value = year
|
|
viewMonth.value = month - 1
|
|
}
|
|
}, { immediate: true })
|
|
|
|
interface CalendarCell {
|
|
day: number
|
|
currentMonth: boolean
|
|
dateStr: string
|
|
}
|
|
|
|
// Generate calendar grid cells
|
|
const calendarCells = computed((): CalendarCell[] => {
|
|
const cells: CalendarCell[] = []
|
|
const year = viewYear.value
|
|
const month = viewMonth.value
|
|
|
|
// First day of the month (0=Sun, 1=Mon, ...)
|
|
const firstDay = new Date(year, month, 1).getDay()
|
|
// Convert to Monday-start (Mon=0, Tue=1, ..., Sun=6)
|
|
const startOffset = firstDay === 0 ? 6 : firstDay - 1
|
|
|
|
// Days in current month
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
|
// Days in previous month
|
|
const daysInPrevMonth = new Date(year, month, 0).getDate()
|
|
|
|
// Previous month padding
|
|
for (let i = startOffset - 1; i >= 0; i--) {
|
|
const day = daysInPrevMonth - i
|
|
const prevMonth = month === 0 ? 11 : month - 1
|
|
const prevYear = month === 0 ? year - 1 : year
|
|
cells.push({
|
|
day,
|
|
currentMonth: false,
|
|
dateStr: formatDateStr(prevYear, prevMonth, day)
|
|
})
|
|
}
|
|
|
|
// Current month days
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
cells.push({
|
|
day,
|
|
currentMonth: true,
|
|
dateStr: formatDateStr(year, month, day)
|
|
})
|
|
}
|
|
|
|
// Next month padding (fill to 42 cells = 6 rows)
|
|
const remaining = 42 - cells.length
|
|
for (let day = 1; day <= remaining; day++) {
|
|
const nextMonth = month === 11 ? 0 : month + 1
|
|
const nextYear = month === 11 ? year + 1 : year
|
|
cells.push({
|
|
day,
|
|
currentMonth: false,
|
|
dateStr: formatDateStr(nextYear, nextMonth, day)
|
|
})
|
|
}
|
|
|
|
return cells
|
|
})
|
|
|
|
function formatDateStr(year: number, month: number, day: number): string {
|
|
return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
}
|
|
|
|
// Get CSS classes for a day cell
|
|
function getDayCellClasses(cell: CalendarCell): string {
|
|
const classes: string[] = []
|
|
|
|
if (!cell.currentMonth) {
|
|
classes.push('text-text-tertiary/40 cursor-default')
|
|
return classes.join(' ')
|
|
}
|
|
|
|
// Selected
|
|
if (cell.dateStr === props.modelValue) {
|
|
classes.push('bg-accent text-bg-base font-medium')
|
|
return classes.join(' ')
|
|
}
|
|
|
|
// Today
|
|
const todayStr = formatDateStr(today.getFullYear(), today.getMonth(), today.getDate())
|
|
if (cell.dateStr === todayStr) {
|
|
classes.push('ring-1 ring-accent text-accent-text')
|
|
} else {
|
|
classes.push('text-text-primary')
|
|
}
|
|
|
|
classes.push('hover:bg-bg-elevated cursor-pointer')
|
|
return classes.join(' ')
|
|
}
|
|
|
|
// Month navigation
|
|
function prevMonth() {
|
|
if (viewMonth.value === 0) {
|
|
viewMonth.value = 11
|
|
viewYear.value--
|
|
} else {
|
|
viewMonth.value--
|
|
}
|
|
}
|
|
|
|
function nextMonth() {
|
|
if (viewMonth.value === 11) {
|
|
viewMonth.value = 0
|
|
viewYear.value++
|
|
} else {
|
|
viewMonth.value++
|
|
}
|
|
}
|
|
|
|
// Select a day
|
|
function selectDay(day: number) {
|
|
const dateStr = formatDateStr(viewYear.value, viewMonth.value, day)
|
|
emit('update:modelValue', dateStr)
|
|
close()
|
|
}
|
|
|
|
// Select today shortcut
|
|
function selectToday() {
|
|
const dateStr = formatDateStr(today.getFullYear(), today.getMonth(), today.getDate())
|
|
emit('update:modelValue', dateStr)
|
|
viewYear.value = today.getFullYear()
|
|
viewMonth.value = today.getMonth()
|
|
close()
|
|
}
|
|
|
|
// Position the panel below the trigger
|
|
function positionPanel() {
|
|
if (!triggerRef.value) return
|
|
const rect = triggerRef.value.getBoundingClientRect()
|
|
const panelWidth = 280
|
|
// Ensure panel doesn't overflow right edge
|
|
let left = rect.left
|
|
if (left + panelWidth > window.innerWidth - 8) {
|
|
left = window.innerWidth - panelWidth - 8
|
|
}
|
|
panelStyle.value = {
|
|
top: `${rect.bottom + 4}px`,
|
|
left: `${left}px`,
|
|
width: `${panelWidth}px`
|
|
}
|
|
}
|
|
|
|
function toggle() {
|
|
if (isOpen.value) {
|
|
close()
|
|
} else {
|
|
open()
|
|
}
|
|
}
|
|
|
|
function open() {
|
|
isOpen.value = true
|
|
nextTick(() => {
|
|
positionPanel()
|
|
document.addEventListener('click', handleClickOutside, true)
|
|
document.addEventListener('scroll', positionPanel, true)
|
|
window.addEventListener('resize', positionPanel)
|
|
})
|
|
}
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
document.removeEventListener('click', handleClickOutside, true)
|
|
document.removeEventListener('scroll', positionPanel, true)
|
|
window.removeEventListener('resize', positionPanel)
|
|
}
|
|
|
|
function handleClickOutside(e: MouseEvent) {
|
|
const target = e.target as Node
|
|
if (
|
|
containerRef.value && !containerRef.value.contains(target) &&
|
|
panelRef.value && !panelRef.value.contains(target)
|
|
) {
|
|
close()
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
close()
|
|
})
|
|
</script>
|
|
```
|
|
|
|
**Step 2: Verify it builds**
|
|
|
|
Run: `npx vite build`
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/AppDatePicker.vue
|
|
git commit -m "feat: add AppDatePicker custom calendar component"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Replace Selects in Timer.vue
|
|
|
|
**Files:**
|
|
- Modify: `src/views/Timer.vue:29-59` (template selects), `src/views/Timer.vue:121` (imports)
|
|
|
|
**Context:** Timer.vue has 2 native selects:
|
|
1. Project selector (line 29-42): `v-model="selectedProject"`, options from `activeProjects`, disabled when `timerStore.isRunning`
|
|
2. Task selector (line 46-59): `v-model="selectedTask"`, options from `projectTasks`, disabled when `timerStore.isRunning || !selectedProject`
|
|
|
|
**Step 1: Add import**
|
|
|
|
In the `<script setup>` imports section (line 121), add `AppSelect`:
|
|
|
|
Change:
|
|
```ts
|
|
import { Timer as TimerIcon } from 'lucide-vue-next'
|
|
```
|
|
To:
|
|
```ts
|
|
import { Timer as TimerIcon } from 'lucide-vue-next'
|
|
import AppSelect from '../components/AppSelect.vue'
|
|
```
|
|
|
|
**Step 2: Replace project select (lines 29-42)**
|
|
|
|
Replace:
|
|
```html
|
|
<select
|
|
v-model="selectedProject"
|
|
:disabled="timerStore.isRunning"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<option :value="null">Select project</option>
|
|
<option
|
|
v-for="project in activeProjects"
|
|
:key="project.id"
|
|
:value="project.id"
|
|
>
|
|
{{ project.name }}
|
|
</option>
|
|
</select>
|
|
```
|
|
With:
|
|
```html
|
|
<AppSelect
|
|
v-model="selectedProject"
|
|
:options="activeProjects"
|
|
label-key="name"
|
|
value-key="id"
|
|
placeholder="Select project"
|
|
:placeholder-value="null"
|
|
:disabled="timerStore.isRunning"
|
|
/>
|
|
```
|
|
|
|
**Step 3: Replace task select (lines 46-59)**
|
|
|
|
Replace:
|
|
```html
|
|
<select
|
|
v-model="selectedTask"
|
|
:disabled="timerStore.isRunning || !selectedProject"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<option :value="null">Select task</option>
|
|
<option
|
|
v-for="task in projectTasks"
|
|
:key="task.id"
|
|
:value="task.id"
|
|
>
|
|
{{ task.name }}
|
|
</option>
|
|
</select>
|
|
```
|
|
With:
|
|
```html
|
|
<AppSelect
|
|
v-model="selectedTask"
|
|
:options="projectTasks"
|
|
label-key="name"
|
|
value-key="id"
|
|
placeholder="Select task"
|
|
:placeholder-value="null"
|
|
:disabled="timerStore.isRunning || !selectedProject"
|
|
/>
|
|
```
|
|
|
|
**Step 4: Verify it builds**
|
|
|
|
Run: `npx vite build`
|
|
Expected: Build succeeds
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/views/Timer.vue
|
|
git commit -m "feat: replace native selects with AppSelect in Timer view"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Replace Select in Projects.vue
|
|
|
|
**Files:**
|
|
- Modify: `src/views/Projects.vue:95-107` (template), `src/views/Projects.vue:203` (imports)
|
|
|
|
**Context:** Projects.vue has 1 native select: Client selector in the create/edit dialog (line 95-107). `v-model="formData.client_id"`, optional with "No client" placeholder, value is `undefined` when no client.
|
|
|
|
**Step 1: Add import**
|
|
|
|
After line 203 (`import { FolderKanban } from 'lucide-vue-next'`), add:
|
|
```ts
|
|
import AppSelect from '../components/AppSelect.vue'
|
|
```
|
|
|
|
**Step 2: Replace client select (lines 95-107)**
|
|
|
|
Replace:
|
|
```html
|
|
<select
|
|
v-model="formData.client_id"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
>
|
|
<option :value="undefined">No client</option>
|
|
<option
|
|
v-for="client in clientsStore.clients"
|
|
:key="client.id"
|
|
:value="client.id"
|
|
>
|
|
{{ client.name }}
|
|
</option>
|
|
</select>
|
|
```
|
|
With:
|
|
```html
|
|
<AppSelect
|
|
v-model="formData.client_id"
|
|
:options="clientsStore.clients"
|
|
label-key="name"
|
|
value-key="id"
|
|
placeholder="No client"
|
|
:placeholder-value="undefined"
|
|
/>
|
|
```
|
|
|
|
**Step 3: Verify and commit**
|
|
|
|
Run: `npx vite build`
|
|
|
|
```bash
|
|
git add src/views/Projects.vue
|
|
git commit -m "feat: replace native select with AppSelect in Projects view"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Replace Selects and Date Pickers in Entries.vue
|
|
|
|
**Files:**
|
|
- Modify: `src/views/Entries.vue:9-13,17-21,25-37,142-154` (template), `src/views/Entries.vue:241` (imports)
|
|
|
|
**Context:** Entries.vue has:
|
|
- 2 date pickers: Start Date filter (line 9-13), End Date filter (line 17-21)
|
|
- 1 project filter select (line 25-37): `v-model="filterProject"`, nullable, "All Projects" placeholder
|
|
- 1 project select in edit dialog (line 142-154): `v-model="editForm.project_id"`, required (no placeholder option)
|
|
|
|
**Step 1: Add imports**
|
|
|
|
After line 241 (`import { List as ListIcon } from 'lucide-vue-next'`), add:
|
|
```ts
|
|
import AppSelect from '../components/AppSelect.vue'
|
|
import AppDatePicker from '../components/AppDatePicker.vue'
|
|
```
|
|
|
|
**Step 2: Replace Start Date input (lines 9-13)**
|
|
|
|
Replace:
|
|
```html
|
|
<input
|
|
v-model="startDate"
|
|
type="date"
|
|
class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
/>
|
|
```
|
|
With:
|
|
```html
|
|
<AppDatePicker
|
|
v-model="startDate"
|
|
placeholder="Start date"
|
|
/>
|
|
```
|
|
|
|
**Step 3: Replace End Date input (lines 17-21)**
|
|
|
|
Replace:
|
|
```html
|
|
<input
|
|
v-model="endDate"
|
|
type="date"
|
|
class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
/>
|
|
```
|
|
With:
|
|
```html
|
|
<AppDatePicker
|
|
v-model="endDate"
|
|
placeholder="End date"
|
|
/>
|
|
```
|
|
|
|
**Step 4: Replace project filter select (lines 25-37)**
|
|
|
|
Replace:
|
|
```html
|
|
<select
|
|
v-model="filterProject"
|
|
class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
>
|
|
<option :value="null">All Projects</option>
|
|
<option
|
|
v-for="project in projectsStore.projects"
|
|
:key="project.id"
|
|
:value="project.id"
|
|
>
|
|
{{ project.name }}
|
|
</option>
|
|
</select>
|
|
```
|
|
With:
|
|
```html
|
|
<AppSelect
|
|
v-model="filterProject"
|
|
:options="projectsStore.projects"
|
|
label-key="name"
|
|
value-key="id"
|
|
placeholder="All Projects"
|
|
:placeholder-value="null"
|
|
/>
|
|
```
|
|
|
|
**Step 5: Replace project select in edit dialog (lines 142-154)**
|
|
|
|
Replace:
|
|
```html
|
|
<select
|
|
v-model="editForm.project_id"
|
|
required
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
>
|
|
<option
|
|
v-for="project in projectsStore.projects"
|
|
:key="project.id"
|
|
:value="project.id"
|
|
>
|
|
{{ project.name }}
|
|
</option>
|
|
</select>
|
|
```
|
|
With:
|
|
```html
|
|
<AppSelect
|
|
v-model="editForm.project_id"
|
|
:options="projectsStore.projects"
|
|
label-key="name"
|
|
value-key="id"
|
|
placeholder="Select project"
|
|
/>
|
|
```
|
|
|
|
**Step 6: Verify and commit**
|
|
|
|
Run: `npx vite build`
|
|
|
|
```bash
|
|
git add src/views/Entries.vue
|
|
git commit -m "feat: replace native selects and date pickers with custom components in Entries view"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Replace Date Pickers in Reports.vue
|
|
|
|
**Files:**
|
|
- Modify: `src/views/Reports.vue:9-13,17-21` (template), `src/views/Reports.vue:108` (imports)
|
|
|
|
**Context:** Reports.vue has 2 date pickers: Start Date (line 9-13) and End Date (line 17-21). Both bind to `startDate`/`endDate` refs.
|
|
|
|
**Step 1: Add import**
|
|
|
|
After line 108 (`import { ref, computed, onMounted } from 'vue'`), on a separate line in the imports area, add:
|
|
```ts
|
|
import AppDatePicker from '../components/AppDatePicker.vue'
|
|
```
|
|
|
|
**Step 2: Replace Start Date input (lines 9-13)**
|
|
|
|
Replace:
|
|
```html
|
|
<input
|
|
v-model="startDate"
|
|
type="date"
|
|
class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
/>
|
|
```
|
|
With:
|
|
```html
|
|
<AppDatePicker
|
|
v-model="startDate"
|
|
placeholder="Start date"
|
|
/>
|
|
```
|
|
|
|
**Step 3: Replace End Date input (lines 17-21)**
|
|
|
|
Replace:
|
|
```html
|
|
<input
|
|
v-model="endDate"
|
|
type="date"
|
|
class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
/>
|
|
```
|
|
With:
|
|
```html
|
|
<AppDatePicker
|
|
v-model="endDate"
|
|
placeholder="End date"
|
|
/>
|
|
```
|
|
|
|
**Step 4: Verify and commit**
|
|
|
|
Run: `npx vite build`
|
|
|
|
```bash
|
|
git add src/views/Reports.vue
|
|
git commit -m "feat: replace native date pickers with AppDatePicker in Reports view"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Replace Select and Date Pickers in Invoices.vue
|
|
|
|
**Files:**
|
|
- Modify: `src/views/Invoices.vue:129-142,149-154,158-162` (template), `src/views/Invoices.vue:342-343` (imports)
|
|
|
|
**Context:** Invoices.vue has:
|
|
- 1 client select (line 129-142): `v-model="createForm.client_id"`, required, "Select a client" with value `0`
|
|
- 1 invoice date (line 149-154): `v-model="createForm.date"`, required
|
|
- 1 due date (line 158-162): `v-model="createForm.due_date"`, optional
|
|
|
|
**Step 1: Add imports**
|
|
|
|
After line 343 (`import { FileText } from 'lucide-vue-next'`), add:
|
|
```ts
|
|
import AppSelect from '../components/AppSelect.vue'
|
|
import AppDatePicker from '../components/AppDatePicker.vue'
|
|
```
|
|
|
|
**Step 2: Replace client select (lines 129-142)**
|
|
|
|
Replace:
|
|
```html
|
|
<select
|
|
v-model="createForm.client_id"
|
|
required
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
>
|
|
<option :value="0">Select a client</option>
|
|
<option
|
|
v-for="client in clientsStore.clients"
|
|
:key="client.id"
|
|
:value="client.id"
|
|
>
|
|
{{ client.name }}
|
|
</option>
|
|
</select>
|
|
```
|
|
With:
|
|
```html
|
|
<AppSelect
|
|
v-model="createForm.client_id"
|
|
:options="clientsStore.clients"
|
|
label-key="name"
|
|
value-key="id"
|
|
placeholder="Select a client"
|
|
:placeholder-value="0"
|
|
/>
|
|
```
|
|
|
|
**Step 3: Replace invoice date input (lines 149-154)**
|
|
|
|
Replace:
|
|
```html
|
|
<input
|
|
v-model="createForm.date"
|
|
type="date"
|
|
required
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
/>
|
|
```
|
|
With:
|
|
```html
|
|
<AppDatePicker
|
|
v-model="createForm.date"
|
|
placeholder="Invoice date"
|
|
/>
|
|
```
|
|
|
|
**Step 4: Replace due date input (lines 158-162)**
|
|
|
|
Replace:
|
|
```html
|
|
<input
|
|
v-model="createForm.due_date"
|
|
type="date"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
/>
|
|
```
|
|
With:
|
|
```html
|
|
<AppDatePicker
|
|
v-model="createForm.due_date"
|
|
placeholder="Due date"
|
|
/>
|
|
```
|
|
|
|
**Step 5: Verify and commit**
|
|
|
|
Run: `npx vite build`
|
|
|
|
```bash
|
|
git add src/views/Invoices.vue
|
|
git commit -m "feat: replace native select and date pickers with custom components in Invoices view"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Final Build Verification
|
|
|
|
**Files:** None (verification only)
|
|
|
|
**Step 1: Full build**
|
|
|
|
Run: `npx vite build`
|
|
Expected: Clean build, no errors, no warnings about unused imports.
|
|
|
|
**Step 2: Verify no remaining native selects or date inputs**
|
|
|
|
Run grep to confirm zero remaining instances:
|
|
```bash
|
|
grep -rn '<select' src/views/ src/components/
|
|
grep -rn 'type="date"' src/views/ src/components/
|
|
```
|
|
|
|
Expected: No matches (the `datetime-local` in Entries.vue is `type="datetime-local"`, not `type="date"`, so it won't match).
|
|
|
|
**Step 3: Final commit if any cleanup needed**
|
|
|
|
If all clean, no commit needed. The feature is complete.
|