diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 515a25f7..91021367 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -36,6 +36,7 @@ module.exports = { "calendar", "table", "split-button", + "datepicker", ], ], }, diff --git a/playground/template.html b/playground/template.html index 0628cba1..1b1d23d6 100644 --- a/playground/template.html +++ b/playground/template.html @@ -1,37 +1,37 @@ - - - - Baklava Playground - - - - - - -

Baklava Playground

+ h1 { + font: var(--bl-font-display-1); + } + + + +

Baklava Playground

-

- Copy this file as playground/index.html and try your work here by running - npm run serve. -

+

+ Copy this file as playground/index.html and try your work here by running + npm run serve. +

- Baklava is ready - - \ No newline at end of file +Baklava is ready + + diff --git a/src/baklava.ts b/src/baklava.ts index 72f1a5f8..bbaddfb5 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -36,4 +36,5 @@ export { default as BlTableHeaderCell } from "./components/table/table-header-ce export { default as BlTableCell } from "./components/table/table-cell/bl-table-cell"; export { default as BlSplitButton } from "./components/split-button/bl-split-button"; export { default as BlCalendar } from "./components/calendar/bl-calendar"; +export { default as BlDatePicker } from "./components/datepicker/bl-datepicker"; export { getIconPath, setIconPath } from "./utilities/asset-paths"; diff --git a/src/components/calendar/bl-calendar.css b/src/components/calendar/bl-calendar.css index 453d9fd0..8b689341 100644 --- a/src/components/calendar/bl-calendar.css +++ b/src/components/calendar/bl-calendar.css @@ -5,10 +5,10 @@ .calendar-content { display: flex; - padding: 16px; + padding: var(--bl-size-m); flex-direction: column; align-items: center; - gap: 16px; + gap: var(--bl-size-m); border-radius: var(--bl-border-radius-s); width: fit-content; background: var(--bl-color-neutral-full); @@ -19,7 +19,7 @@ justify-content: space-between; width: 100%; align-items: center; - gap: 2px; + padding-bottom: var(--bl-size-s); } .arrow { @@ -50,7 +50,7 @@ display: flex; align-items: center; flex-direction: row; - padding-bottom: 8px; + padding-bottom: var(--bl-size-2xs); } .day { @@ -115,7 +115,7 @@ .weekday-text { color: var(--bl-color-neutral-dark); text-align: center; - padding: 8px 0; + padding: var(--bl-size-2xs) 0; width: 40px; } @@ -132,7 +132,7 @@ } .grid-item:not(:nth-last-child(-n + 3)) { - padding-bottom: 8px; + padding-bottom: var(--bl-size-2xs); } .calendar-text { diff --git a/src/components/calendar/bl-calendar.stories.mdx b/src/components/calendar/bl-calendar.stories.mdx index ccfbbb3a..85efafef 100644 --- a/src/components/calendar/bl-calendar.stories.mdx +++ b/src/components/calendar/bl-calendar.stories.mdx @@ -1,7 +1,7 @@ -import { html } from 'lit'; -import { Meta, Canvas, ArgsTable, Story } from '@storybook/addon-docs'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { html } from "lit"; +import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; -export const CalendarTemplate = (args) => html` html` + ${unsafeHTML(args.content)}` + value=${ifDefined(args.value)} + disabled-dates=${ifDefined(args.disabledDates)}>${unsafeHTML(args.content)} + ` -export const Template = (args) => html`${CalendarTemplate({...args})}`; +export const Template = (args) => html`${CalendarTemplate({ ...args })}`; # Calendar [ADR](https://github.com/Trendyol/baklava/issues/795) -[Figma](https://www.figma.com/file/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?type=design&node-id=1412-8914&mode=design&t=b0kU7tBfJQFvz2at-0) +[Figma](https://www.figma.com/file/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?type=design&node-id=1412-8914&mode=design&t=b0kU7tBfJQFvz2at-0) Calendar component is an **internal** component for using inside Datepicker component. @@ -77,12 +80,28 @@ You can select date range from calendar. +### Set Value + +You can set default value to calendar. + + + + {Template.bind({})} + + + ### Disabled Dates You can set dates which you want to disable from calendar. - + {Template.bind({})} diff --git a/src/components/calendar/bl-calendar.test.ts b/src/components/calendar/bl-calendar.test.ts new file mode 100644 index 00000000..01d8951b --- /dev/null +++ b/src/components/calendar/bl-calendar.test.ts @@ -0,0 +1,581 @@ +import { expect, fixture, html } from "@open-wc/testing"; +import "./bl-calendar"; +import { BlButton, BlCalendar } from "../../baklava"; +import { blCalendarChangedEvent } from "./bl-calendar"; +import { CALENDAR_TYPES, CALENDAR_VIEWS, FIRST_MONTH_INDEX, LAST_MONTH_INDEX } from "./bl-calendar.constant"; +import sinon from "sinon"; + +describe("bl-calendar", () => { + let element: BlCalendar; + let consoleWarnSpy: sinon.SinonSpy; + + beforeEach(async () => { + element = await fixture(html` + `); + consoleWarnSpy = sinon.spy(console, "warn"); + + }); + + afterEach(() => { + consoleWarnSpy.restore(); + }); + + it("should instantiate the element", () => { + expect(document.createElement("bl-calendar")).instanceOf(HTMLElement); + }); + + it("should render the calendar header", () => { + const headerButtons = element.shadowRoot?.querySelectorAll(".calendar-header bl-button"); + + expect(headerButtons?.length).to.equal(4); + }); + + it("should navigate to the previous month when clicking the left arrow", () => { + const prevButton = element.shadowRoot?.querySelector(".calendar-header .arrow") as BlButton; + const currentMonth = element._calendarMonth; + + prevButton?.click(); + expect(element._calendarMonth).to.equal( + currentMonth === 0 ? 11 : currentMonth - 1 + ); + }); + + it("should navigate to the next month when clicking the right arrow", () => { + const nextButton = element.shadowRoot?.querySelectorAll(".calendar-header .arrow")[1] as BlButton; + const currentMonth = element._calendarMonth; + + nextButton?.click(); + expect(element._calendarMonth).to.equal( + currentMonth === 11 ? 0 : currentMonth + 1 + ); + }); + + it("should render days of the week", () => { + const weekDays = element.shadowRoot?.querySelectorAll(".calendar-text.weekday-text"); + + expect(weekDays?.length).to.equal(7); + }); + + it("should correctly handle single date selection", async () => { + const singleTypeCalendar = element = await fixture(html` + `); + + await singleTypeCalendar.updateComplete; + const dayButton = element.shadowRoot?.querySelector(".day-wrapper bl-button") as BlButton; + + dayButton?.click(); + expect(element._selectedDates.length).to.equal(1); + expect(element.checkIfSelectedDate(element._selectedDates[0])).to.be.true; + }); + + it("should correctly handle multiple date selection", async () => { + const multipleTypeCalendar = element = await fixture(html` + `); + + await multipleTypeCalendar.updateComplete; + const dayButtons = Array.from(element.shadowRoot?.querySelectorAll(".day-wrapper bl-button") || []) as BlButton[]; + + dayButtons[0].click(); + dayButtons[1].click(); + expect(element._selectedDates.length).to.equal(2); + }); + + it("should fire bl-calendar-change event when dates are selected", async () => { + const singleTypeCalendar = await fixture(html` + `); + + await singleTypeCalendar.updateComplete; + let selectedDates: Date[] = []; + + const onBlCalendarChanged: EventListener = (e: Event) => { + const customEvent = e as CustomEvent; + + selectedDates = customEvent.detail; + }; + + singleTypeCalendar.addEventListener(blCalendarChangedEvent, onBlCalendarChanged); + const daysButtons = Array.from(singleTypeCalendar.shadowRoot?.querySelectorAll(".day-wrapper bl-button") || []) as BlButton[]; + + daysButtons[0].click(); + expect(selectedDates.length).to.equal(1); + expect(selectedDates[0]).to.equal(singleTypeCalendar._selectedDates[0]); + + }); + + it("should not allow selection of dates before minDate", async () => { + element.minDate = new Date(2023, 0, 15); + element._calendarMonth = 0; + element._calendarYear = 2023; + + await element.updateComplete; + + const calendarDay = Array.from( + element.shadowRoot?.querySelectorAll("bl-button") || [] + ).find(button => button?.textContent?.trim() === "10"); + + expect(calendarDay?.hasAttribute("disabled")).to.be.true; + }); + + it("should not allow selection of dates after maxDate", async () => { + element.maxDate = new Date(2023, 0, 15); + element._calendarMonth = 0; + element._calendarYear = 2023; + + await element.updateComplete; + + const calendarDay = Array.from( + element.shadowRoot?.querySelectorAll("bl-button") || [] + ).find(button => button?.textContent?.trim() === "20"); + + expect(calendarDay?.hasAttribute("disabled")).to.be.true; + }); + + it("should switch to month view when the month button is clicked", async () => { + const monthButton = element.shadowRoot?.querySelector(".header-text") as BlButton; + + monthButton?.click(); + + expect(element._calendarView).to.equal(CALENDAR_VIEWS.MONTHS); + }); + + it("should select a date range correctly", async () => { + const startDate = new Date(2023, 1, 10); + const endDate = new Date(2023, 1, 20); + + element.handleRangeSelectCalendar(startDate); + element.handleRangeSelectCalendar(endDate); + + expect(element._selectedDates[0]).to.deep.equal(startDate); + expect(element._selectedDates[1]).to.deep.equal(endDate); + }); + + it("should render month names in the correct locale", async () => { + element = await fixture(html` + `); + + const monthName = new Date().toLocaleString("fr", { month: "long" }); + const firstMonth = element.shadowRoot?.querySelector(".header-text")?.textContent?.trim(); + + expect(firstMonth).to.equal(monthName); + }); + + it("should switch to the year view and render years", async () => { + const yearButton = (element.shadowRoot?.querySelectorAll(".header-text")[1]) as BlButton; + + yearButton?.click(); + + await element.updateComplete; + + expect(element._calendarView).to.equal(CALENDAR_VIEWS.YEARS); + const yearButtons = element.shadowRoot?.querySelectorAll(".grid-item"); + + expect(yearButtons?.length).to.equal(12); + }); + + it("should update the calendar month and view when setMonthAndCalendarView is called", async () => { + const setHoverClassSpy = sinon.spy(element, "setHoverClass"); + const testMonth = 5; + + element.setMonthAndCalendarView(testMonth); + + expect(element._calendarMonth).to.equal(testMonth); + expect(element._calendarView).to.equal(CALENDAR_VIEWS.DAYS); + expect(setHoverClassSpy.calledOnce).to.be.false; + + element.type = CALENDAR_TYPES.RANGE; + element.setMonthAndCalendarView(testMonth); + + expect(setHoverClassSpy.calledOnce).to.be.true; + }); + + it("should update the calendar year and view when setYearAndCalendarView is called", async () => { + const setHoverClassSpy = sinon.spy(element, "setHoverClass"); + const testYear = 2025; + + element.setYearAndCalendarView(testYear); + + expect(element._calendarYear).to.equal(testYear); + expect(element._calendarView).to.equal(CALENDAR_VIEWS.DAYS); + expect(setHoverClassSpy.calledOnce).to.be.false; + + element.type = CALENDAR_TYPES.RANGE; + element.setYearAndCalendarView(testYear); + + expect(setHoverClassSpy.calledOnce).to.be.true; + }); + + it("should return true if calendarDate is in disabledDates", () => { + const calendarDate = new Date(2023, 9, 18); + + element.disabledDates = [new Date(2023, 9, 18), new Date(2023, 9, 20)]; + + const result = element.checkIfDateIsDisabled(calendarDate); + + expect(result).to.be.true; + }); + + it("should return false if calendarDate is not in disabledDates", () => { + const calendarDate = new Date(2023, 9, 19); + + element.disabledDates = [new Date(2023, 9, 18), new Date(2023, 9, 20)]; + + const result = element.checkIfDateIsDisabled(calendarDate); + + expect(result).to.be.false; + }); + + it("should return false if calendarDate is not disabled", () => { + const calendarDate = new Date(2023, 9, 19); + + const result = element.checkIfDateIsDisabled(calendarDate); + + expect(result).to.be.false; + }); + + it("should wrap value in an array if it is a single date", async () => { + const calendar = new BlCalendar(); + + calendar.value = new Date("2023-09-18"); + calendar.type = CALENDAR_TYPES.SINGLE; + + expect(calendar._selectedDates).to.deep.equal([new Date("2023-09-18")], {}); + }); + + it("should set startDate and endDate in selectedDays when type is range", async () => { + const defaultDate1 = new Date(2023, 9, 18); + const defaultDate2 = new Date(2023, 9, 19); + + element.value = [defaultDate1, defaultDate2]; + element.type = CALENDAR_TYPES.RANGE; + + expect(element._selectedDates[0]).to.be.equal(defaultDate1); + expect(element._selectedDates[1]).to.be.equal(defaultDate2); + }); + + it("should navigate to the previous month in DAYS view", async () => { + element._calendarView = CALENDAR_VIEWS.DAYS; + element._calendarMonth = 5; + element._calendarYear = 2023; + + element.setPreviousCalendarView(); + await element.updateComplete; + + expect(element._calendarMonth).to.equal(4); + expect(element._calendarYear).to.equal(2023); + }); + + it("should navigate to December of the previous year if on January in DAYS view", async () => { + element._calendarView = CALENDAR_VIEWS.DAYS; + element._calendarMonth = FIRST_MONTH_INDEX; + element._calendarYear = 2023; + + element.setPreviousCalendarView(); + await element.updateComplete; + + expect(element._calendarMonth).to.equal(LAST_MONTH_INDEX); + expect(element._calendarYear).to.equal(2022); + }); + + it("should navigate to the previous year in MONTHS view", async () => { + element._calendarView = CALENDAR_VIEWS.MONTHS; + element._calendarYear = 2023; + + element.setPreviousCalendarView(); + await element.updateComplete; + + expect(element._calendarYear).to.equal(2022); + }); + + it("should generate the previous 12 years when in YEARS view", async () => { + element._calendarView = CALENDAR_VIEWS.YEARS; + element._calendarYears = [2023]; + + element.setPreviousCalendarView(); + await element.updateComplete; + + expect(element._calendarYears.length).to.equal(12); + expect(element._calendarYears).to.deep.equal([ + 2022, 2021, 2020, 2019, 2018, 2017, 2016, 2015, 2014, 2013, 2012, 2011 + ]); + }); + + it("should update calendar when in DAYS view and month is December", async () => { + element._calendarView = CALENDAR_VIEWS.DAYS; + element._calendarMonth = 11; + element._calendarYear = 2023; + + element.setNextCalendarView(); + await element.updateComplete; + + expect(element._calendarMonth).to.equal(0); + expect(element._calendarYear).to.equal(2024); + }); + + it("should update calendar when in DAYS view and month is not December", async () => { + element._calendarView = CALENDAR_VIEWS.DAYS; + element._calendarMonth = 5; + element._calendarYear = 2023; + + element.setNextCalendarView(); + await element.updateComplete; + + expect(element._calendarMonth).to.equal(6); + expect(element._calendarYear).to.equal(2023); + }); + + it("should update year when in MONTHS view", async () => { + element._calendarView = CALENDAR_VIEWS.MONTHS; + element._calendarYear = 2023; + + element.setNextCalendarView(); + await element.updateComplete; + + expect(element._calendarYear).to.equal(2024); + }); + + it("should update calendar years when in YEARS view", async () => { + element._calendarView = CALENDAR_VIEWS.YEARS; + element._calendarYears = [2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030, 2031]; + + element.setNextCalendarView(); + await element.updateComplete; + + expect(element._calendarYears.length).to.equal(12); + expect(element._calendarYears).to.deep.equal([ + 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, 2040, 2041, 2042, 2043 + ]); + }); + + it("should set both startDate and endDate when endDate is not set and calendarDate is earlier than startDate", () => { + const startDate = new Date(2023, 0, 5); + const calendarDate = new Date(2023, 0, 1); + + element._selectedDates[0] = startDate; + + element.handleRangeSelectCalendar(calendarDate); + + expect(element._selectedDates).to.deep.equal([calendarDate, startDate]); + }); + + it("should reset to only startDate when both startDate and endDate are set", () => { + const calendarDate = new Date(2023, 0, 10); + const startDate = new Date(2023, 0, 5); + const endDate = new Date(2023, 0, 15); + + element._selectedDates = [startDate, endDate]; + + element.handleRangeSelectCalendar(calendarDate); + + expect(element._selectedDates).to.deep.equal([calendarDate]); + }); + + it("should remove the date if it already exists in _selectedDates", () => { + const calendarDate = new Date(2023, 0, 5); + + element._selectedDates.push(calendarDate); + + element.handleMultipleSelectCalendar(calendarDate); + + expect(element._selectedDates).to.not.include(calendarDate); + expect(element._selectedDates).to.have.lengthOf(0); + }); + + it("should add the date if it does not exist in selectedDates", () => { + const calendarDate = new Date(2023, 0, 5); + + element.handleMultipleSelectCalendar(calendarDate); + + expect(element._selectedDates).to.include(calendarDate); + expect(element._selectedDates).to.have.lengthOf(1); + }); + + it("should call handleRangeSelectCalendar when type is RANGE", () => { + const calendarDate = new Date(2023, 6, 15); + + element.type = CALENDAR_TYPES.RANGE; + + const handleRangeSelectCalendarStub = sinon.stub(element, "handleRangeSelectCalendar"); + + element.handleDate(calendarDate); + + expect(handleRangeSelectCalendarStub).to.have.been.calledWith(calendarDate); + }); + + + it("should add range-start-day class to the start date element", async () => { + element._selectedDates = [new Date(element.today.getFullYear(), element.today.getMonth(), 1), + new Date(element.today.getFullYear(), element.today.getMonth(), 5) + ]; + + element.setHoverClass(); + + await new Promise((resolve) => setTimeout(resolve, 200)); + const startDateElement = element.shadowRoot?.getElementById( + `${element._selectedDates[0]?.getTime()}` + )?.parentElement; + + expect(startDateElement?.classList.contains("range-start-day")).to.be.true; + }); + + it("should add range-end-day class to the end date element", async () => { + element._selectedDates = [new Date(element.today.getFullYear(), element.today.getMonth(), 1), + new Date(element.today.getFullYear(), element.today.getMonth(), 5) + ]; + + element.setHoverClass(); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const endDateElement = element.shadowRoot?.getElementById( + `${element._selectedDates[1]?.getTime()}` + )?.parentElement; + + expect(endDateElement?.classList.contains("range-end-day")).to.be.true; + }); + + it("should correctly calculate lastMonthDaysCount when currentMonthStartWeekDay smaller startOfWeek", () => { + + element._calendarYear = 2024; + element._calendarMonth = 9; + + element.startOfWeek = 1; + const currentMonthStartWeekDay = 0; + + element.getWeekDayOfDate = () => currentMonthStartWeekDay; + + element.getDayNumInAMonth = (_year, month) => (month === 8 ? 30 : 31); + + element.createCalendarDays(); + + const expectedLastMonthDaysCount = 7 - (element.startOfWeek - currentMonthStartWeekDay); + + expect(element.getDayNumInAMonth(2024, 8)).to.equal(30); + expect(element.getWeekDayOfDate(2024, 9)).to.equal(currentMonthStartWeekDay); + expect(expectedLastMonthDaysCount).to.equal(6); + }); + + it("should correctly calculate lastMonthDaysCount when currentMonthStartWeekDay bigger than startOfWeek", () => { + element.startOfWeek = 1; + const currentMonthStartWeekDay = 2; + + element.getWeekDayOfDate = () => currentMonthStartWeekDay; + + element.getDayNumInAMonth = (_year, month) => (month === 8 ? 30 : 31); + + element.createCalendarDays(); + + const expectedLastMonthDaysCount = currentMonthStartWeekDay - element.startOfWeek; + + expect(expectedLastMonthDaysCount).to.equal(1); + }); + + it("should call setHoverClass when type is RANGE", () => { + + element.type = CALENDAR_TYPES.RANGE; + + const setHoverClassSpy = sinon.spy(element, "setHoverClass"); + + element._calendarView = CALENDAR_VIEWS.DAYS; + element._calendarMonth = FIRST_MONTH_INDEX; + element._calendarYear = 2024; + element._calendarYears = [2023, 2024, 2025]; + + element.setPreviousCalendarView(); + + expect(setHoverClassSpy).to.have.been.called; + + setHoverClassSpy.restore(); + }); + + it("should not call setHoverClass when type is not RANGE", () => { + + element.type = CALENDAR_TYPES.SINGLE; + + const setHoverClassSpy = sinon.spy(element, "setHoverClass"); + + element.setPreviousCalendarView(); + + expect(setHoverClassSpy).to.not.have.been.called; + + setHoverClassSpy.restore(); + }); + + it("should set calendarView to CALENDAR_VIEWS.DAYS when current view is CALENDAR_VIEWS.DAYS", () => { + + element._calendarView = CALENDAR_VIEWS.DAYS; + + const setHoverClassSpy = sinon.spy(element, "setHoverClass"); + + element.setCurrentCalendarView(CALENDAR_VIEWS.DAYS); + + expect(element._calendarView).to.equal(CALENDAR_VIEWS.DAYS); + + expect(setHoverClassSpy).to.have.been.called; + + setHoverClassSpy.restore(); + }); + + it("should set calendarView to the provided view if it is different from the current view", () => { + + element._calendarView = CALENDAR_VIEWS.MONTHS; + + const setHoverClassSpy = sinon.spy(element, "setHoverClass"); + + element.setCurrentCalendarView(CALENDAR_VIEWS.DAYS); + + expect(element._calendarView).to.equal(CALENDAR_VIEWS.DAYS); + + expect(setHoverClassSpy).to.have.been.called; + + setHoverClassSpy.restore(); + }); + + it("should call setNextCalendarView when date month is greater than calendarMonth", () => { + element._calendarMonth = 0; + element.type = CALENDAR_TYPES.SINGLE; + + const setNextCalendarViewSpy = sinon.spy(element, "setNextCalendarView"); + + element.handleDate(new Date(2024, 2, 15)); + + expect(setNextCalendarViewSpy).to.have.been.called; + + setNextCalendarViewSpy.restore(); + }); + + it("should not call setNextCalendarView when calendar type is RANGE", () => { + element._calendarMonth = 0; + element.type = CALENDAR_TYPES.RANGE; + + const setNextCalendarViewSpy = sinon.spy(element, "setNextCalendarView"); + + element.handleDate(new Date(2024, 2, 15)); + + expect(setNextCalendarViewSpy).to.not.have.been.called; + + setNextCalendarViewSpy.restore(); + }); + + it("should not call setNextCalendarView when date month is less than or equal to calendarMonth", () => { + element._calendarMonth = 0; + element._calendarMonth = 3; + + const setNextCalendarViewSpy = sinon.spy(element, "setNextCalendarView"); + + element.handleDate(new Date(2024, 2, 15)); + + expect(setNextCalendarViewSpy).to.not.have.been.called; + + setNextCalendarViewSpy.restore(); + }); + + it("should add classes when both startDate and endDate are defined", () => { + + element._selectedDates = [new Date(2024, 0, 10), new Date(2024, 0, 15)]; + + const setTimeoutSpy = sinon.spy(window, "setTimeout"); + + element.setHoverClass(); + + expect(setTimeoutSpy).to.have.been.calledOnce; + }); +}); diff --git a/src/components/calendar/bl-calendar.ts b/src/components/calendar/bl-calendar.ts index fa3b7ddb..1e1dcc1b 100644 --- a/src/components/calendar/bl-calendar.ts +++ b/src/components/calendar/bl-calendar.ts @@ -1,6 +1,7 @@ -import { CSSResultGroup, html, LitElement } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { CSSResultGroup, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; +import DatepickerCalendarMixin from "../../mixins/datepicker-calendar-mixin/datepicker-calendar-mixin"; import { event, EventDispatcher } from "../../utilities/event"; import "../button/bl-button"; import "../icon/bl-icon"; @@ -11,131 +12,65 @@ import { LAST_MONTH_INDEX, } from "./bl-calendar.constant"; import style from "./bl-calendar.css"; -import { - Calendar, - CalendarDate, - CalendarType, - CalendarView, - CalendarDay, - DayValues, - RangePickerDates, -} from "./bl-calendar.types"; +import { Calendar, CalendarDay, CalendarView } from "./bl-calendar.types"; + +export const blCalendarChangedEvent = "bl-calendar-change"; /** * @tag bl-calendar * @summary Baklava Calendar component **/ @customElement("bl-calendar") -export default class BlCalendar extends LitElement { - /** - * Defines the calendar types, available types are single, multiple and range - */ - @property() - type: CalendarType = CALENDAR_TYPES.SINGLE; - - /** - * Defines the minimum date value for the calendar - */ - @property({ type: Date, attribute: "min-date", reflect: true }) - minDate: Date; - - /** - * Defines the maximum date value for the calendar - */ - @property({ type: Date, attribute: "max-date", reflect: true }) - maxDate: Date; - - /** - * Defines the start day of the calendar (1 defines monday) - */ - @property({ type: Number, attribute: "start-of-week", reflect: true }) - startOfWeek: DayValues = 0; - - /** - * Defines the unselectable dates for calendar - */ - @property({ type: Array, attribute: "disabled-dates", reflect: true }) - disabledDates: Date[]; - - /** - * Defines the calendar language - */ - @property() - locale: string = document.documentElement.lang; - +export default class BlCalendar extends DatepickerCalendarMixin { @state() - private _selectedDates: CalendarDate[] = []; - + today = new Date(); @state() - private _selectedRangeDates: RangePickerDates = { startDate: undefined, endDate: undefined }; - + _calendarMonth: number = this.today.getMonth(); @state() - private today = new Date(); - + _calendarYear: number = this.today.getFullYear(); @state() - private _calendarMonth: number = this.today.getMonth(); - + _calendarView: CalendarView = CALENDAR_VIEWS.DAYS; @state() - private _calendarYear: number = this.today.getFullYear(); - + _calendarYears: number[] = []; @state() - private _calendarView: CalendarView = CALENDAR_VIEWS.DAYS; - - @state() - private _calendarYears: number[] = []; - - @state() - private _calendarDays: CalendarDay[] = []; - - private _defaultValue: Date | Date[]; - + _calendarDays: CalendarDay[] = []; /** - * Defines the default selected date value for the calendar + * Fires when date selection changes */ - @property({ type: Array, attribute: "default-value", reflect: true }) - get defaultValue(): Date | Date[] { - return this._defaultValue; - } - set defaultValue(defaultValue) { - if (this.type === CALENDAR_TYPES.SINGLE && Array.isArray(defaultValue)) { - console.warn("Invalid prop value for defaultValue"); - } else if (this.defaultValue) { - if (Array.isArray(this.defaultValue)) { - this._selectedDates = { ...this.defaultValue }; - } else this._selectedDates = [this.defaultValue]; - } + @event(blCalendarChangedEvent) _onBlCalendarChange: EventDispatcher; + + static get styles(): CSSResultGroup { + return [style]; } + get months() { - return [...Array(12).keys()].map(month => { - return { - name: new Date(0, month + 1, 0).toLocaleString(this.locale, { - month: "long", - }), - value: month, - }; - }); + return [...Array(12).keys()].map(month => ({ + name: new Date(0, month + 1, 0).toLocaleString(this.locale, { month: "long" }), + value: month, + })); } + get days() { - return [...Array(7).keys()].map(day => { - return { - name: new Date(0, 0, day).toLocaleString(this.locale, { weekday: "short" }), - value: day, - }; - }); - } - /** - * Fires when date selection changes - */ - @event("bl-calendar-change") private _onBlCalendarChange: EventDispatcher; - static get styles(): CSSResultGroup { - return [style]; + return [...Array(7).keys()].map(day => ({ + name: new Date(0, 0, day).toLocaleString(this.locale, { weekday: "short" }), + value: day, + })); } + + public handleClearSelectedDates = () => { + this._selectedDates = []; + this._onBlCalendarChange([]); + this.clearRangePickerStyles(); + }; + getDayNumInAMonth(year: number, month: number) { return new Date(year, month + 1, 0).getDate(); } + getWeekDayOfDate(year: number, month: number) { return new Date(year, month, 1).getDay(); } + setPreviousCalendarView() { this.clearRangePickerStyles(); if (this._calendarView === CALENDAR_VIEWS.DAYS) { @@ -148,151 +83,118 @@ export default class BlCalendar extends LitElement { } else if (this._calendarView === CALENDAR_VIEWS.YEARS) { const fromYear = this._calendarYears[0]; - this._calendarYears = []; - for (let i = 12; i > 0; i--) { - this._calendarYears.push(fromYear - i); - } - } - if (this.type === CALENDAR_TYPES.RANGE) { - this.setHoverClass(); + this._calendarYears = Array.from({ length: 12 }, (_, i) => fromYear - (i + 1)); } + if (this.type === CALENDAR_TYPES.RANGE) this.setHoverClass(); } + setNextCalendarView() { this.clearRangePickerStyles(); if (this._calendarView === CALENDAR_VIEWS.DAYS) { - if (this._calendarMonth === LAST_MONTH_INDEX) { - this._calendarMonth = FIRST_MONTH_INDEX; - this._calendarYear += 1; - } else this._calendarMonth += 1; + this._calendarMonth === LAST_MONTH_INDEX + ? ((this._calendarMonth = FIRST_MONTH_INDEX), (this._calendarYear += 1)) + : (this._calendarMonth += 1); } else if (this._calendarView === CALENDAR_VIEWS.MONTHS) { this._calendarYear += 1; } else if (this._calendarView === CALENDAR_VIEWS.YEARS) { const fromYear = this._calendarYears[11]; - this._calendarYears = []; - for (let i = 1; i <= 12; i++) { - this._calendarYears.push(fromYear + i); - } - } - if (this.type === CALENDAR_TYPES.RANGE) { - this.setHoverClass(); + this._calendarYears = Array.from({ length: 12 }, (_, i) => fromYear + (i + 1)); } + this.setHoverClass(); } setCurrentCalendarView(view: CalendarView) { - if (this._calendarView !== view) { - this._calendarView = view; - } else this._calendarView = CALENDAR_VIEWS.DAYS; + this._calendarView = this._calendarView !== view ? view : CALENDAR_VIEWS.DAYS; this.setHoverClass(); } setMonthAndCalendarView(month: number) { this._calendarMonth = month; this._calendarView = CALENDAR_VIEWS.DAYS; - if (this.type === CALENDAR_TYPES.RANGE) { - this.setHoverClass(); - } + if (this.type === CALENDAR_TYPES.RANGE) this.setHoverClass(); } + setYearAndCalendarView(year: number) { this._calendarYear = year; this._calendarView = CALENDAR_VIEWS.DAYS; - if (this.type === CALENDAR_TYPES.RANGE) { - this.setHoverClass(); - } + if (this.type === CALENDAR_TYPES.RANGE) this.setHoverClass(); } generateSurroundingYears() { - if (this._calendarYears.length === 0) { - this._calendarYears = Array.from( - { length: 12 }, - (_, index) => this._calendarYear - 4 + index - ); + if (!this._calendarYears.length) { + this._calendarYears = Array.from({ length: 12 }, (_, i) => this._calendarYear - 4 + i); } } + clearRangePickerStyles() { - this.shadowRoot?.querySelectorAll(".range-day").forEach(day => { - day.classList.remove("range-day"); - }); - this.shadowRoot?.querySelectorAll(".range-start-day").forEach(day => { - day.classList.remove("range-start-day"); - }); - this.shadowRoot?.querySelectorAll(".range-end-day").forEach(day => { - day.classList.remove("range-end-day"); - }); + this.shadowRoot + ?.querySelectorAll(".range-day, .range-start-day, .range-end-day") + .forEach(day => day.classList.remove("range-day", "range-start-day", "range-end-day")); } - handleDate(date: CalendarDate) { + + handleDate(date: Date) { if (this.type !== CALENDAR_TYPES.RANGE) { - if (date.getMonth() < this._calendarMonth) { - this.setPreviousCalendarView(); - } else if (date.getMonth() > this._calendarMonth) { - this.setNextCalendarView(); - } + if (date.getMonth() < this._calendarMonth) this.setPreviousCalendarView(); + else if (date.getMonth() > this._calendarMonth) this.setNextCalendarView(); } - if (this.type === CALENDAR_TYPES.SINGLE) { - this.handleSingleSelectCalendar(date); - } else if (this.type === CALENDAR_TYPES.MULTIPLE) { - this.handleMultipleSelectCalendar(date); - } else if (this.type === CALENDAR_TYPES.RANGE) { - this.handleRangeSelectCalendar(date); + switch (this.type) { + case CALENDAR_TYPES.SINGLE: + this.handleSingleSelectCalendar(date); + break; + case CALENDAR_TYPES.MULTIPLE: + this.handleMultipleSelectCalendar(date); + break; + case CALENDAR_TYPES.RANGE: + this.handleRangeSelectCalendar(date); + break; } this._onBlCalendarChange(this._selectedDates); this.requestUpdate(); } - handleSingleSelectCalendar(calendarDate: CalendarDate) { - this._selectedDates.splice(0, 1); - this._selectedDates.push(calendarDate); + + handleSingleSelectCalendar(calendarDate: Date) { + this._selectedDates = [calendarDate]; } - handleMultipleSelectCalendar(calendarDate: CalendarDate) { - const dateExist = this._selectedDates.find(function (selectedDate) { - return selectedDate.getTime() === calendarDate.getTime(); - }); - - if (dateExist) - this._selectedDates.splice( - this._selectedDates.findIndex(date => date.getTime() === calendarDate.getTime()), - 1 - ); - else this._selectedDates.push(calendarDate); + + handleMultipleSelectCalendar(calendarDate: Date) { + const dateExist = this._selectedDates?.some(d => d.getTime() === calendarDate.getTime()); + + dateExist + ? this._selectedDates?.splice( + this._selectedDates?.findIndex(d => d.getTime() === calendarDate.getTime()), + 1 + ) + : this._selectedDates.push(calendarDate); } - handleRangeSelectCalendar(calendarDate: CalendarDate) { - if (!this._selectedRangeDates.startDate) { - this._selectedRangeDates.startDate = calendarDate; - this._selectedDates.push(calendarDate); - } else if (this._selectedRangeDates.startDate && !this._selectedRangeDates.endDate) { - if (calendarDate.getTime() > this._selectedRangeDates.startDate.getTime()) { - this._selectedRangeDates.endDate = calendarDate; - this._selectedDates.push(calendarDate); - } else if (calendarDate.getTime() < this._selectedRangeDates.startDate.getTime()) { - const temp = this._selectedRangeDates.startDate; - - this._selectedRangeDates.startDate = calendarDate; - this._selectedRangeDates.endDate = temp; - this._selectedDates.splice( - 0, - this._selectedDates.length, - this._selectedRangeDates.startDate, - this._selectedRangeDates.endDate - ); + + handleRangeSelectCalendar(calendarDate: Date) { + if (!this._selectedDates[0]) { + this._selectedDates[0] = calendarDate; + } else if (!this._selectedDates[1]) { + if (calendarDate.getTime() > this._selectedDates[0].getTime()) { + this._selectedDates[1] = calendarDate; + } else { + const tempEndDate = this._selectedDates[0]; + + this._selectedDates[0] = calendarDate; + this._selectedDates[1] = tempEndDate; } - } else if (this._selectedRangeDates.startDate && this._selectedRangeDates.endDate) { - this._selectedRangeDates.startDate = calendarDate; - this._selectedRangeDates.endDate = undefined; - this._selectedDates.splice(0, this._selectedDates.length, this._selectedRangeDates.startDate); + } else { + this._selectedDates = []; + this._selectedDates[0] = calendarDate; } this.setHoverClass(); } - checkIfSelectedDate(calendarDate: CalendarDate) { - const day = this._selectedDates.find(selectedDate => { - return calendarDate.getTime() === selectedDate.getTime(); - }); - - return !!day; + checkIfSelectedDate(calendarDate: Date) { + return this._selectedDates?.some(date => date?.getTime() === calendarDate.getTime()); } - checkIfDateIsToday(calendarDate: CalendarDate) { - const today = new Date(); + + checkIfDateIsToday(calendarDate: Date) { + const today = this.today; return ( today.getDate() === calendarDate.getDate() && @@ -300,20 +202,18 @@ export default class BlCalendar extends LitElement { today.getFullYear() === calendarDate.getFullYear() ); } - checkIfDateIsDisabled(calendarDate: CalendarDate) { + + checkIfDateIsDisabled(calendarDate: Date) { if ( calendarDate.getTime() < this.minDate?.getTime() || calendarDate.getTime() > this.maxDate?.getTime() ) { return true; } - - if (Array.isArray(this.disabledDates)) { - const day = this.disabledDates.find(disabledDate => { - return calendarDate.getTime() === new Date(disabledDate).getTime(); + if (this.disabledDates.length > 0) { + return this.disabledDates.some(disabledDate => { + return calendarDate.getTime() === disabledDate.getTime(); }); - - return !!day; } return false; } @@ -321,16 +221,16 @@ export default class BlCalendar extends LitElement { setHoverClass() { this.clearRangePickerStyles(); - if (this._selectedRangeDates.startDate && this._selectedRangeDates.endDate) { + if (this._selectedDates[0] && this._selectedDates[1]) { setTimeout(() => { const startDateParentElement = this.shadowRoot?.getElementById( - `${this._selectedRangeDates.startDate?.getTime()}` + `${this._selectedDates[0]?.getTime()}` )?.parentElement; startDateParentElement?.classList.add("range-start-day"); const endDateParentElement = this.shadowRoot?.getElementById( - `${this._selectedRangeDates.endDate?.getTime()}` + `${this._selectedDates[1]?.getTime()}` )?.parentElement; endDateParentElement?.classList.add("range-end-day"); @@ -338,8 +238,8 @@ export default class BlCalendar extends LitElement { .flat() .filter( date => - date.getTime() > (this._selectedRangeDates?.startDate?.getTime() || 0) && - date.getTime() < (this._selectedRangeDates?.endDate?.getTime() || 0) + date.getTime() > this._selectedDates[0]!.getTime() && + date.getTime() < this._selectedDates[1]!.getTime() ); for (let i = 0; i < rangeDays.length; i++) { @@ -426,19 +326,59 @@ export default class BlCalendar extends LitElement { } return calendar; } - render() { - const getCalendarView = (calendarView: CalendarView) => { - if (calendarView === CALENDAR_VIEWS.DAYS) { - const calendarDays = this.createCalendarDays(); - const valuesArray = Array.from(calendarDays.values()); - - return html`
- ${[...calendarDays.keys()].map(key => { - return html`
${key}
`; - })}
-
+ + renderCalendarHeader() { + const showMonthSelected = + this._calendarView === CALENDAR_VIEWS.MONTHS ? "header-text-hover" : ""; + const showYearSelected = this._calendarView === CALENDAR_VIEWS.YEARS ? "header-text-hover" : ""; + + return html` +
+ + ${this.months[this._calendarMonth].name} + + ${this._calendarYear} + + +
+ `; + } + + renderCalendarDays() { + const calendarDays = this.createCalendarDays(); + const valuesArray = Array.from(calendarDays.values()); + + return html` +
+ ${[...calendarDays.keys()].map(key => { + return html`
${key}
`; + })} +
+
${[...Array(valuesArray[0].length).keys()].map(key => { - return html`
+ return html`
${valuesArray.map(values => { const date = values[key]; const isSelectedDay = this.checkIfSelectedDate(date); @@ -472,85 +412,58 @@ export default class BlCalendar extends LitElement { })}
`; })} +
+
`; + } + + renderCalendarMonths() { + return html`
+ ${this.months.map((month, index) => { + const variant = month.value === this._calendarMonth ? "primary" : "tertiary"; + const neutral = month.value === this._calendarMonth ? "default" : "neutral"; + + return html` ${month.name}`; + })} +
`; + } + + renderCalendarYears() { + this.generateSurroundingYears(); + return html`
+ ${this._calendarYears.map(year => { + const variant = year === this._calendarYear ? "primary" : "tertiary"; + const neutral = year === this._calendarYear ? "default" : "neutral"; + + return html` ${year}`; + })} +
`; + } + + render() { + return html` +
+
+
+ ${this.renderCalendarHeader()} + ${this._calendarView === CALENDAR_VIEWS.DAYS ? this.renderCalendarDays() : ""} + ${this._calendarView === CALENDAR_VIEWS.MONTHS ? this.renderCalendarMonths() : ""} + ${this._calendarView === CALENDAR_VIEWS.YEARS ? this.renderCalendarYears() : ""} +
-
`; - } else if (calendarView === CALENDAR_VIEWS.MONTHS) { - return html`
- ${this.months.map((month, index) => { - const variant = month.value === this._calendarMonth ? "primary" : "tertiary"; - const neutral = month.value === this._calendarMonth ? "default" : "neutral"; - - return html`${month.name}`; - })} -
`; - } else { - this.generateSurroundingYears(); - return html`
- ${this._calendarYears.map(year => { - const variant = year === this._calendarYear ? "primary" : "tertiary"; - const neutral = year === this._calendarYear ? "default" : "neutral"; - - return html`${year}`; - })} -
`; - } - }; - const showMonthSelected = - this._calendarView === CALENDAR_VIEWS.MONTHS ? "header-text-hover" : ""; - const showYearSelected = this._calendarView === CALENDAR_VIEWS.YEARS ? "header-text-hover" : ""; - const buttonLabel = this._calendarView === CALENDAR_VIEWS.DAYS ? "Month" : "Year"; - - return html`
-
-
- - - ${this.months[this._calendarMonth].name} - ${this._calendarYear} - - -
-
${getCalendarView(this._calendarView)}
-
`; + `; } } diff --git a/src/components/calendar/bl-calendar.types.ts b/src/components/calendar/bl-calendar.types.ts index de556270..487f4c37 100644 --- a/src/components/calendar/bl-calendar.types.ts +++ b/src/components/calendar/bl-calendar.types.ts @@ -6,11 +6,10 @@ export type CalendarView = CALENDAR_VIEWS; export type CalendarType = CALENDAR_TYPES; export type CalendarDay = { value: number; name: string }; -export type CalendarDate = Date; export type RangePickerDates = { - startDate?: CalendarDate; - endDate?: CalendarDate; + startDate?: Date; + endDate?: Date; }; -export type Calendar = Map; +export type Calendar = Map; diff --git a/src/components/datepicker/bl-datepicker.css b/src/components/datepicker/bl-datepicker.css new file mode 100644 index 00000000..d5a66ed1 --- /dev/null +++ b/src/components/datepicker/bl-datepicker.css @@ -0,0 +1,53 @@ +:host { + width: fit-content; + display: block; +} + +.datepicker-content { + --bl-input-cursor: pointer; + --icon-size: var(--line-height); + --icon-color: var(--bl-color-neutral-light); + --datepicker-width: 314px; + + display: flex; + flex-direction: column; + gap: var(--bl-size-2xs); + width: fit-content; +} + +.datepicker-input { + width: var(--bl-datepicker-input-width, var(--datepicker-width)); + white-space: nowrap; + text-overflow: ellipsis; +} + +.icon-container { + display: flex; + gap: var(--bl-size-3xs); + align-items: center; +} + +.calendar-icon { + display: flex; + align-items: center; + gap: var(--icon-gap); + flex-basis: var(--icon-size); + align-self: center; + margin-right: var(--label-padding); + font-size: var(--icon-size); + color: var(--icon-color); + height: var(--icon-size); +} + +.action-divider { + display: block; + height: var(--bl-size-m); + width: 1px; + background-color: var(--bl-color-neutral-lighter); + margin-right: var(--bl-size-3xs); +} + +bl-popover { + --bl-popover-padding: 0; + --bl-popover-background-color: transparent; +} diff --git a/src/components/datepicker/bl-datepicker.stories.mdx b/src/components/datepicker/bl-datepicker.stories.mdx new file mode 100644 index 00000000..126f18f3 --- /dev/null +++ b/src/components/datepicker/bl-datepicker.stories.mdx @@ -0,0 +1,139 @@ +import { html } from "lit"; +import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + + + +export const DatepickerTemplate = (args) => html` + ${unsafeHTML(args.content)} + ` + + +export const Template = (args) => html`${DatepickerTemplate({ ...args })}`; + + +# Datepicker + +[ADR](https://github.com/Trendyol/baklava/issues/894) + [Figma](https://www.figma.com/file/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?type=design&node-id=1412-8914&mode=design&t=b0kU7tBfJQFvz2at-0) + +Datepicker renders the calendar component within itself and provides the functionality provided by the calendar component. + +### Usage + +* Datepicker has three types such as **single**,**multiple** and **range**.Default datepicker type is `single`.You can set datepicker type by using `type` attribute. +* Datepicker has **min-date** and **max-date** attributes.By entering these values,you can disable all dates before min-date property or will disable all dates after max-date property. +* Another attribute **disabled-dates** is also restrict the dates that can be selected on the datepicker. +* Attribute **start-of-date** defines the days of the week, corresponding to 0 Sundays and 6 Saturdays. By entering this, you can choose from which day the datepicker will create the datepicker view. + + +## Datepicker Types + +### Single Type Datepicker + +Default datepicker type is `single` and you can only select a single day from datepicker. + + + + {Template.bind({})} + + + +### Multiple Type Datepicker + +You can select multiple days from Datepicker. + + + + {Template.bind({})} + + + +### Range Type Datepicker + +You can select date range from Datepicker. + + + + {Template.bind({})} + + + +### Default Value + +You can set a default value to datepicker. + + + + {Template.bind({})} + + + +### Disabled Dates + +You can set dates which you want to disable from Datepicker. + + + + {Template.bind({})} + + + +### Input Width + +You can set input width of datepicker as you wish. + + + + {Template.bind({})} + + + +## Reference + + diff --git a/src/components/datepicker/bl-datepicker.test.ts b/src/components/datepicker/bl-datepicker.test.ts new file mode 100644 index 00000000..902a1216 --- /dev/null +++ b/src/components/datepicker/bl-datepicker.test.ts @@ -0,0 +1,378 @@ +import { aTimeout, expect, fixture, html } from "@open-wc/testing"; +import BlDatepicker, { blDatepickerChangedEvent } from "./bl-datepicker"; +import { BlButton, BlDatePicker } from "../../baklava"; +import { blCalendarChangedEvent } from "../calendar/bl-calendar"; +import { CALENDAR_TYPES } from "../calendar/bl-calendar.constant"; +import sinon from "sinon"; + +describe("BlDatepicker", () => { + let element: BlDatepicker; + let getElementByIdStub: sinon.SinonStub; + let consoleWarnSpy: sinon.SinonSpy; + + beforeEach(async () => { + element = await fixture(html` + `); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + getElementByIdStub = sinon.stub(element.shadowRoot, "getElementById").callsFake((id) => { + if (id === "datepicker-input") { + return { offsetWidth: 300 }; + } + if (id === "icon-container") { + return { offsetWidth: 60 }; + } + return null; + }); + consoleWarnSpy = sinon.spy(console, "warn"); + + await element.updateComplete; + }); + + afterEach(() => { + getElementByIdStub.restore(); + consoleWarnSpy.restore(); + }); + + it("should instantiate the component", () => { + expect(document.createElement("bl-datepicker")).instanceOf(HTMLElement); + }); + + it("should render the datepicker component", () => { + expect(element).to.exist; + expect(element.shadowRoot).to.exist; + }); + + it("should have default empty value", () => { + expect(element._inputValue).to.equal(""); + }); + + it("should set placeholder correctly", async () => { + element.placeholder = "Select a date"; + await element.updateComplete; + + expect(element._inputEl?.placeholder).to.equal("Select a date"); + }); + + it("should open the popover when input is clicked", async () => { + + element._inputEl?.click(); + await element.updateComplete; + + expect(element._popoverEl).to.exist; + expect(element._popoverEl?.visible).to.be.true; + }); + + it("should close the popover after selecting a date", async () => { + + element._inputEl?.click(); + await element.updateComplete; + + element._calendarEl?.dispatchEvent(new CustomEvent(blCalendarChangedEvent, { detail: [new Date()] })); + await element.updateComplete; + await aTimeout(400); + expect(element._selectedDates.length).to.equal(1); + expect(element._popoverEl.visible).to.be.false; + }); + + it("should trigger datepicker change event on date selection", async () => { + const testDate = new Date(2023, 1, 1); + + element.addEventListener(blDatepickerChangedEvent, (event) => { + const customEvent = event as CustomEvent; + + expect(customEvent).to.exist; + expect(customEvent.detail).to.deep.equal([testDate]); + + }); + + element._calendarEl.dispatchEvent(new CustomEvent(blCalendarChangedEvent, { detail: [testDate] })); + + await element.updateComplete; + }); + + it("should clear selected dates when clear button is clicked", async () => { + element._selectedDates = [new Date(2023, 1, 1)]; + await element.updateComplete; + + const clearButton = element.shadowRoot?.querySelector("bl-button") as BlButton; + + clearButton?.click(); + await element.updateComplete; + + expect(element._selectedDates).to.deep.equal([]); + expect(element._inputValue).to.equal(""); + }); + + it("should disable the input when 'disabled' is set", async () => { + element.disabled = true; + await element.updateComplete; + + const input = element._inputEl; + + expect(input?.hasAttribute("disabled")).to.be.true; + }); + + it("should use custom value formatter when provided", async () => { + const testDate = new Date(2023, 1, 1); + + element.inputValueFormatter = (dates: Date[]) => `Custom format: ${dates[0].toDateString()}`; + element.setDatePickerInput([testDate]); + await element.updateComplete; + + expect(element._inputValue).to.equal(`Custom format: ${testDate.toDateString()}`); + }); + + it("should handle multiple date selections", async () => { + const dates = [new Date(2023, 1, 1), new Date(2023, 1, 2)]; + + element.type = CALENDAR_TYPES.MULTIPLE; + await element.updateComplete; + + element._calendarEl?.dispatchEvent(new CustomEvent(blCalendarChangedEvent, { detail: dates })); + await element.updateComplete; + + expect(element._selectedDates.length).to.equal(2); + expect(element._selectedDates).to.deep.equal(dates); + }); + + it("should clear the datepicker even if no dates are selected", async () => { + element.clearDatepicker(); + await element.updateComplete; + + expect(element._selectedDates).to.deep.equal([]); + expect(element._inputValue).to.equal(""); + }); + + it("should handle selecting a range of dates", async () => { + const startDate = new Date(2023, 1, 1); + const endDate = new Date(2023, 1, 7); + + element.type = CALENDAR_TYPES.RANGE; + await element.updateComplete; + + element._calendarEl?.dispatchEvent(new CustomEvent(blCalendarChangedEvent, { detail: [startDate, endDate] })); + await element.updateComplete; + + expect(element._selectedDates.length).to.equal(2); + expect(element._selectedDates).to.deep.equal([startDate, endDate]); + }); + + it("should display help text when provided", async () => { + element.helpText = "Please select a valid date."; + await element.updateComplete; + + const input = element._inputEl; + + expect(input?.getAttribute("help-text")).to.equal("Please select a valid date."); + }); + + it("should close the popover after timeout", async () => { + element._inputEl?.click(); + await element.updateComplete; + + element.closePopoverWithTimeout(); + await aTimeout(300); + + expect(element._popoverEl.visible).to.be.false; + }); + + it("should insert a
after every third item", () => { + const inputString = "Item1, Item2, Item3, Item4"; + const result = element.formatAdditionalDates(inputString); + + expect(result[3].strings).to.include("
"); + }); + + it("should render the tooltip when floatingDateCount > 0", async () => { + element._floatingDateCount = 2; + element.requestUpdate(); + + await element.updateComplete; + + const tooltip = element.shadowRoot?.querySelector("bl-tooltip"); + + expect(tooltip).to.not.be.null; + + const trigger = tooltip?.querySelector("[slot=\"tooltip-trigger\"]"); + + expect(trigger).to.not.be.null; + expect(trigger?.textContent).to.equal("+2"); + }); + + it("should include ',...' when floatingDateCount is greater than 0 for MULTIPLE type", () => { + + element.type = CALENDAR_TYPES.MULTIPLE; + element._selectedDates = [new Date("2024-01-01"), new Date("2024-01-02"), new Date("2024-01-03")]; + element.setFloatingDates(); + element.defaultInputValueFormatter(); + expect(element._inputValue).to.include(" ,..."); + }); + + it("should not include \" ,...\" when floatingDateCount is 0 for MULTIPLE type", () => { + + element.type = CALENDAR_TYPES.MULTIPLE; + element._selectedDates = [new Date("2024-01-01")]; + + element.setFloatingDates(); + + element.defaultInputValueFormatter(); + expect(element._inputValue).to.not.include(" ,..."); + }); + + it("should format a date correctly", () => { + const testDate = new Date(2024, 9, 8); + const formattedDate = element.formatDate(testDate); + + expect(formattedDate).to.equal("08/10/2024"); + }); + + it("should handle single-digit days and months correctly", () => { + const testDate = new Date(2024, 0, 5); + const formattedDate = element.formatDate(testDate); + + expect(formattedDate).to.equal("05/01/2024"); + }); + + it("should call openPopover when popoverEl is not visible", () => { + const openPopoverSpy = sinon.spy(element, "openPopover"); + + element._togglePopover(); + + expect(openPopoverSpy).to.have.been.calledOnce; + expect(element._popoverEl.visible).to.be.true; + openPopoverSpy.restore(); + }); + + it("should call closePopover when popoverEl is visible", () => { + element._popoverEl.show(); + const closePopoverSpy = sinon.spy(element, "closePopover"); + + element._togglePopover(); + + expect(closePopoverSpy).to.have.been.calledOnce; + expect(element._popoverEl.visible).to.be.false; + closePopoverSpy.restore(); + }); + + it("should return a single date when value is a single Date", () => { + const date = new Date("2024-01-01"); + + element._value = date; + expect(element.value).to.equal(date); + }); + + it("should return an array of dates when value is an array of Dates", () => { + const dates = [new Date("2024-01-01"), new Date("2024-02-01")]; + + element._value = dates; + expect(element.value).to.deep.equal(dates); + }); + + it("should return undefined if value is not set", () => { + expect(element.value).to.be.undefined; + }); + + it("should warn when 'value' is not an array for multiple/range selection", async () => { + element = await fixture(html` + `); + element._value = new Date(); + + element.firstUpdated(); + + expect(consoleWarnSpy.calledOnce).to.be.true; + }); + + it("should not warn when value is an array for multiple/range selection", () => { + element.type = CALENDAR_TYPES.MULTIPLE; + element._value = [new Date(), new Date()]; + + element.firstUpdated(); + + expect(consoleWarnSpy.called).to.be.false; + }); + + it("should not warn when 'value' is an array of exactly two Date objects in RANGE mode", () => { + element.type = CALENDAR_TYPES.RANGE; + element._value = [new Date(), new Date()]; + + element.firstUpdated(); + + expect(consoleWarnSpy.called).to.be.false; + }); + + it("should warn if minDate is greater than or equal to maxDate", async () => { + + element.maxDate = new Date(2023, 0, 1); + await element.updateComplete; + + element.minDate = new Date(2023, 0, 2); + await element.updateComplete; + + expect(consoleWarnSpy.calledWith("minDate cannot be greater than maxDate.")).to.be.true; + }); + + it("should warn if maxDate is less than or equal to minDate", async () => { + + element.minDate = new Date(2023, 0, 2); + await element.updateComplete; + + element.maxDate = new Date(2023, 0, 1); + await element.updateComplete; + + expect(consoleWarnSpy.calledWith("maxDate cannot be smaller than minDate.")).to.be.true; + }); + + + it("should focus the input element on calendar mouse down", async () => { + const focusSpy = sinon.spy(element._inputEl, "focus"); + const preventDefaultSpy = sinon.spy(); + + element._inputEl.focus = focusSpy; + + const mouseDownEvent = new MouseEvent("mousedown", { bubbles: true, composed: true }); + + mouseDownEvent.preventDefault = preventDefaultSpy; + + element._inputEl.dispatchEvent(mouseDownEvent); + + expect(preventDefaultSpy).to.have.been.calledOnce; + + expect(focusSpy).to.have.been.calledOnce; + }); + + it("should focus the input element on input mouse down", async () => { + // Create spies for the methods we want to check + const focusSpy = sinon.spy(element._inputEl, "focus"); + const preventDefaultSpy = sinon.spy(); + + element._inputEl.focus = focusSpy; + + const mouseDownEvent = new MouseEvent("mousedown", { bubbles: true, composed: true }); + + mouseDownEvent.preventDefault = preventDefaultSpy; + + element._inputEl.dispatchEvent(mouseDownEvent); + + expect(preventDefaultSpy).to.have.been.calledOnce; + + expect(focusSpy).to.have.been.calledOnce; + }); + + it("should focus the input element on calendar mouse down", async () => { + const focusSpy = sinon.spy(element._inputEl, "focus"); + const preventDefaultSpy = sinon.spy(); + + const mouseDownEvent = new MouseEvent("mousedown", { bubbles: true, composed: true }); + + mouseDownEvent.preventDefault = preventDefaultSpy; + + element._calendarEl.dispatchEvent(mouseDownEvent); + + expect(preventDefaultSpy.called).to.be.true; + + expect(focusSpy.called).to.be.true; + }); + +}); diff --git a/src/components/datepicker/bl-datepicker.ts b/src/components/datepicker/bl-datepicker.ts new file mode 100644 index 00000000..dcfe562f --- /dev/null +++ b/src/components/datepicker/bl-datepicker.ts @@ -0,0 +1,280 @@ +import { CSSResultGroup, html, TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { BlCalendar, BlPopover } from "../../baklava"; +import DatepickerCalendarMixin from "../../mixins/datepicker-calendar-mixin/datepicker-calendar-mixin"; +import { event, EventDispatcher } from "../../utilities/event"; +import "../calendar/bl-calendar"; +import { CALENDAR_TYPES } from "../calendar/bl-calendar.constant"; +import "../input/bl-input"; +import BlInput, { InputSize } from "../input/bl-input"; +import "../tooltip/bl-tooltip"; +import style from "./bl-datepicker.css"; + +export const blDatepickerTag = "bl-datepicker"; +export const blDatepickerChangedEvent = "bl-datepicker-change"; + +/** + * @tag bl-datepicker + * @summary Baklava DatePicker component + * + * @cssproperty [--bl-datepicker-input-width] - Sets the width of datepicker input + **/ +@customElement(blDatepickerTag) +export default class BlDatepicker extends DatepickerCalendarMixin { + /** + * Defines the datepicker input placeholder + */ + @property({ type: String, attribute: "placeholder", reflect: true }) + placeholder: string; + /** + * Sets input size. + */ + @property({ type: String, reflect: true }) + inputSize?: InputSize = "medium"; + + /** + * Makes datepicker input label as fixed positioned + */ + @property({ type: Boolean, attribute: "input-label-fixed", reflect: true }) + inputLabelFixed = false; + /** + * Defines the datepicker input label + */ + @property({ type: String, attribute: "label", reflect: true }) + label: string; + /** + * Defines the custom formatter function + */ + @property({ type: Function, attribute: "input-value-formatter" }) + inputValueFormatter: ((dates: Date[]) => string) | null = null; + /** + * Sets datepicker to disabled + */ + @property({ type: Boolean }) + disabled: boolean; + /** + * Defines help text to datepicker input for users + */ + @property({ type: String, attribute: "help-text", reflect: true }) + helpText: string; + + @state() + _inputValue = ""; + + @state() + _selectedDates: Date[] = []; + + @state() + _floatingDateCount: number = 0; + + @state() + _fittingDateCount: number = 0; + + @query("bl-calendar") + _calendarEl: BlCalendar; + + @query("bl-popover") + _popoverEl: BlPopover; + + @query("bl-input") + _inputEl!: BlInput; + + private _onCalendarMouseDown!: (event: MouseEvent) => void; + private _onInputMouseDown!: (event: MouseEvent) => void; + + /** + * Fires when date selection is changed + */ + @event(blDatepickerChangedEvent) private _onBlDatepickerChanged: EventDispatcher; + + static get styles(): CSSResultGroup { + return [style]; + } + + defaultInputValueFormatter() { + if (this.type === CALENDAR_TYPES.SINGLE) { + this._inputValue = this.formatDate(this._selectedDates[0]); + this.closePopoverWithTimeout(); + } else if (this.type === CALENDAR_TYPES.MULTIPLE) { + this.setFloatingDates(); + const values = this._selectedDates + .slice(0, this._fittingDateCount) + .map(date => this.formatDate(date)); + + this._inputValue = values.join(",") + (this._floatingDateCount > 0 ? " ,..." : ""); + } else if (this.type === CALENDAR_TYPES.RANGE) { + if (this._selectedDates[0]) this._inputValue = this.formatDate(this._selectedDates[0]); + if (this._selectedDates[1]) + this._inputValue = `${this._inputValue}-${this.formatDate(this._selectedDates[1])}`; + if (this._selectedDates[0] && this._selectedDates[1]) this.closePopoverWithTimeout(); + } + } + + closePopoverWithTimeout() { + setTimeout(() => { + this.closePopover(); + this._inputEl.blur(); + }, 200); + } + + setFloatingDates() { + const datepickerInput = this.shadowRoot?.getElementById("datepicker-input"); + const iconsContainer = this.shadowRoot?.getElementById("icon-container"); + const datesTextTotalWidth = datepickerInput!.offsetWidth! - iconsContainer!.offsetWidth!; + + this._fittingDateCount = Math.floor(datesTextTotalWidth / 90); + + this._floatingDateCount = this._selectedDates.length - this._fittingDateCount; + } + + setDatePickerInput(dates: Date[] | []) { + if (!dates.length) { + this._inputValue = ""; + } else { + this._selectedDates = dates; + if (this.inputValueFormatter) { + this._inputValue = this.inputValueFormatter(this._selectedDates); + } else { + this.defaultInputValueFormatter(); + } + } + + this._onBlDatepickerChanged(this._selectedDates); + } + + formatDate(date: Date): string { + return `${String(date?.getDate()).padStart(2, "0")}/${String(date?.getMonth() + 1).padStart( + 2, + "0" + )}/${date?.getFullYear()}`; + } + + clearDatepicker() { + this._calendarEl.handleClearSelectedDates(); + this._selectedDates = []; + this._inputValue = ""; + this._floatingDateCount = 0; + } + + openPopover() { + this._popoverEl.target = this._inputEl; + this._popoverEl.show(); + } + + closePopover() { + this._popoverEl.hide(); + } + + _togglePopover() { + this._popoverEl.visible ? this.closePopover() : this.openPopover(); + } + + formatAdditionalDates(str: string): TemplateResult[] { + const parts = str.split(","); + + return parts.reduce((acc, part, index) => { + if (index > 0 && index % 3 === 0) { + acc.push(html`
`); + } + acc.push(html`${part.trim()}${index < parts.length - 1 ? ", " : ""}`); + return acc; + }, []); + } + + async firstUpdated() { + this._onCalendarMouseDown = event => { + event.preventDefault(); + this._inputEl?.focus(); + }; + + this._onInputMouseDown = event => { + event.preventDefault(); + this._inputEl?.focus(); + }; + + this._calendarEl?.addEventListener("mousedown", this._onCalendarMouseDown); + this._inputEl?.addEventListener("mousedown", this._onInputMouseDown); + + if (this._selectedDates) { + this.setDatePickerInput(this._selectedDates); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._calendarEl?.removeEventListener("mousedown", this._onCalendarMouseDown); + this._inputEl?.removeEventListener("mousedown", this._onInputMouseDown); + } + + render() { + const renderCalendar = html` + + + + `; + const additionalDates = this._selectedDates + ?.slice(this._fittingDateCount) + .map(date => { + return this.formatDate(date); + }) + .join(","); + + const formattedAdditionalDates = this.formatAdditionalDates(additionalDates); + + const additionalDatesView = + this._floatingDateCount > 0 + ? html` + +${this._floatingDateCount} +
${formattedAdditionalDates}
+
` + : ""; + + const clearDatepickerButton = + this._selectedDates.length > 0 + ? html` this.clearDatepicker()} + > +
` + : ""; + + return html` +
+ +
+ ${additionalDatesView} ${clearDatepickerButton} + +
+
+ ${renderCalendar} +
+ `; + } +} diff --git a/src/components/input/bl-input.css b/src/components/input/bl-input.css index eb18f49f..6991ee6a 100644 --- a/src/components/input/bl-input.css +++ b/src/components/input/bl-input.css @@ -140,6 +140,7 @@ input { color: var(--text-color); -webkit-text-fill-color: var(--text-color); background-color: transparent; + cursor: var(--bl-input-cursor, not-allowed); } input::-webkit-credentials-auto-fill-button { diff --git a/src/components/popover/bl-popover.ts b/src/components/popover/bl-popover.ts index 5466053d..04195b47 100644 --- a/src/components/popover/bl-popover.ts +++ b/src/components/popover/bl-popover.ts @@ -40,56 +40,81 @@ export type Placement = * @cssproperty [--bl-popover-border-color=--bl-color-primary-highlight] - Sets the border color of popover. * @cssproperty [--bl-popover-border-size=1px] - Sets the border size of popover. You can set it to `0px` to not have a border (if you use a custom background color). Always use with a length unit. * @cssproperty [--bl-popover-padding=--bl-size-m] - Sets the padding of popover. - * @cssproperty [--bl-popover-border-radius=--bl-size-3xs] - Sets the border radius of popover. + * @cssproperty [--bl-popbover-border-radius=--bl-size-3xs] - Sets the border radius of popover. * @cssproperty [--bl-popover-max-width=100vw] - Sets the maximum width of the popover (including border and padding). * @cssproperty [--bl-popover-position=fixed] - Sets the position of popover. You can set it to `absolute` if parent element is a fixed positioned element like drawer or dialog. */ @customElement("bl-popover") export default class BlPopover extends LitElement { - static get styles(): CSSResultGroup { - return [style]; - } - - @query(".popover") private _popover: HTMLElement; - @query(".arrow") private arrow: HTMLElement; - /** * Sets placement of the popover */ @property({ type: String }) placement: Placement = "bottom"; - - /** - * Target elements state - */ - @state() _target: string | Element; - /** * Sets size of popover same as trigger element */ @property({ type: Boolean, attribute: "fit-size" }) fitSize = false; - /** * Sets the distance between popover and target/trigger element */ @property({ type: Number }) offset = 8; + @query(".popover") private _popover: HTMLElement; + @query(".arrow") private arrow: HTMLElement; + /** + * Fires when the popover is shown + */ + @event("bl-popover-show") private onBlPopoverShow: EventDispatcher; + /** + * Fires when popover becomes hidden + */ + @event("bl-popover-hide") private onBlPopoverHide: EventDispatcher; + private popoverAutoUpdateCleanup: () => void; + + static get styles(): CSSResultGroup { + return [style]; + } /** - * Visibility state + * Target elements state */ - @state() private _visible = false; + @state() _target: string | Element; /** - * Fires when the popover is shown + * Sets the target element of the popover to align and trigger. + * It can be a string id of the target element or can be a direct Element reference of it. */ - @event("bl-popover-show") private onBlPopoverShow: EventDispatcher; + @property() + get target(): string | Element { + return this._target; + } + + set target(value: string | Element) { + const target = getTarget(value); + + if (!target) { + console.warn( + "BlPopover target only accepts an Element instance or a string id of a DOM element." + ); + return; + } + + this._target = target; + } /** - * Fires when popover becomes hidden + * Visibility state */ - @event("bl-popover-hide") private onBlPopoverHide: EventDispatcher; + @state() private _visible = false; + + /** + * Gives the visibility status of the popover + */ + get visible(): boolean { + return this._visible; + } connectedCallback() { super.connectedCallback(); @@ -105,6 +130,41 @@ export default class BlPopover extends LitElement { this.popoverAutoUpdateCleanup && this.popoverAutoUpdateCleanup(); } + /** + * Shows popover + */ + show() { + this._visible = true; + this.setPopover(); + this.onBlPopoverShow(""); + document.addEventListener("click", this._handleClickOutside); + document.addEventListener("keydown", this._handleKeydownEvent); + document.addEventListener("bl-popover-show", this._handlePopoverShowEvent); + } + + /** + * Hides popover + */ + hide() { + this._visible = false; + document.removeEventListener("click", this._handleClickOutside); + document.removeEventListener("keydown", this._handleKeydownEvent); + document.removeEventListener("bl-popover-show", this._handlePopoverShowEvent); + this.onBlPopoverHide(""); + } + + render(): TemplateResult { + const classes = classMap({ + popover: true, + visible: this._visible, + }); + + return html`
+ + +
`; + } + private getMiddleware(): Middleware[] { const middlewareParams: Middleware[] = []; @@ -137,8 +197,6 @@ export default class BlPopover extends LitElement { } }; - private popoverAutoUpdateCleanup: () => void; - private setPopover() { if (this.target) { this.popoverAutoUpdateCleanup = autoUpdate(this.target as Element, this._popover, () => { @@ -167,58 +225,6 @@ export default class BlPopover extends LitElement { } } - /** - * Sets the target element of the popover to align and trigger. - * It can be a string id of the target element or can be a direct Element reference of it. - */ - @property() - get target(): string | Element { - return this._target; - } - - set target(value: string | Element) { - const target = getTarget(value); - - if (!target) { - console.warn( - "BlPopover target only accepts an Element instance or a string id of a DOM element." - ); - return; - } - - this._target = target; - } - - /** - * Shows popover - */ - show() { - this._visible = true; - this.setPopover(); - this.onBlPopoverShow(""); - document.addEventListener("click", this._handleClickOutside); - document.addEventListener("keydown", this._handleKeydownEvent); - document.addEventListener("bl-popover-show", this._handlePopoverShowEvent); - } - - /** - * Hides popover - */ - hide() { - this._visible = false; - document.removeEventListener("click", this._handleClickOutside); - document.removeEventListener("keydown", this._handleKeydownEvent); - document.removeEventListener("bl-popover-show", this._handlePopoverShowEvent); - this.onBlPopoverHide(""); - } - - /** - * Gives the visibility status of the popover - */ - get visible(): boolean { - return this._visible; - } - private _handlePopoverShowEvent(event: Event) { if (event.target !== this) { const { parentElement } = event.target as HTMLElement; @@ -236,18 +242,6 @@ export default class BlPopover extends LitElement { this.hide(); } } - - render(): TemplateResult { - const classes = classMap({ - popover: true, - visible: this._visible, - }); - - return html`
- - -
`; - } } declare global { diff --git a/src/mixins/datepicker-calendar-mixin/datepicker-calendar-mixin.test.ts b/src/mixins/datepicker-calendar-mixin/datepicker-calendar-mixin.test.ts new file mode 100644 index 00000000..71364cc1 --- /dev/null +++ b/src/mixins/datepicker-calendar-mixin/datepicker-calendar-mixin.test.ts @@ -0,0 +1,130 @@ +import { html } from "lit"; +import { expect, fixture } from "@open-wc/testing"; +import DatepickerCalendarMixin from "./datepicker-calendar-mixin"; +import { CALENDAR_TYPES } from "../../components/calendar/bl-calendar.constant"; +import sinon from "sinon"; + +class TestDatepickerCalendar extends DatepickerCalendarMixin { +} + +customElements.define("test-datepicker-calendar", TestDatepickerCalendar); + +describe("DatepickerCalendarMixin", () => { + let element: TestDatepickerCalendar; + + beforeEach(async () => { + element = await fixture( + html` + ` + ); + }); + + it("should correctly set and get disabledDates from a string", () => { + element.disabledDates = "2024-01-01,2024-01-15"; + expect(element.disabledDates).to.have.length(2); + expect(element.disabledDates[0].getTime()).to.equal(new Date("2024-01-01").getTime()); + expect(element.disabledDates[1].getTime()).to.equal(new Date("2024-01-15").getTime()); + }); + + it("should correctly set and get disabledDates from an array of dates", () => { + const disabledDatesArray = [new Date("2024-01-01"), new Date("2024-01-15")]; + + element.disabledDates = disabledDatesArray; + expect(element.disabledDates).to.deep.equal(disabledDatesArray); + }); + + it("should not add invalid dates to disabledDates", () => { + element.disabledDates = "invalid-date,2024-01-15"; + expect(element.disabledDates).to.have.length(1); + expect(element.disabledDates[0].getTime()).to.equal(new Date("2024-01-15").getTime()); + }); + + it("should set and get minDate correctly", () => { + const minDate = new Date("2024-01-01"); + + element.minDate = minDate; + expect(element.minDate).to.equal(minDate); + }); + + it("should log a warning if minDate is greater than maxDate", () => { + const consoleSpy = sinon.spy(console, "warn"); + + element.maxDate = new Date("2024-01-01"); + element.minDate = new Date("2024-02-01"); + expect(consoleSpy.calledWith("minDate cannot be greater than maxDate.")).to.be.true; + consoleSpy.restore(); + }); + + it("should set and get maxDate correctly", () => { + const maxDate = new Date("2024-12-31"); + + element.maxDate = maxDate; + expect(element.maxDate).to.equal(maxDate); + }); + + it("should log a warning if maxDate is smaller than minDate", () => { + const consoleSpy = sinon.spy(console, "warn"); + + element.minDate = new Date("2024-12-31"); + element.maxDate = new Date("2024-01-01"); + expect(consoleSpy.calledWith("maxDate cannot be smaller than minDate.")).to.be.true; + consoleSpy.restore(); + }); + + it("should correctly parse value from a string", () => { + const valueString = "2024-01-01,2024-01-15"; + + element.type = CALENDAR_TYPES.MULTIPLE; + element.value = valueString; + expect(element._selectedDates).to.be.an("array"); + expect(element._selectedDates).to.have.length(2); + expect((element._selectedDates)[0].getTime()).to.equal(new Date("2024-01-01").getTime()); + expect((element._selectedDates)[1].getTime()).to.equal(new Date("2024-01-15").getTime()); + }); + + it("should correctly parse value from a Date object", () => { + const dateValue = new Date("2024-01-01"); + + element.type = CALENDAR_TYPES.SINGLE; + element.value = dateValue; + expect(element.value).to.equal(dateValue); + }); + + it("should log a warning if value type is invalid for CALENDAR_TYPES.SINGLE", () => { + const consoleSpy = sinon.spy(console, "warn"); + + element.type = CALENDAR_TYPES.SINGLE; + element.value = [new Date("2024-01-01"), new Date("2024-01-15")]; + expect(consoleSpy.calledWith("'value' must be a single Date for single type selection.")).to.be + .true; + consoleSpy.restore(); + }); + + it("should log a warning if value type is invalid for CALENDAR_TYPES.RANGE", () => { + const consoleSpy = sinon.spy(console, "warn"); + + element.type = CALENDAR_TYPES.RANGE; + element.value = [new Date("2024-01-01")]; + expect( + consoleSpy.calledWith( + "'value' must be an array of two Date objects when the type selection mode is set to range." + ) + ).to.be.true; + consoleSpy.restore(); + }); + + it("should update selectedDates when value changes", () => { + const dateValue = new Date("2024-01-01"); + + element.type = CALENDAR_TYPES.SINGLE; + element.value = dateValue; + expect(element._selectedDates).to.deep.equal([dateValue]); + }); + + it("should not update selectedDates if value is invalid", () => { + const originalSelectedDates = [...element._selectedDates]; + + element.value = "invalid-date"; + expect(element._selectedDates).to.deep.equal(originalSelectedDates); + }); +}); diff --git a/src/mixins/datepicker-calendar-mixin/datepicker-calendar-mixin.ts b/src/mixins/datepicker-calendar-mixin/datepicker-calendar-mixin.ts new file mode 100644 index 00000000..a89dd7fd --- /dev/null +++ b/src/mixins/datepicker-calendar-mixin/datepicker-calendar-mixin.ts @@ -0,0 +1,125 @@ +import { LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; +import { CALENDAR_TYPES } from "../../components/calendar/bl-calendar.constant"; +import { CalendarType, DayValues } from "../../components/calendar/bl-calendar.types"; +import { stringToDateArray } from "../../utilities/string-to-date-converter"; + +export default class DatepickerCalendarMixin extends LitElement { + /** + * Defines the calendar types, available types are single, multiple and range + */ + @property() + type: CalendarType; + /** + * Defines the start day of the calendar (1 defines monday) + */ + @property({ type: Number, attribute: "start-of-week", reflect: true }) + startOfWeek: DayValues = 0; + /** + * Defines the calendar language + */ + @property() + locale: string = document.documentElement.lang || "en-EN"; + @state() + _selectedDates: Date[] = []; + + /** + * Defines the unselectable dates for calendar + */ + _disabledDates: Date[] = []; + + get disabledDates(): Date[] { + return this._disabledDates; + } + + @property({ attribute: "disabled-dates", reflect: true }) + set disabledDates(disabledDates: Date[] | string) { + if (typeof disabledDates === "string") { + this._disabledDates = stringToDateArray(disabledDates); + } else if (Array.isArray(disabledDates)) { + disabledDates.forEach(disabledDate => { + if (!isNaN(disabledDate.getTime())) this._disabledDates.push(disabledDate); + }); + } + } + + /** + * Defines the maximum date value for the calendar + */ + _maxDate: Date; + + get maxDate() { + return this._maxDate; + } + + @property({ type: Date, attribute: "max-date", reflect: true }) + set maxDate(maxDate: Date) { + if (this._minDate && this._minDate >= maxDate) { + console.warn("maxDate cannot be smaller than minDate."); + } else { + this._maxDate = maxDate; + } + } + + /** + * Defines the minimum date value for the calendar + */ + _minDate: Date; + + get minDate() { + return this._minDate; + } + + @property({ type: Date, attribute: "min-date", reflect: true }) + set minDate(minDate: Date) { + if (this._maxDate && this._maxDate <= minDate) { + console.warn("minDate cannot be greater than maxDate."); + } else { + this._minDate = minDate; + } + } + + /** + * Target elements state + */ + + @state() _value: Date | Date[] | string; + /** + * Sets the target element of the popover to align and trigger. + * It can be a string id of the target element or can be a direct Element reference of it. + */ + @property() + get value(): string | Date | Date[] { + return this._value; + } + + set value(value: string | Date | Date[]) { + if (value) { + let tempVal: Date[] = []; + + if (typeof value === "string") { + tempVal = stringToDateArray(value); + } else if (value instanceof Date) { + tempVal.push(value); + } else if (Array.isArray(value)) { + tempVal = value; + } + if (tempVal.length > 0) { + if (this.type === CALENDAR_TYPES.SINGLE && tempVal.length > 1) { + console.warn("'value' must be a single Date for single type selection."); + } else if ( + this.type === CALENDAR_TYPES.RANGE && + Array.isArray(tempVal) && + tempVal.length != 2 + ) { + console.warn( + "'value' must be an array of two Date objects when the type selection mode is set to range." + ); + } else { + this._value = value; + this._selectedDates.splice(0, this._selectedDates.length, ...tempVal); + } + } + } + } +} diff --git a/src/utilities/string-to-date-converter.test.ts b/src/utilities/string-to-date-converter.test.ts new file mode 100644 index 00000000..4cc773e6 --- /dev/null +++ b/src/utilities/string-to-date-converter.test.ts @@ -0,0 +1,63 @@ +import { expect } from "@open-wc/testing"; +import { stringToDateArray } from "./string-to-date-converter"; + +describe("stringToDateArray", () => { + it("should convert a valid string of dates into an array of Date objects", () => { + const input = "2024-01-01,2024-02-01,2024-03-01"; + const result = stringToDateArray(input); + + expect(result).to.be.an("array").with.length(3); + expect(result[0].getTime()).to.equal(new Date("2024-01-01").getTime()); + expect(result[1].getTime()).to.equal(new Date("2024-02-01").getTime()); + expect(result[2].getTime()).to.equal(new Date("2024-03-01").getTime()); + }); + + it("should handle an empty string and return an empty array", () => { + const input = ""; + const result = stringToDateArray(input); + + expect(result).to.be.an("array").that.is.empty; + }); + + it("should skip invalid date strings", () => { + const input = "2024-01-01,invalid-date,2024-03-01"; + const result = stringToDateArray(input); + + expect(result).to.be.an("array").with.length(2); + expect(result[0].toISOString()).to.include("2024-01-01"); + expect(result[1].toISOString()).to.include("2024-03-01"); + }); + + it("should return an empty array if all dates are invalid", () => { + const input = "invalid-date1,invalid-date2,not-a-date"; + const result = stringToDateArray(input); + + expect(result).to.be.an("array").that.is.empty; + }); + + it("should correctly parse a single valid date", () => { + const input = "2024-01-01"; + const result = stringToDateArray(input); + + expect(result).to.be.an("array").with.length(1); + expect(result[0].toISOString()).to.include("2024-01-01"); + }); + + it("should handle leading and trailing spaces in date strings", () => { + const input = " 2024-01-01 , 2024-02-01 "; + const result = stringToDateArray(input); + + expect(result).to.be.an("array").with.length(2); + expect(result[0].getTime()).to.equal(new Date("2024-01-01").getTime()); + expect(result[1].getTime()).to.equal(new Date("2024-02-01").getTime()); + }); + + it("should handle mixed valid and invalid dates with extra spaces", () => { + const input = " 2024-01-01 , invalid-date , 2024-03-01 "; + const result = stringToDateArray(input); + + expect(result).to.be.an("array").with.length(2); + expect(result[0].getTime()).to.equal(new Date("2024-01-01").getTime()); + expect(result[1].getTime()).to.equal(new Date("2024-03-01").getTime()); + }); +}); diff --git a/src/utilities/string-to-date-converter.ts b/src/utilities/string-to-date-converter.ts new file mode 100644 index 00000000..259f2fa3 --- /dev/null +++ b/src/utilities/string-to-date-converter.ts @@ -0,0 +1,13 @@ +export function stringToDateArray(value: string): Date[] { + const tempValue: Date[] = []; + const splitDates = value.split(","); + + splitDates?.forEach(date => { + const isDate = new Date(date.trim()); + + if (!isNaN(isDate.getTime())) { + tempValue.push(isDate); + } + }); + return tempValue; +}