Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions frontend/src/lib/components/AdaptiveDateInput.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script lang="ts">
import { Calendar } from '@lucide/svelte';
import SegmentedDateInput from './SegmentedDateInput.svelte';

let {
value = $bindable(''),
invalid = $bindable(false),
hasInput = $bindable(false),
name = '',
min = undefined,
max = undefined,
disabled = false,
required = false,
inputClass = 'input input-bordered input-sm w-full',
ariaLabel = ''
}: {
value?: string;
invalid?: boolean;
hasInput?: boolean;
name?: string;
min?: string;
max?: string;
disabled?: boolean;
required?: boolean;
inputClass?: string;
ariaLabel?: string;
} = $props();

let pickerInputEl: HTMLInputElement | undefined = $state();

function openNativePicker() {
if (disabled || !pickerInputEl) return;
try {
pickerInputEl.showPicker();
} catch {
pickerInputEl.focus();
pickerInputEl.click();
}
}
</script>

<div class="flex items-center gap-2">
<div class="flex-1 min-w-0 relative">
<SegmentedDateInput
{name}
bind:value
bind:invalid
bind:hasInput
{min}
{max}
{disabled}
{required}
{inputClass}
{ariaLabel}
/>
<!-- Positioned over the date input so the native picker opens next to the field -->
<input
bind:this={pickerInputEl}
type="date"
class="absolute inset-0 opacity-0 pointer-events-none -z-10"
bind:value
{min}
{max}
{disabled}
{required}
aria-hidden="true"
tabindex="-1"
/>
</div>

<button
type="button"
class="btn btn-outline btn-sm"
onclick={openNativePicker}
disabled={disabled}
aria-label={`Open date picker${ariaLabel ? ` for ${ariaLabel}` : ''}`}
title="Open date picker"
>
<Calendar class="w-4 h-4" />
</button>
</div>
40 changes: 37 additions & 3 deletions frontend/src/lib/components/BookDrawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import CoverPicker from './CoverPicker.svelte';
import SuggestionInput from './SuggestionInput.svelte';
import TagInput from './TagInput.svelte';
import AdaptiveDateInput from './AdaptiveDateInput.svelte';
import DateConflictDialog from './DateConflictDialog.svelte';
import AutoSearchCoverModal from './AutoSearchCoverModal.svelte';
import BarcodeScanner from './BarcodeScanner.svelte';
Expand Down Expand Up @@ -61,6 +62,10 @@
let tags = $state('');
let date_started = $state('');
let date_finished = $state('');
let dateStartedInvalid = $state(false);
let dateFinishedInvalid = $state(false);
let dateStartedHasInput = $state(false);
let dateFinishedHasInput = $state(false);
let cover_url = $state<string | null>(null);

// ── Android back button: close drawer instead of navigating away ──────────
Expand Down Expand Up @@ -216,6 +221,14 @@
toasts.add($_('error.pageCountRequired'), 'error');
return;
}
if (dateStartedInvalid && dateStartedHasInput) {
toasts.add($_('error.invalidDate'), 'error');
return;
}
if (dateFinishedInvalid && dateFinishedHasInput) {
toasts.add($_('error.invalidDate'), 'error');
return;
}
const ds = date_started.trim();
const df = date_finished.trim();
if (ds && df && ds > df) {
Expand Down Expand Up @@ -501,12 +514,30 @@

<label class="flex flex-col gap-1">
<span class="label label-text">{$_('book.dateStarted')}</span>
<input type="date" class="input input-bordered input-sm" name="date_started" bind:value={date_started} max={today} />
<AdaptiveDateInput
name="date_started"
bind:value={date_started}
bind:invalid={dateStartedInvalid}
bind:hasInput={dateStartedHasInput}
ariaLabel={$_('book.dateStarted')}
/>
{#if dateStartedInvalid && dateStartedHasInput}
<span class="label label-text-alt text-error">{$_('error.invalidDate')}</span>
{/if}
</label>

<label class="flex flex-col gap-1">
<span class="label label-text">{$_('book.dateFinished')}</span>
<input type="date" class="input input-bordered input-sm" name="date_finished" bind:value={date_finished} max={today} />
<AdaptiveDateInput
name="date_finished"
bind:value={date_finished}
bind:invalid={dateFinishedInvalid}
bind:hasInput={dateFinishedHasInput}
ariaLabel={$_('book.dateFinished')}
/>
{#if dateFinishedInvalid && dateFinishedHasInput}
<span class="label label-text-alt text-error">{$_('error.invalidDate')}</span>
{/if}
</label>

<label class="flex flex-col gap-1">
Expand Down Expand Up @@ -596,7 +627,10 @@
<p class="text-sm text-base-content/70 mt-2">{$_('book.startDatePromptMessage')}</p>
<label class="flex flex-col gap-1 mt-4">
<span class="label label-text">{$_('book.dateStarted')}</span>
<input type="date" class="input input-bordered input-sm" bind:value={promptedStartDate} max={today} />
<AdaptiveDateInput
bind:value={promptedStartDate}
ariaLabel={$_('book.dateStarted')}
/>
</label>
<div class="modal-action">
<button
Expand Down
33 changes: 29 additions & 4 deletions frontend/src/lib/components/BookDrawer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ describe('BookDrawer', () => {
render(BookDrawer, { props: { book: mockBook, open: true } });
expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /ISBN/ })).toBeInTheDocument();
expect(screen.getByLabelText(/Year/)).toBeInTheDocument();
expect(screen.getByLabelText(/Pages/)).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: /^Year$/ })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: /Pages/ })).toBeInTheDocument();
expect(screen.getByLabelText(/Language/)).toBeInTheDocument();
expect(screen.getByLabelText(/Status/)).toBeInTheDocument();
expect(screen.getByLabelText(/Notes/)).toBeInTheDocument();
Expand Down Expand Up @@ -151,8 +151,8 @@ describe('BookDrawer', () => {

it('shows date inputs', () => {
render(BookDrawer, { props: { book: mockBook, open: true } });
expect(screen.getByLabelText(/Date started/)).toBeInTheDocument();
expect(screen.getByLabelText(/Date finished/)).toBeInTheDocument();
expect(document.querySelector('input[name="date_started"]')).toBeInTheDocument();
expect(document.querySelector('input[name="date_finished"]')).toBeInTheDocument();
});

it('has rating selector', () => {
Expand Down Expand Up @@ -182,4 +182,29 @@ describe('BookDrawer', () => {
const autoSearchBtn = screen.getByRole('button', { name: 'Auto-search covers' });
expect(autoSearchBtn).not.toBeDisabled();
});

it('blocks save and shows error when date_started is invalid', async () => {
render(BookDrawer, { props: { book: mockBook, open: true } });

const day = screen.getByLabelText('Date started') as HTMLInputElement;
await fireEvent.input(day, { target: { value: '31' } });
await fireEvent.blur(day);

const saveBtn = screen.getByRole('button', { name: 'Save' });
await fireEvent.click(saveBtn);

expect(mockBooksUpdate).not.toHaveBeenCalled();
expect(mockTransitionStatus).not.toHaveBeenCalled();
expect(mockToastsAdd).toHaveBeenCalledWith('Please enter a valid date.', 'error');
});

it('shows inline helper text when date_started is invalid', async () => {
render(BookDrawer, { props: { book: mockBook, open: true } });

const day = screen.getByLabelText('Date started') as HTMLInputElement;
await fireEvent.input(day, { target: { value: '31' } });
await fireEvent.blur(day);

expect(screen.getByText('Please enter a valid date.')).toBeInTheDocument();
});
});
Loading