📅 Add calendar functionality
This commit is contained in:
parent
6434102635
commit
a5348a3c62
13 changed files with 1328 additions and 142 deletions
70
src/components/Calendar/CalendarEvent.ts
Normal file
70
src/components/Calendar/CalendarEvent.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
export interface CalendarEventData {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
description: string;
|
||||
location?: string;
|
||||
participants?: string[];
|
||||
tags?: string[];
|
||||
type: 'date' | 'time';
|
||||
}
|
||||
|
||||
@customElement('arx-calendar-event')
|
||||
export class CalendarEvent extends LitElement {
|
||||
@property({ type: Object })
|
||||
event!: CalendarEventData;
|
||||
|
||||
@property({ type: String })
|
||||
locale = 'en-US';
|
||||
|
||||
static override styles = css`
|
||||
.calendar-event {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
padding: 0.25rem;
|
||||
margin: 0.25rem 0;
|
||||
border-radius: var(--radius-selector);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
private handleClick() {
|
||||
this.dispatchEvent(new CustomEvent('event-click', { detail: { event: this.event } }));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="calendar-event" @click=${this.handleClick}>
|
||||
${this.event.title}
|
||||
${when(
|
||||
this.event.type === 'time',
|
||||
() => html`
|
||||
<div class="event-time">
|
||||
${this.event.start.toLocaleTimeString(this.locale, { hour: '2-digit', minute: '2-digit' })}
|
||||
${when(
|
||||
this.event.end,
|
||||
() =>
|
||||
html` - ${this.event.end!.toLocaleTimeString(this.locale, { hour: '2-digit', minute: '2-digit' })}`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
88
src/components/Calendar/CalendarEventDetailsDialog.ts
Normal file
88
src/components/Calendar/CalendarEventDetailsDialog.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import formatDateTime from '@/utils/formatDateTime';
|
||||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
import type { CalendarEventData } from './CalendarEvent';
|
||||
|
||||
import '@components/General/Dialog';
|
||||
import '@components/General/Fieldset';
|
||||
|
||||
@customElement('arx-calendar-event-details-dialog')
|
||||
export class CalendarEventDetailsDialog extends LitElement {
|
||||
@property({ type: Object })
|
||||
event: CalendarEventData | null = null;
|
||||
|
||||
@property({ type: Boolean })
|
||||
open = false;
|
||||
|
||||
@property({ type: String })
|
||||
locale = 'en-US';
|
||||
|
||||
static override styles = css`
|
||||
.event-detail {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-detail-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-base-content);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.event-detail-value {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
`;
|
||||
|
||||
private handleClose() {
|
||||
this.dispatchEvent(new CustomEvent('close'));
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.open || !this.event) return html``;
|
||||
|
||||
const { end, location, description } = this.event;
|
||||
|
||||
return html`
|
||||
<arx-dialog
|
||||
.open=${this.open}
|
||||
@close=${this.handleClose}
|
||||
>
|
||||
<arx-fieldset .legend=${this.event.title}>
|
||||
<div class="event-detail">
|
||||
<span class="event-detail-label">Start:</span>
|
||||
<span class="event-detail-value">${formatDateTime(this.event.start)}</span>
|
||||
</div>
|
||||
${when(
|
||||
end instanceof Date,
|
||||
() => html`
|
||||
<div class="event-detail">
|
||||
<span class="event-detail-label">End:</span>
|
||||
<span class="event-detail-value">${formatDateTime(end!)}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
${when(
|
||||
location,
|
||||
() => html`
|
||||
<div class="event-detail">
|
||||
<span class="event-detail-label">Location:</span>
|
||||
<span class="event-detail-value">${location}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
${when(
|
||||
description,
|
||||
() => html`
|
||||
<div class="event-detail">
|
||||
<span class="event-detail-label">Description:</span>
|
||||
<span class="event-detail-value">${description}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</arx-fieldset>
|
||||
</arx-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
169
src/components/Calendar/CalendarEventDialog.ts
Normal file
169
src/components/Calendar/CalendarEventDialog.ts
Normal file
|
@ -0,0 +1,169 @@
|
|||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
import '@components/General/Button';
|
||||
import { StyledInput } from '@components/General/Input';
|
||||
import { StyledTextarea } from '@components/General/Textarea';
|
||||
import { StyledToggle } from '@components/General/Toggle';
|
||||
|
||||
interface InputEvent extends Event {
|
||||
detail: {
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
@customElement('arx-calendar-event-dialog')
|
||||
export class CalendarEventDialog extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
open = false;
|
||||
|
||||
@state()
|
||||
private newEvent = {
|
||||
title: '',
|
||||
description: '',
|
||||
location: '',
|
||||
startDate: '',
|
||||
startTime: '',
|
||||
endDate: '',
|
||||
endTime: '',
|
||||
allDay: false,
|
||||
participants: [] as string[],
|
||||
tags: [] as string[],
|
||||
};
|
||||
|
||||
static override styles = css`
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: var(--radius-selector);
|
||||
background: var(--color-base-200);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
private handleClose() {
|
||||
this.dispatchEvent(new CustomEvent('close'));
|
||||
}
|
||||
|
||||
private handleInputChange(e: Event) {
|
||||
if (e.target instanceof StyledToggle) {
|
||||
const checked = e.target.checked;
|
||||
this.newEvent = {
|
||||
...this.newEvent,
|
||||
[e.target.name]: checked,
|
||||
};
|
||||
} else if (e.target instanceof StyledTextarea || e.target instanceof StyledInput) {
|
||||
const inputEvent = e as InputEvent;
|
||||
this.newEvent = {
|
||||
...this.newEvent,
|
||||
[e.target.name]: inputEvent.detail.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('submit', { detail: { event: this.newEvent } }));
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.open) return html``;
|
||||
|
||||
return html`
|
||||
<arx-dialog width="500px" title="Add Event" .open=${this.open}>
|
||||
<arx-fieldset legend="Title">
|
||||
<arx-input
|
||||
type="text"
|
||||
name="title"
|
||||
.value=${this.newEvent.title}
|
||||
@change=${this.handleInputChange}
|
||||
required
|
||||
></arx-input>
|
||||
</arx-fieldset>
|
||||
<arx-fieldset legend="Description">
|
||||
<arx-textarea
|
||||
name="description"
|
||||
.value=${this.newEvent.description}
|
||||
@change=${this.handleInputChange}
|
||||
></arx-textarea>
|
||||
</arx-fieldset>
|
||||
<arx-fieldset legend="Location">
|
||||
<arx-input
|
||||
type="text"
|
||||
name="location"
|
||||
.value=${this.newEvent.location}
|
||||
@change=${this.handleInputChange}
|
||||
></arx-input>
|
||||
</arx-fieldset>
|
||||
<arx-fieldset>
|
||||
<arx-toggle
|
||||
label="All-day event"
|
||||
name="allDay"
|
||||
.checked=${this.newEvent.allDay}
|
||||
@change=${this.handleInputChange}
|
||||
></arx-toggle>
|
||||
</arx-fieldset>
|
||||
<div class="form-row">
|
||||
<arx-fieldset legend="Start Date">
|
||||
<arx-input
|
||||
type="date"
|
||||
name="startDate"
|
||||
.value=${this.newEvent.startDate}
|
||||
@change=${this.handleInputChange}
|
||||
required
|
||||
></arx-input>
|
||||
</arx-fieldset>
|
||||
${when(
|
||||
!this.newEvent.allDay,
|
||||
() => html`
|
||||
<arx-fieldset legend="Start Time">
|
||||
<arx-input
|
||||
type="time"
|
||||
name="startTime"
|
||||
.value=${this.newEvent.startTime}
|
||||
@change=${this.handleInputChange}
|
||||
required
|
||||
></arx-input>
|
||||
</arx-fieldset>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<arx-fieldset legend="End Date">
|
||||
<arx-input
|
||||
type="date"
|
||||
name="endDate"
|
||||
.value=${this.newEvent.endDate}
|
||||
@change=${this.handleInputChange}
|
||||
></arx-input>
|
||||
</arx-fieldset>
|
||||
${when(
|
||||
!this.newEvent.allDay,
|
||||
() => html`
|
||||
<arx-fieldset legend="End Time">
|
||||
<arx-input
|
||||
type="time"
|
||||
name="endTime"
|
||||
.value=${this.newEvent.endTime}
|
||||
@change=${this.handleInputChange}
|
||||
?required=${this.newEvent.endDate !== ''}
|
||||
></arx-input>
|
||||
</arx-fieldset>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<arx-button variant="secondary" @click=${this.handleClose} label="Cancel"></arx-button>
|
||||
<arx-button variant="primary" @click=${this.handleSubmit} type="submit" label="Create Event"></arx-button>
|
||||
</div>
|
||||
</arx-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
126
src/components/Calendar/CalendarHeader.ts
Normal file
126
src/components/Calendar/CalendarHeader.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
type CalendarView = 'month' | 'week';
|
||||
|
||||
@customElement('arx-calendar-header')
|
||||
export class CalendarHeader extends LitElement {
|
||||
@property({ type: String })
|
||||
view: CalendarView = 'month';
|
||||
|
||||
@property({ type: Date })
|
||||
currentDate = new Date();
|
||||
|
||||
@property({ type: Boolean })
|
||||
isToday = false;
|
||||
|
||||
@property({ type: String })
|
||||
dateRange = '';
|
||||
|
||||
static override styles = css`
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.calendar-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.calendar-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-base-content);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.calendar-controls {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-view-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
private handlePrev() {
|
||||
this.dispatchEvent(new CustomEvent('prev'));
|
||||
}
|
||||
|
||||
private handleNext() {
|
||||
this.dispatchEvent(new CustomEvent('next'));
|
||||
}
|
||||
|
||||
private handleToday() {
|
||||
this.dispatchEvent(new CustomEvent('today'));
|
||||
}
|
||||
|
||||
private handleAddEvent() {
|
||||
this.dispatchEvent(new CustomEvent('add-event'));
|
||||
}
|
||||
|
||||
private handleViewChange(e: CustomEvent) {
|
||||
this.dispatchEvent(new CustomEvent('view-change', { detail: { view: e.detail.checked ? 'week' : 'month' } }));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="calendar-header">
|
||||
<div class="calendar-title-row">
|
||||
<arx-button
|
||||
variant="secondary"
|
||||
@click=${this.handlePrev}
|
||||
label="Previous"
|
||||
></arx-button>
|
||||
<h2 class="calendar-title">${this.dateRange}</h2>
|
||||
<arx-button
|
||||
variant="secondary"
|
||||
@click=${this.handleNext}
|
||||
label="Next"
|
||||
></arx-button>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<div class="calendar-view-toggle">
|
||||
${when(
|
||||
!this.isToday,
|
||||
() => html`
|
||||
<arx-button
|
||||
variant="primary"
|
||||
@click=${this.handleToday}
|
||||
label="Today"
|
||||
></arx-button>
|
||||
`,
|
||||
)}
|
||||
<arx-button
|
||||
variant="primary"
|
||||
@click=${this.handleAddEvent}
|
||||
label="Add Event"
|
||||
>
|
||||
<iconify-icon icon="mdi:calendar-plus" slot="prefix"></iconify-icon>
|
||||
</arx-button>
|
||||
<arx-toggle
|
||||
label="Week View"
|
||||
.checked=${this.view === 'week'}
|
||||
@change=${this.handleViewChange}
|
||||
></arx-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
212
src/components/Calendar/CalendarMonthView.ts
Normal file
212
src/components/Calendar/CalendarMonthView.ts
Normal file
|
@ -0,0 +1,212 @@
|
|||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { CalendarEventData } from './CalendarEvent';
|
||||
|
||||
@customElement('arx-calendar-month-view')
|
||||
export class CalendarMonthView extends LitElement {
|
||||
@property({ type: Date })
|
||||
currentDate = new Date();
|
||||
|
||||
@property({ type: Date })
|
||||
selectedDate: Date | null = null;
|
||||
|
||||
@property({ type: Array })
|
||||
events: CalendarEventData[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
locale = 'en-US';
|
||||
|
||||
static override styles = css`
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.5rem;
|
||||
background: var(--color-base-100);
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-selector);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: var(--radius-selector);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--color-base-200);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
text-align: center;
|
||||
padding: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background: var(--color-base-300);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.calendar-day.selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
border: 2px solid var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-day.other-month {
|
||||
opacity: 0.5;
|
||||
background: var(--color-base-100);
|
||||
}
|
||||
|
||||
.calendar-day-events {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 100px;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
private getDaysInMonth(date: Date): number {
|
||||
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
}
|
||||
|
||||
private getFirstDayOfMonth(date: Date): number {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
}
|
||||
|
||||
private getCalendarDays(): Date[] {
|
||||
const days: Date[] = [];
|
||||
const firstDay = this.getFirstDayOfMonth(this.currentDate);
|
||||
const daysInMonth = this.getDaysInMonth(this.currentDate);
|
||||
const daysInPrevMonth = this.getDaysInMonth(
|
||||
new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1),
|
||||
);
|
||||
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
days.push(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, daysInPrevMonth - i));
|
||||
}
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), i));
|
||||
}
|
||||
|
||||
const remainingDays = 42 - days.length; // 6 rows * 7 days
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
days.push(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, i));
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
private isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
private isSelected(date: Date): boolean {
|
||||
if (!this.selectedDate) return false;
|
||||
return (
|
||||
date.getDate() === this.selectedDate.getDate() &&
|
||||
date.getMonth() === this.selectedDate.getMonth() &&
|
||||
date.getFullYear() === this.selectedDate.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
private isOtherMonth(date: Date): boolean {
|
||||
return date.getMonth() !== this.currentDate.getMonth();
|
||||
}
|
||||
|
||||
private getEventsForDate(date: Date): CalendarEventData[] {
|
||||
return this.events.filter((event) => {
|
||||
const eventStart = new Date(event.start);
|
||||
if (
|
||||
eventStart.getDate() === date.getDate() &&
|
||||
eventStart.getMonth() === date.getMonth() &&
|
||||
eventStart.getFullYear() === date.getFullYear()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.end) return false;
|
||||
|
||||
const eventEnd = new Date(event.end);
|
||||
|
||||
return (
|
||||
eventEnd.getDate() === date.getDate() &&
|
||||
eventEnd.getMonth() === date.getMonth() &&
|
||||
eventEnd.getFullYear() === date.getFullYear()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private handleDateClick(date: Date) {
|
||||
this.dispatchEvent(new CustomEvent('date-click', { detail: { date } }));
|
||||
}
|
||||
|
||||
private handleEventClick(event: CalendarEventData) {
|
||||
this.dispatchEvent(new CustomEvent('event-click', { detail: { event } }));
|
||||
}
|
||||
|
||||
override render() {
|
||||
const firstDay = new Date(2024, 0, 1);
|
||||
const dayNames = Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date(firstDay);
|
||||
date.setDate(firstDay.getDate() + i);
|
||||
return date.toLocaleDateString(this.locale, { weekday: 'short' });
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="calendar-grid">
|
||||
${dayNames.map((day) => html`<div class="calendar-day-header">${day}</div>`)}
|
||||
${this.getCalendarDays().map(
|
||||
(date) => html`
|
||||
<div
|
||||
class="calendar-day ${this.isToday(date) ? 'today' : ''} ${
|
||||
this.isSelected(date) ? 'selected' : ''
|
||||
} ${this.isOtherMonth(date) ? 'other-month' : ''}"
|
||||
@click=${() => this.handleDateClick(date)}
|
||||
>
|
||||
<div class="calendar-day-number">${date.getDate()}</div>
|
||||
<div class="calendar-day-events">
|
||||
${this.getEventsForDate(date).map(
|
||||
(event) => html`
|
||||
<arx-calendar-event
|
||||
.event=${event}
|
||||
.locale=${this.locale}
|
||||
@event-click=${(e: CustomEvent) => this.handleEventClick(e.detail.event)}
|
||||
></arx-calendar-event>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
114
src/components/Calendar/CalendarWeekView.ts
Normal file
114
src/components/Calendar/CalendarWeekView.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { CalendarEventData } from './CalendarEvent';
|
||||
|
||||
@customElement('arx-calendar-week-view')
|
||||
export class CalendarWeekView extends LitElement {
|
||||
@property({ type: Date })
|
||||
currentDate = new Date();
|
||||
|
||||
@property({ type: Array })
|
||||
events: CalendarEventData[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
locale = 'en-US';
|
||||
|
||||
static override styles = css`
|
||||
.week-view {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.week-day {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-base-100);
|
||||
border-radius: var(--radius-selector);
|
||||
}
|
||||
|
||||
.week-day-header {
|
||||
font-weight: 600;
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.week-day-content {
|
||||
min-height: 100px;
|
||||
border-left: 1px solid var(--color-base-300);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
private getWeekDays(): Date[] {
|
||||
const days: Date[] = [];
|
||||
const currentDay = this.currentDate.getDay();
|
||||
const startOfWeek = new Date(this.currentDate);
|
||||
startOfWeek.setDate(this.currentDate.getDate() - currentDay);
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(startOfWeek);
|
||||
date.setDate(startOfWeek.getDate() + i);
|
||||
days.push(date);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
private getEventsForDate(date: Date): CalendarEventData[] {
|
||||
return this.events.filter((event) => {
|
||||
const eventStart = new Date(event.start);
|
||||
if (
|
||||
eventStart.getDate() === date.getDate() &&
|
||||
eventStart.getMonth() === date.getMonth() &&
|
||||
eventStart.getFullYear() === date.getFullYear()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.end) return false;
|
||||
|
||||
const eventEnd = new Date(event.end);
|
||||
|
||||
return (
|
||||
eventEnd.getDate() === date.getDate() &&
|
||||
eventEnd.getMonth() === date.getMonth() &&
|
||||
eventEnd.getFullYear() === date.getFullYear()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private handleEventClick(event: CalendarEventData) {
|
||||
this.dispatchEvent(new CustomEvent('event-click', { detail: { event } }));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="week-view">
|
||||
${this.getWeekDays().map(
|
||||
(date) => html`
|
||||
<div class="week-day">
|
||||
<div class="week-day-header">
|
||||
${date.toLocaleDateString(this.locale, { weekday: 'long' })}
|
||||
<br />
|
||||
${date.toLocaleDateString(this.locale, { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
<div class="week-day-content">
|
||||
${this.getEventsForDate(date).map(
|
||||
(event) => html`
|
||||
<arx-calendar-event
|
||||
.event=${event}
|
||||
.locale=${this.locale}
|
||||
@event-click=${(e: CustomEvent) => this.handleEventClick(e.detail.event)}
|
||||
></arx-calendar-event>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -187,6 +187,7 @@ export class StyledButton extends LitElement {
|
|||
|
||||
private _handleClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.disabled || this.loading) {
|
||||
return;
|
||||
}
|
||||
|
@ -207,8 +208,8 @@ export class StyledButton extends LitElement {
|
|||
name: this.name,
|
||||
value: this.value,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
bubbles: false,
|
||||
composed: false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
|
165
src/components/General/Dialog.ts
Normal file
165
src/components/General/Dialog.ts
Normal file
|
@ -0,0 +1,165 @@
|
|||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
@customElement('arx-dialog')
|
||||
export class EveDialog extends LitElement {
|
||||
@property({ type: String }) override title = '';
|
||||
@property({ type: Boolean }) open = false;
|
||||
@property({ type: Boolean }) closeOnOverlayClick = true;
|
||||
@property({ type: Boolean }) closeOnEscape = true;
|
||||
@property({ type: String }) width = '420px';
|
||||
@property({ type: String }) maxWidth = '90%';
|
||||
|
||||
@state() private _previousFocus: HTMLElement | null = null;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: oklch(from var(--color-base-content) l c h / 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.overlay.active {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.dialog-container {
|
||||
background-color: var(--color-base-100);
|
||||
border-radius: var(--radius-box);
|
||||
border: var(--border) solid var(--color-base-300);
|
||||
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
|
||||
calc(var(--depth) * 8px)
|
||||
oklch(from var(--color-base-content) l c h / 0.2),
|
||||
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
|
||||
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4);
|
||||
width: var(--dialog-width, 420px);
|
||||
max-width: var(--dialog-max-width, 90%);
|
||||
padding: 28px;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
transition: transform 0.25s cubic-bezier(0.1, 1, 0.2, 1);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.overlay.active .dialog-container {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.4;
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-container:focus-within {
|
||||
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
|
||||
calc(var(--depth) * 8px)
|
||||
oklch(from var(--color-base-content) l c h / 0.2),
|
||||
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
|
||||
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4),
|
||||
0 0 0 2px oklch(from var(--color-accent) l c h / 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._handleKeyDown = this._handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
override updated(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has('open') && this.open) {
|
||||
this._previousFocus = document.activeElement as HTMLElement;
|
||||
// Focus the dialog container after rendering
|
||||
setTimeout(() => {
|
||||
const container = this.shadowRoot?.querySelector('.dialog-container') as HTMLElement;
|
||||
if (container) container.focus();
|
||||
}, 50);
|
||||
} else if (changedProps.has('open') && !this.open && this._previousFocus) {
|
||||
this._previousFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleKeyDown(e: KeyboardEvent) {
|
||||
if (!this.open || !this.closeOnEscape) return;
|
||||
if (e.key === 'Escape') this.close();
|
||||
}
|
||||
|
||||
private _handleOverlayClick(e: MouseEvent) {
|
||||
if (this.closeOnOverlayClick && e.target === e.currentTarget) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.open = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new CustomEvent('close'));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div
|
||||
class="${classMap({ overlay: true, active: this.open })}"
|
||||
@click=${this._handleOverlayClick}
|
||||
style="--dialog-width: ${this.width}; --dialog-max-width: ${this.maxWidth};"
|
||||
>
|
||||
<div class="dialog-container" tabindex="-1">
|
||||
${this.title ? html`<div class="dialog-header">${this.title}</div>` : ''}
|
||||
<div class="dialog-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -13,15 +13,22 @@ export class StyledInput extends LitElement {
|
|||
@property() placeholder = '';
|
||||
@property() value = '';
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
@property() type: 'text' | 'number' | 'password' = 'text';
|
||||
@property() type: 'text' | 'number' | 'password' | 'date' | 'time' = 'text';
|
||||
@property() name = '';
|
||||
@property({ type: Boolean }) required = false;
|
||||
@property() label = '';
|
||||
@state() override lang = 'en-US';
|
||||
|
||||
@query('input') private _input!: HTMLInputElement;
|
||||
|
||||
@state() private _value = '';
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
const dateTimeFormatOptions = JSON.parse(localStorage.getItem('dateTimeFormatOptions') || '{}');
|
||||
if (dateTimeFormatOptions?.locale) this.lang = dateTimeFormatOptions.locale;
|
||||
}
|
||||
|
||||
protected override firstUpdated(_changedProperties: PropertyValues): void {
|
||||
this._value = this.value;
|
||||
}
|
||||
|
@ -122,6 +129,7 @@ export class StyledInput extends LitElement {
|
|||
placeholder=${this.placeholder}
|
||||
type=${this.type}
|
||||
name=${this.name}
|
||||
lang=${this.lang}
|
||||
@input=${this._handleInput}
|
||||
@focus=${this._handleFocus}
|
||||
@blur=${this._handleBlur}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import type { ArxInputChangeEvent } from '@components/General/Input';
|
||||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
import '@components/General/Dialog';
|
||||
import '@components/General/Input';
|
||||
|
||||
@customElement('arx-prompt')
|
||||
|
@ -16,122 +17,19 @@ export class EvePrompt extends LitElement {
|
|||
@property({ type: String }) defaultValue = '';
|
||||
|
||||
@state() private _inputValue = '';
|
||||
private _previousFocus: HTMLElement | null = null;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: oklch(from var(--color-base-content) l c h / 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.overlay.active {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.prompt-container {
|
||||
background-color: var(--color-base-100);
|
||||
border-radius: var(--radius-box);
|
||||
border: var(--border) solid var(--color-base-300);
|
||||
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
|
||||
calc(var(--depth) * 8px)
|
||||
oklch(from var(--color-base-content) l c h / 0.2),
|
||||
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
|
||||
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4);
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
padding: 28px;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
transition: transform 0.25s cubic-bezier(0.1, 1, 0.2, 1);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.overlay.active .prompt-container {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.prompt-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.4;
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.prompt-container:focus-within {
|
||||
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
|
||||
calc(var(--depth) * 8px)
|
||||
oklch(from var(--color-base-content) l c h / 0.2),
|
||||
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
|
||||
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4),
|
||||
0 0 0 2px oklch(from var(--color-accent) l c h / 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._handleKeyDown = this._handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
override updated(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has('open') && this.open) {
|
||||
this._inputValue = this.defaultValue;
|
||||
this._previousFocus = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus the input or save button after rendering
|
||||
setTimeout(() => {
|
||||
if (this.showInput) {
|
||||
const input = this.shadowRoot?.querySelector('.input-field') as HTMLElement;
|
||||
if (input) input.focus();
|
||||
} else {
|
||||
const saveBtn = this.shadowRoot?.querySelector('.save-btn') as HTMLElement;
|
||||
if (saveBtn) saveBtn.focus();
|
||||
}
|
||||
}, 50);
|
||||
} else if (changedProps.has('open') && !this.open && this._previousFocus) {
|
||||
this._previousFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleKeyDown(e: KeyboardEvent) {
|
||||
if (!this.open) return;
|
||||
if (e.key === 'Escape') this._handleCancel();
|
||||
if (e.key === 'Enter' && !e.shiftKey) this._handleSave();
|
||||
}
|
||||
|
||||
private _handleInputChange(e: ArxInputChangeEvent) {
|
||||
this._inputValue = e.detail.value;
|
||||
}
|
||||
|
@ -152,43 +50,37 @@ export class EvePrompt extends LitElement {
|
|||
|
||||
override render() {
|
||||
return html`
|
||||
<div
|
||||
class="${classMap({ overlay: true, active: this.open })}"
|
||||
@click="${(e: MouseEvent) => e.target === e.currentTarget && this._handleCancel()}"
|
||||
<arx-dialog
|
||||
.title=${this.promptText}
|
||||
.open=${this.open}
|
||||
.closeOnOverlayClick=${false}
|
||||
.closeOnEscape=${false}
|
||||
>
|
||||
<div class="prompt-container">
|
||||
<div class="prompt-header">${this.promptText}</div>
|
||||
|
||||
${
|
||||
this.showInput
|
||||
? html`
|
||||
<arx-input
|
||||
type="text"
|
||||
class="input-field"
|
||||
.value=${this._inputValue}
|
||||
@change=${this._handleInputChange}
|
||||
placeholder=${this.placeholder}
|
||||
></arx-input>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<div class="buttons">
|
||||
<arx-button
|
||||
variant="accent"
|
||||
label=${this.cancelText}
|
||||
@click=${this._handleCancel}
|
||||
>
|
||||
</arx-button>
|
||||
<arx-button
|
||||
variant="secondary"
|
||||
label=${this.saveText}
|
||||
@click=${this._handleSave}
|
||||
>
|
||||
</arx-button>
|
||||
</div>
|
||||
${when(
|
||||
this.showInput,
|
||||
() => html`
|
||||
<arx-input
|
||||
type="text"
|
||||
class="input-field"
|
||||
.value=${this._inputValue}
|
||||
@change=${this._handleInputChange}
|
||||
placeholder=${this.placeholder}
|
||||
></arx-input>
|
||||
`,
|
||||
)}
|
||||
<div slot="footer">
|
||||
<arx-button
|
||||
variant="accent"
|
||||
label=${this.cancelText}
|
||||
@click=${this._handleCancel}
|
||||
></arx-button>
|
||||
<arx-button
|
||||
variant="secondary"
|
||||
label=${this.saveText}
|
||||
@click=${this._handleSave}
|
||||
></arx-button>
|
||||
</div>
|
||||
</div>
|
||||
</arx-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,11 @@ export class StyledToggle extends LitElement {
|
|||
@property({ type: Boolean }) required = false;
|
||||
@property() value = 'on';
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (!this.checked) this.value = 'off';
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue