feat: add utility components and helpers

Add DropdownSelectField and StatusPicker components for form inputs. Add time utility functions.
This commit is contained in:
Justin Edmund 2025-11-03 23:03:50 -08:00
parent 4df84addfa
commit 314885b704
3 changed files with 375 additions and 0 deletions

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { clickOutside } from '$lib/actions/clickOutside'
import DropdownItem from './DropdownItem.svelte'
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
import FormField from './FormField.svelte'
interface Option {
value: string
label: string
description?: string
}
interface Props {
label: string
value?: string
options: Option[]
required?: boolean
helpText?: string
error?: string
disabled?: boolean
placeholder?: string
}
let {
label,
value = $bindable(),
options,
required = false,
helpText,
error,
disabled = false,
placeholder = 'Select an option'
}: Props = $props()
let isOpen = $state(false)
const selectedOption = $derived(options.find((opt) => opt.value === value))
function handleSelect(optionValue: string) {
value = optionValue
isOpen = false
}
function handleClickOutside() {
isOpen = false
}
</script>
<FormField {label} {required} {helpText} {error}>
{#snippet children()}
<div
class="dropdown-select"
use:clickOutside={{ enabled: isOpen }}
onclickoutside={handleClickOutside}
>
<button
type="button"
class="dropdown-select-trigger"
class:open={isOpen}
class:has-value={!!value}
class:disabled
{disabled}
onclick={() => !disabled && (isOpen = !isOpen)}
>
<span class="dropdown-select-value">
{selectedOption?.label || placeholder}
</span>
<svg
class="chevron"
class:rotate={isOpen}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if isOpen}
<DropdownMenuContainer>
{#each options as option}
<DropdownItem
label={option.label}
description={option.description}
onclick={() => handleSelect(option.value)}
/>
{/each}
</DropdownMenuContainer>
{/if}
</div>
{/snippet}
</FormField>
<style lang="scss">
.dropdown-select {
position: relative;
width: 100%;
}
.dropdown-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: $unit-2x $unit-3x;
background: $input-bg;
border: 1px solid $input-border;
border-radius: $corner-radius-full;
font-size: 0.9375rem;
color: $input-text;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(.disabled) {
background: $input-bg-hover;
border-color: $gray-70;
}
&.open,
&:focus {
background: $input-bg-focus;
border-color: $input-border-focus;
outline: none;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(.has-value) {
.dropdown-select-value {
color: $gray-40;
}
}
}
.dropdown-select-value {
flex: 1;
text-align: left;
}
.chevron {
flex-shrink: 0;
color: $gray-40;
transition: transform 0.2s ease;
&.rotate {
transform: rotate(180deg);
}
}
</style>

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { clickOutside } from '$lib/actions/clickOutside'
import DropdownItem from './DropdownItem.svelte'
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
interface Props {
currentStatus: 'draft' | 'published' | 'list-only' | 'password-protected'
onChange: (status: string) => void
disabled?: boolean
viewUrl?: string
}
let { currentStatus, onChange, disabled = false, viewUrl }: Props = $props()
let isOpen = $state(false)
function handleStatusChange(status: string) {
onChange(status)
isOpen = false
}
function handleClickOutside() {
isOpen = false
}
const statusConfig = {
draft: {
label: 'Draft',
color: 'var(--status-draft, #f59e0b)'
},
published: {
label: 'Published',
color: 'var(--status-published, #10b981)'
},
'list-only': {
label: 'List Only',
color: 'var(--status-list-only, #3b82f6)'
},
'password-protected': {
label: 'Password Protected',
color: 'var(--status-password, #f97316)'
}
} as const
const currentConfig = $derived(statusConfig[currentStatus])
const availableStatuses = [
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'list-only', label: 'List Only' },
{ value: 'password-protected', label: 'Password Protected' }
]
</script>
<div
class="status-picker"
use:clickOutside={{ enabled: isOpen }}
onclickoutside={handleClickOutside}
>
<button
class="status-badge"
class:disabled
style="--status-color: {currentConfig.color}"
onclick={() => !disabled && (isOpen = !isOpen)}
type="button"
>
<span class="status-dot"></span>
<span class="status-label">{currentConfig.label}</span>
<svg
class="chevron"
class:open={isOpen}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if isOpen}
<DropdownMenuContainer>
{#each availableStatuses as status}
{#if status.value !== currentStatus}
<DropdownItem onclick={() => handleStatusChange(status.value)}>
{status.label}
</DropdownItem>
{/if}
{/each}
{#if viewUrl && currentStatus === 'published'}
<div class="dropdown-divider"></div>
<a
href={viewUrl}
target="_blank"
rel="noopener noreferrer"
class="dropdown-item view-link"
>
View on site
</a>
{/if}
</DropdownMenuContainer>
{/if}
</div>
<style lang="scss">
.status-picker {
position: relative;
display: inline-block;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: $unit-half;
padding: $unit-half $unit-2x;
background: transparent;
border: 1px solid $gray-70;
border-radius: $corner-radius-full;
font-size: 0.8125rem;
font-weight: 500;
color: var(--status-color);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover:not(.disabled) {
background: $gray-95;
border-color: $gray-60;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.status-label {
line-height: 1;
}
.chevron {
flex-shrink: 0;
transition: transform 0.2s ease;
color: $gray-40;
&.open {
transform: rotate(180deg);
}
}
.dropdown-divider {
height: 1px;
background-color: $gray-80;
margin: $unit-half 0;
}
.dropdown-item.view-link {
display: block;
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: $gray-20;
cursor: pointer;
transition: background-color $transition-normal ease;
text-decoration: none;
&:hover {
background-color: $gray-95;
}
}
</style>

27
src/lib/utils/time.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* Format a date as a relative time string (e.g., "2 minutes ago")
* @param date - The date to format
* @returns A human-readable relative time string
*/
export function formatTimeAgo(date: Date | string): string {
const now = new Date()
const past = new Date(date)
const seconds = Math.floor((now.getTime() - past.getTime()) / 1000)
if (seconds < 10) return 'just now'
if (seconds < 60) return `${seconds} seconds ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`
// For saves older than 24 hours, show formatted date
return past.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}