flowbite-svelte v1 turns components into runes: on:click on
components becomes onclick props (native elements keep on:), Modal
footers become {#snippet footer()}, Button["color"] indexing becomes
the exported ButtonProps type, and slate is gone from the color
union so the datepicker greys map to gray.
Stores now use the generated bindings models (what the Go backend
actually returns) instead of hand-written shapes that drifted from
them - nullability of genres/tags/mediaList included. That single
move clears most of the newly surfaced type errors, including the
optional episodes/nextAiringEpisode cluster. The rest is strictness
debt the old red check masked: definite-assignment on
subscribe-fed lets, null guards on querySelector/match results and
date parsing (two of which fixed latent throw-on-garbage paths),
aria-labels on icon-only buttons, the completed default-data
literal, and a shared flex centering context for the detail poster
column so poster and stars align by construction at every width.
483 lines
17 KiB
Svelte
483 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { createEventDispatcher, onMount } from "svelte";
|
|
import { fade } from "svelte/transition";
|
|
import { Button } from "flowbite-svelte";
|
|
import type { ButtonProps } from "flowbite-svelte";
|
|
|
|
export let value: Date | null = null;
|
|
export let defaultDate: Date | null = null;
|
|
export let range: boolean = false;
|
|
export let rangeFrom: Date | null = null;
|
|
export let rangeTo: Date | null = null;
|
|
export let locale: string = "default";
|
|
export let firstDayOfWeek: number = 0; // 0 = Monday, 6 = Sunday
|
|
export let dateFormat: Intl.DateTimeFormatOptions = {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
};
|
|
export let placeholder: string = "Select date";
|
|
export let disabled: boolean = false;
|
|
export let required: boolean = false;
|
|
export let inputClass: string = "";
|
|
export let color: ButtonProps["color"] = "primary";
|
|
export let inline: boolean = false;
|
|
export let autohide: boolean = true;
|
|
export let showActionButtons: boolean = false;
|
|
export let title: string = "";
|
|
|
|
// Internal state
|
|
const dispatch = createEventDispatcher();
|
|
let isOpen: boolean = inline;
|
|
let inputElement: HTMLInputElement;
|
|
let datepickerContainerElement: HTMLDivElement;
|
|
let currentMonth: Date = value || defaultDate || new Date();
|
|
let focusedDate: Date | null = null;
|
|
let calendarRef: HTMLDivElement;
|
|
|
|
$: daysInMonth = getDaysInMonth(currentMonth);
|
|
$: weekdays = getWeekdays();
|
|
|
|
onMount(() => {
|
|
if (!inline) {
|
|
document.addEventListener("click", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("click", handleClickOutside);
|
|
};
|
|
}
|
|
});
|
|
|
|
// Color handling functions
|
|
function getFocusRingClass(color: ButtonProps["color"]): string {
|
|
switch (color) {
|
|
case "primary":
|
|
return "focus:ring-2 focus:ring-primary-400";
|
|
case "blue":
|
|
return "focus:ring-2 focus:ring-blue-400";
|
|
case "red":
|
|
return "focus:ring-2 focus:ring-red-400";
|
|
case "green":
|
|
return "focus:ring-2 focus:ring-green-400";
|
|
case "yellow":
|
|
return "focus:ring-2 focus:ring-yellow-400";
|
|
case "purple":
|
|
return "focus:ring-2 focus:ring-purple-400";
|
|
case "gray":
|
|
return "focus:ring-2 focus:ring-gray-400";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function getRangeBackgroundClass(color: ButtonProps["color"]): string {
|
|
switch (color) {
|
|
case "primary":
|
|
return "bg-primary-900";
|
|
case "blue":
|
|
return "bg-blue-900";
|
|
case "red":
|
|
return "bg-red-900";
|
|
case "green":
|
|
return "bg-green-900";
|
|
case "yellow":
|
|
return "bg-yellow-900";
|
|
case "purple":
|
|
return "bg-purple-900";
|
|
case "gray":
|
|
return "bg-gray-900";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function getDaysInMonth(date: Date): Date[] {
|
|
const year = date.getFullYear();
|
|
const month = date.getMonth();
|
|
const firstDay = new Date(year, month, 0);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const daysArray: Date[] = [];
|
|
|
|
// Add days from previous month to fill the first week
|
|
let start = firstDay.getDay() - firstDayOfWeek;
|
|
if (start < 0) start += 7;
|
|
for (let i = 0; i < start; i++) {
|
|
daysArray.unshift(new Date(year, month, -i));
|
|
}
|
|
|
|
// Add days of the current month
|
|
for (let i = 1; i <= lastDay.getDate(); i++) {
|
|
daysArray.push(new Date(year, month, i));
|
|
}
|
|
|
|
// Add days from next month to fill the last week
|
|
const remainingDays = 7 - (daysArray.length % 7);
|
|
if (remainingDays < 7) {
|
|
for (let i = 1; i <= remainingDays; i++) {
|
|
daysArray.push(new Date(year, month + 1, i));
|
|
}
|
|
}
|
|
|
|
return daysArray;
|
|
}
|
|
|
|
function getWeekdays(): string[] {
|
|
const weekdays = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
const day = new Date(2021, 5, i + firstDayOfWeek);
|
|
weekdays.push(day.toLocaleString(locale, { weekday: "short" }));
|
|
}
|
|
return weekdays;
|
|
}
|
|
|
|
function changeMonth(increment: number) {
|
|
currentMonth = new Date(
|
|
currentMonth.getFullYear(),
|
|
currentMonth.getMonth() + increment,
|
|
1,
|
|
);
|
|
}
|
|
|
|
function handleDaySelect(day: Date) {
|
|
if (range) {
|
|
if (!rangeFrom || (rangeFrom && rangeTo)) {
|
|
rangeFrom = day;
|
|
rangeTo = null;
|
|
} else if (day < rangeFrom) {
|
|
rangeTo = rangeFrom;
|
|
rangeFrom = day;
|
|
} else {
|
|
rangeTo = day;
|
|
}
|
|
dispatch("select", { from: rangeFrom, to: rangeTo });
|
|
} else {
|
|
value = day;
|
|
dispatch("select", value);
|
|
if (autohide && !inline) isOpen = false;
|
|
}
|
|
}
|
|
|
|
function handleInputChange() {
|
|
const date = new Date(inputElement.value);
|
|
if (!isNaN(date.getTime())) {
|
|
handleDaySelect(date);
|
|
}
|
|
}
|
|
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (
|
|
isOpen &&
|
|
datepickerContainerElement &&
|
|
!datepickerContainerElement.contains(event.target as Node)
|
|
) {
|
|
isOpen = false;
|
|
}
|
|
}
|
|
|
|
function formatDate(date: Date | null): string {
|
|
if (!date) return "";
|
|
return date.toLocaleDateString(locale, dateFormat);
|
|
}
|
|
|
|
function isSameDate(date1: Date | null, date2: Date | null): boolean {
|
|
if (!date1 || !date2) return false;
|
|
return date1.toDateString() === date2.toDateString();
|
|
}
|
|
|
|
$: isSelected = (day: Date): boolean => {
|
|
if (range) {
|
|
return isSameDate(day, rangeFrom) || isSameDate(day, rangeTo);
|
|
}
|
|
return isSameDate(day, value);
|
|
};
|
|
|
|
function isInRange(day: Date): boolean {
|
|
if (!range || !rangeFrom || !rangeTo) return false;
|
|
return day > rangeFrom && day < rangeTo;
|
|
}
|
|
|
|
function isToday(day: Date): boolean {
|
|
const today = new Date();
|
|
return day.toDateString() === today.toDateString();
|
|
}
|
|
|
|
function handleCalendarKeydown(event: KeyboardEvent) {
|
|
if (!isOpen) return;
|
|
|
|
if (!focusedDate) {
|
|
focusedDate = value || new Date();
|
|
}
|
|
|
|
switch (event.key) {
|
|
case "ArrowLeft":
|
|
focusedDate = new Date(
|
|
focusedDate.getFullYear(),
|
|
focusedDate.getMonth(),
|
|
focusedDate.getDate() - 1,
|
|
);
|
|
break;
|
|
case "ArrowRight":
|
|
focusedDate = new Date(
|
|
focusedDate.getFullYear(),
|
|
focusedDate.getMonth(),
|
|
focusedDate.getDate() + 1,
|
|
);
|
|
break;
|
|
case "ArrowUp":
|
|
focusedDate = new Date(
|
|
focusedDate.getFullYear(),
|
|
focusedDate.getMonth(),
|
|
focusedDate.getDate() - 7,
|
|
);
|
|
break;
|
|
case "ArrowDown":
|
|
focusedDate = new Date(
|
|
focusedDate.getFullYear(),
|
|
focusedDate.getMonth(),
|
|
focusedDate.getDate() + 7,
|
|
);
|
|
break;
|
|
case "Enter":
|
|
handleDaySelect(focusedDate);
|
|
break;
|
|
case "Escape":
|
|
isOpen = false;
|
|
inputElement.focus();
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
if (focusedDate.getMonth() !== currentMonth.getMonth()) {
|
|
currentMonth = new Date(
|
|
focusedDate.getFullYear(),
|
|
focusedDate.getMonth(),
|
|
1,
|
|
);
|
|
}
|
|
|
|
// Focus the button for the focused date
|
|
setTimeout(() => {
|
|
const focusedButton = calendarRef.querySelector(
|
|
`button[aria-label="${focusedDate!.toLocaleDateString(locale, { weekday: "long", year: "numeric", month: "long", day: "numeric" })}"]`,
|
|
) as HTMLButtonElement | null;
|
|
focusedButton?.focus();
|
|
}, 0);
|
|
}
|
|
|
|
function handleInputKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
isOpen = !isOpen;
|
|
}
|
|
}
|
|
|
|
function handleToday() {
|
|
handleDaySelect(new Date());
|
|
}
|
|
|
|
function handleClear() {
|
|
value = null;
|
|
rangeFrom = null;
|
|
rangeTo = null;
|
|
dispatch("clear");
|
|
}
|
|
|
|
function handleApply() {
|
|
dispatch("apply", range ? { from: rangeFrom, to: rangeTo } : value);
|
|
if (!inline) isOpen = false;
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
bind:this={datepickerContainerElement}
|
|
class="relative {inline ? 'inline-block' : ''}"
|
|
>
|
|
{#if !inline}
|
|
<div class="relative">
|
|
<input
|
|
bind:this={inputElement}
|
|
type="text"
|
|
class="w-full px-4 py-3 text-sm border rounded-md focus:outline-none bg-gray-700 text-white border-gray-600 {getFocusRingClass(
|
|
color,
|
|
)} {inputClass}"
|
|
{placeholder}
|
|
value={range
|
|
? `${formatDate(rangeFrom)} - ${formatDate(rangeTo)}`
|
|
: formatDate(value)}
|
|
on:focus={() => (isOpen = true)}
|
|
on:input={handleInputChange}
|
|
on:keydown={handleInputKeydown}
|
|
{disabled}
|
|
{required}
|
|
aria-haspopup="dialog"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 focus:outline-none"
|
|
on:click={() => (isOpen = !isOpen)}
|
|
{disabled}
|
|
aria-label={isOpen ? "Close date picker" : "Open date picker"}
|
|
>
|
|
<svg
|
|
class="w-4 h-4 text-gray-400"
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
d="M20 4a2 2 0 0 0-2-2h-2V1a1 1 0 0 0-2 0v1h-3V1a1 1 0 0 0-2 0v1H6V1a1 1 0 0 0-2 0v1H2a2 2 0 0 0-2 2v2h20V4ZM0 18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8H0v10Zm5-8h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z"
|
|
></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if isOpen || inline}
|
|
<div
|
|
bind:this={calendarRef}
|
|
id="datepicker-dropdown"
|
|
class="
|
|
{inline ? '' : 'absolute z-10 mt-1'}
|
|
bg-gray-800 rounded-md shadow-lg"
|
|
transition:fade={{ duration: 100 }}
|
|
role="dialog"
|
|
aria-label="Calendar"
|
|
>
|
|
<div class="p-4" role="application">
|
|
{#if title}
|
|
<h2 class="text-lg font-semibold mb-4 text-white">
|
|
{title}
|
|
</h2>
|
|
{/if}
|
|
<div class="flex items-center justify-between mb-4">
|
|
<Button
|
|
onclick={() => changeMonth(-1)}
|
|
{color}
|
|
size="sm"
|
|
aria-label="Previous month"
|
|
>
|
|
<svg
|
|
class="w-3 h-3 rtl:rotate-180 text-white"
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 14 10"
|
|
><path
|
|
stroke="currentColor"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 5H1m0 0 4 4M1 5l4-4"
|
|
></path></svg
|
|
>
|
|
</Button>
|
|
<h3
|
|
class="text-lg font-semibold text-white"
|
|
aria-live="polite"
|
|
>
|
|
{currentMonth.toLocaleString(locale, {
|
|
month: "long",
|
|
year: "numeric",
|
|
})}
|
|
</h3>
|
|
<Button
|
|
onclick={() => changeMonth(1)}
|
|
{color}
|
|
size="sm"
|
|
aria-label="Next month"
|
|
>
|
|
<svg
|
|
class="w-3 h-3 rtl:rotate-180 text-white"
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 14 10"
|
|
><path
|
|
stroke="currentColor"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M1 5h12m0 0L9 1m4 4L9 9"
|
|
></path></svg
|
|
>
|
|
</Button>
|
|
</div>
|
|
<div class="grid grid-cols-7 gap-1" role="grid">
|
|
{#each weekdays as day}
|
|
<div
|
|
class="text-center text-sm font-medium text-gray-400"
|
|
role="columnheader"
|
|
>
|
|
{day}
|
|
</div>
|
|
{/each}
|
|
{#each daysInMonth as day}
|
|
<Button
|
|
color={isSelected(day) ? color : "alternative"}
|
|
size="sm"
|
|
class="w-full h-8 {day.getMonth() !==
|
|
currentMonth.getMonth()
|
|
? 'text-gray-600'
|
|
: ''} {isToday(day)
|
|
? 'font-bold'
|
|
: ''} {isInRange(day)
|
|
? getRangeBackgroundClass(color)
|
|
: ''}"
|
|
onclick={() => handleDaySelect(day)}
|
|
onkeydown={handleCalendarKeydown}
|
|
aria-label={day.toLocaleDateString(locale, {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
})}
|
|
aria-selected={isSelected(day)}
|
|
role="gridcell"
|
|
>
|
|
{day.getDate()}
|
|
</Button>
|
|
{/each}
|
|
</div>
|
|
{#if showActionButtons}
|
|
<div class="mt-4 flex justify-between">
|
|
<Button onclick={handleToday} {color} size="sm"
|
|
>Today</Button
|
|
>
|
|
<Button onclick={handleClear} color="red" size="sm"
|
|
>Clear</Button
|
|
>
|
|
<Button onclick={handleApply} {color} size="sm"
|
|
>Apply</Button
|
|
>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!--
|
|
@component
|
|
[Go to docs](https://flowbite-svelte.com/)
|
|
## Props
|
|
@prop export let value: Date | null = null;
|
|
@prop export let defaultDate: Date | null = null;
|
|
@prop export let range: boolean = false;
|
|
@prop export let rangeFrom: Date | null = null;
|
|
@prop export let rangeTo: Date | null = null;
|
|
@prop export let locale: string = 'default';
|
|
@prop export let firstDayOfWeek: number = 0;
|
|
@prop export let dateFormat: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' };
|
|
@prop export let placeholder: string = 'Select date';
|
|
@prop export let disabled: boolean = false;
|
|
@prop export let required: boolean = false;
|
|
@prop export let inputClass: string = '';
|
|
@prop export let color: Button['color'] = 'primary';
|
|
@prop export let inline: boolean = false;
|
|
@prop export let autohide: boolean = true;
|
|
@prop export let showActionButtons: boolean = false;
|
|
@prop export let title: string = '';
|
|
-->
|