diff --git a/tedi/components/form/date-picker/date-picker.component.html b/tedi/components/form/date-picker/date-picker.component.html new file mode 100644 index 000000000..a7e5a7959 --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.component.html @@ -0,0 +1,344 @@ + +
+ @if (selected()) { + + + } + + + + + +
+
+ @if (currentView() === "calendar-grid") { + @if (showNavigation()) { + + } + +
+ @if (monthMode() === "dropdown") { + + + + @for (month of monthNames; track i; let i = $index) { +
  • + {{ month() }} +
  • + } +
    +
    + } @else if (monthMode() === "grid") { + + } @else if (monthMode() === "label") { +
    + {{ monthNames[month().getMonth()]() }} +
    + } + + @if (yearMode() === "dropdown") { + + + + @for (year of years(); track year) { +
  • + {{ year }} +
  • + } +
    +
    + } @else if (yearMode() === "grid") { + + } @else if (yearMode() === "label") { +
    + {{ selectedYear() }} +
    + } +
    + + @if (showNavigation()) { + + } + } @else if (currentView() === "month-grid") { +
    + {{ monthNames[month().getMonth()]() }} +
    + } @else if (currentView() === "year-grid") { + +
    {{ selectedYear() }}
    + + } +
    + + @if (currentView() === "calendar-grid") { +
    + @if (showWeekNumbers()) { +
    + } + @for (wd of weekDays; track $index; let i = $index) { +
    + {{ wd() }} +
    + } +
    + +
    + @for (week of weekRows(); track row; let row = $index) { +
    + @if (showWeekNumbers()) { +
    + {{ weekNumbers()[row] }} +
    + } + @for (day of week; track day.date) { + + } +
    + } +
    + } @else if (currentView() === "month-grid") { +
    + @for (monthName of monthShortNames; track i; let i = $index) { + + } +
    + } @else if (currentView() === "year-grid") { +
    + @for (year of pagedYears(); track year) { + + } +
    + } +
    +
    +
    +
    diff --git a/tedi/components/form/date-picker/date-picker.component.scss b/tedi/components/form/date-picker/date-picker.component.scss new file mode 100644 index 000000000..ce60198fe --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.component.scss @@ -0,0 +1,353 @@ +tedi-date-picker { + display: flex; + min-height: var(--form-field-height); + gap: var(--form-field-inner-spacing); + align-self: stretch; + border-radius: var(--form-field-radius); + border: var(--borders-01) solid var(--form-input-border-default); + background: var(--form-input-background-default); + padding-right: var(--form-field-padding-x-md-default); + + &:has(.tedi-date-picker__input:hover):not( + :has(.tedi-date-picker__input:disabled) + ) { + border-color: var(--form-input-border-hover); + } + + &:has(.tedi-date-picker__input:active):not( + :has(.tedi-date-picker__input:disabled) + ), + &:has(.tedi-date-picker__input:focus):not( + :has(.tedi-date-picker__input:disabled) + ) { + border-color: var(--form-input-border-active); + box-shadow: 0 0 0 1px var(--form-input-border-active); + } + + &:has(.tedi-date-picker__input:disabled) { + border-color: var(--form-input-border-disabled); + background: var(--form-input-background-disabled); + cursor: not-allowed; + } + + &:has(.tedi-date-picker__input--valid) { + border-color: var(--form-general-feedback-success-border); + } + + &:has(.tedi-date-picker__input--error) { + border-color: var(--form-general-feedback-error-border); + } + + &:has(.tedi-date-picker__input--small) { + min-height: var(--form-field-height-sm); + } +} + +.tedi-date-picker { + &__input { + flex: 1; + padding-left: var(--form-field-padding-x-md-default); + color: var(--form-input-text-filled); + font-size: var(--body-regular-size); + border: 0; + border-radius: var(--form-field-radius); + + &::placeholder { + color: var(--form-input-text-placeholder); + } + + &:disabled { + cursor: not-allowed; + } + } + + &__input-buttons { + align-self: center; + display: flex; + align-items: center; + justify-content: center; + gap: var(--layout-grid-gutters-04); + } + + &__clear { + &:disabled { + cursor: not-allowed; + } + } + + &__toggle { + width: var(--button-xs-icon-size) !important; + height: var(--form-field-button-height-sm) !important; + border-radius: var(--button-radius-sm) !important; + font-size: 1.125rem !important; + + &:disabled { + cursor: not-allowed; + } + } + + &__calendar { + width: fit-content; + display: block; + border-radius: var(--card-radius-rounded); + background: var(--card-background-primary); + user-select: none; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--layout-grid-gutters-08); + padding: var(--card-padding-md-default) var(--card-padding-md-default) + var(--card-padding-xs) var(--card-padding-md-default); + } + + &__controls { + display: flex; + align-items: center; + gap: var(--layout-grid-gutters-08); + margin: 0 auto; + } + + &__dropdown-trigger { + display: inline-flex; + align-items: center; + gap: var(--layout-grid-gutters-02); + padding: 0; + padding-left: var(--layout-grid-gutters-04); + font-size: 1rem; + font-weight: 500; + color: var(--general-text-primary); + background: transparent; + border: 0; + border-radius: var(--button-radius-sm); + cursor: pointer; + + &:hover { + color: var(--button-main-neutral-text-hover); + background: var(--button-main-neutral-icon-only-background-hover); + + tedi-icon { + color: var(--button-main-neutral-text-hover); + } + } + + &:active { + color: var(--button-main-neutral-text-active); + background: var(--button-main-neutral-icon-only-background-active); + + tedi-icon { + color: var(--button-main-neutral-text-active); + } + } + + &:focus-visible { + outline: var(--borders-02) solid var(--primary-500); + outline-offset: var(--borders-01); + } + + tedi-icon { + font-size: 2rem; + color: var(--general-icon-tertiary); + } + } + + &__dropdown-content { + max-height: 15rem; + + &--month { + width: 10rem; + } + + &--year { + width: 8.75rem; + } + } + + &__label { + color: var(--general-text-primary); + font-weight: 500; + } + + &__nav { + font-size: var(--button-icon-inner-icon-only-size) !important; + } + + &__weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + padding: 0 var(--card-padding-md-default); + + &--numbered { + grid-template-columns: repeat(8, 1fr); + } + } + + &__weekday { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + display: flex; + justify-content: center; + align-items: center; + font-size: var(--body-small-regular-size); + color: var(--general-text-tertiary); + text-align: center; + text-transform: uppercase; + border-bottom: var(--borders-01) solid var(--general-border-primary); + } + + &__grid { + display: flex; + flex-direction: column; + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default) + var(--card-padding-md-default); + } + + &__row { + display: grid; + grid-template-columns: repeat(7, 1fr); + + &--numbered { + grid-template-columns: repeat(8, 1fr); + } + } + + &__weeknumber { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + color: var(--general-text-tertiary); + font-size: var(--body-small-regular-size); + border-right: var(--borders-01) solid var(--general-border-primary); + } + + &__day { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + border: none; + background: none; + cursor: pointer; + color: var(--general-text-primary); + font-size: var(--body-regular-size); + border-radius: var(--button-radius-sm); + + &:hover { + background: var(--form-datepicker-date-hover); + } + + &:active { + background: var(--form-datepicker-date-active); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.3; + } + + &:focus-visible { + outline: var(--borders-02) solid var(--primary-500); + outline-offset: var(--borders-01); + } + + &--other-month { + color: var(--form-datepicker-date-text-muted); + } + + &--selected { + color: var(--form-datepicker-date-text-selected); + border-radius: var(--button-radius-sm); + background: var(--form-datepicker-date-selected); + + &:hover { + background: var(--form-datepicker-date-selected); + } + + .tedi-date-picker__today { + border-color: var(--form-datepicker-today-border-secondary); + } + } + } + + &__today { + display: flex; + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + justify-content: center; + align-items: center; + border-radius: var(--button-radius-default); + border: var(--borders-01) solid var(--form-datepicker-today-border); + flex-shrink: 0; + } + + &__month-year-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--layout-grid-gutters-08); + padding: var(--card-padding-md-default); + } + + &__month-year-button { + display: flex; + justify-content: center; + align-items: center; + padding: var(--form-checkbox-radio-card-radio-padding-y) + var(--form-checkbox-radio-card-radio-padding-x); + border-radius: var(--form-checkbox-radio-card-radius); + border: var(--borders-01) solid + var(--form-checkbox-radio-card-secondary-default-border); + background: var(--form-checkbox-radio-card-secondary-default-background); + color: var(--form-checkbox-radio-card-primary-default-text); + font-size: var(--body-regular-size); + + &:hover { + color: var(--form-checkbox-radio-card-secondary-hover-text); + border-color: var(--form-checkbox-radio-card-secondary-hover-border); + background: var(--form-checkbox-radio-card-secondary-hover-background); + } + + &:focus-visible { + outline: var(--borders-02) solid var(--primary-500); + outline-offset: var(--borders-01); + } + + &:disabled { + color: var(--form-checkbox-radio-card-secondary-disabled-default-text); + border-color: var( + --form-checkbox-radio-card-secondary-disabled-default-border + ); + background: var( + --form-checkbox-radio-card-secondary-disabled-default-background + ); + cursor: not-allowed; + } + + &--selected { + color: var(--form-checkbox-radio-card-secondary-selected-text); + border-color: var(--form-checkbox-radio-card-secondary-selected-border); + box-shadow: 0 0 0 1px + var(--form-checkbox-radio-card-secondary-selected-border); + background: var(--form-checkbox-radio-card-secondary-selected-background); + + &:disabled { + color: var(--form-checkbox-radio-card-secondary-disabled-selected-text); + border-color: var( + --form-checkbox-radio-card-secondary-disabled-selected-border + ); + box-shadow: 0 0 0 1px + var(--form-checkbox-radio-card-secondary-disabled-selected-border); + background: var( + --form-checkbox-radio-card-secondary-disabled-selected-background + ); + cursor: not-allowed; + } + } + } +} diff --git a/tedi/components/form/date-picker/date-picker.component.spec.ts b/tedi/components/form/date-picker/date-picker.component.spec.ts new file mode 100644 index 000000000..6151aa990 --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.component.spec.ts @@ -0,0 +1,651 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { DatePickerComponent } from "./date-picker.component"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { NgxFloatUiContentComponent } from "ngx-float-ui"; +import { ElementRef } from "@angular/core"; + +class TranslationMock { + track(key: string) { + return () => key; + } +} + +describe("DatePickerComponent", () => { + let fixture: ComponentFixture; + let component: DatePickerComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DatePickerComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + ], + }); + + fixture = TestBed.createComponent(DatePickerComponent); + component = fixture.componentInstance; + el = fixture.nativeElement; + + jest.spyOn(component.popover(), "floatUiComponent").mockReturnValue({ + state: false, + show: jest.fn(), + hide: jest.fn(), + } as unknown as NgxFloatUiContentComponent); + + fixture.detectChanges(); + }); + + const getInput = () => el.querySelector("input") as HTMLInputElement; + + it("should create component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with empty input when selected=null", () => { + expect(component.inputValue()).toBe(""); + }); + + it("should update inputValue when a date is selected", () => { + const date = new Date(2024, 2, 10); + + component.selectDay({ + date, + disabled: false, + inCurrentMonth: true, + }); + + fixture.detectChanges(); + + expect(component.selected()).toEqual(date); + expect(component.inputValue()).toBe("10.03.2024"); + }); + + it("should NOT select disabled date", () => { + const date = new Date(2024, 2, 20); + + component.selectDay({ + date, + disabled: true, + inCurrentMonth: true, + }); + + expect(component.selected()).toBeNull(); + }); + + it("clearInput() should reset inputValue & selected", () => { + component.selectDay({ + date: new Date(2024, 1, 1), + disabled: false, + inCurrentMonth: true, + }); + + fixture.detectChanges(); + + component.clearInput(); + fixture.detectChanges(); + + expect(component.inputValue()).toBe(""); + expect(component.selected()).toBeNull(); + }); + + it("prevMonth() should change month to previous", () => { + const initial = component.month(); + component.prevMonth(); + fixture.detectChanges(); + + const expected = (initial.getMonth() + 11) % 12; + expect(component.month().getMonth()).toBe(expected); + }); + + it("nextMonth() should change month to next", () => { + const initial = component.month(); + component.nextMonth(); + fixture.detectChanges(); + + const expected = (initial.getMonth() + 1) % 12; + expect(component.month().getMonth()).toBe(expected); + }); + + it("onMonthClick should switch to month-grid", () => { + component.onMonthClick(); + fixture.detectChanges(); + + expect(component.currentView()).toBe("month-grid"); + }); + + it("onYearClick should switch to year-grid", () => { + component.onYearClick(); + fixture.detectChanges(); + + expect(component.currentView()).toBe("year-grid"); + }); + + it("onMonthSelect should update month & return to calendar-grid", () => { + component.currentView.set("month-grid"); + + component.onMonthSelect("5"); + fixture.detectChanges(); + + expect(component.month().getMonth()).toBe(5); + expect(component.currentView()).toBe("calendar-grid"); + }); + + it("onYearSelect should update year & return to calendar-grid", () => { + component.currentView.set("year-grid"); + + component.onYearSelect("2030"); + fixture.detectChanges(); + + expect(component.month().getFullYear()).toBe(2030); + expect(component.currentView()).toBe("calendar-grid"); + }); + + it("manual input should update inputValue", () => { + const input = getInput(); + input.value = "15.04.2024"; + + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + expect(component.inputValue()).toBe("15.04.2024"); + }); + + it("should clear date when input value is empty string", () => { + const input = getInput(); + input.value = ""; + + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + expect(component.selected()).toBe(null); + }); + + it("valid manual input should update selected on blur", () => { + const input = getInput(); + + input.value = "05.02.2025"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(component.selected()).toEqual(new Date(2025, 1, 5)); + }); + + it("invalid manual input restores previous selected date", () => { + component.selected.set(new Date(2024, 0, 1)); + component.inputValue.set("01.01.2024"); + + fixture.detectChanges(); + + const input = getInput(); + input.value = "invalid"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(component.inputValue()).toBe("01.01.2024"); + }); + + it("disabled matcher using Date should disable that day", () => { + const disabledDate = new Date(2024, 1, 10); + fixture.componentRef.setInput("disabled", disabledDate); + fixture.detectChanges(); + + expect(component.isDisabled(disabledDate)).toBe(true); + expect(component.isDisabled(new Date(2024, 1, 11))).toBe(false); + }); + + it("Escape in day grid should hide popover and focus input", () => { + const hideMock = jest.fn(); + const pop = component.popover().floatUiComponent(); + pop.hide = hideMock; + pop.state = true; + + const input = component.inputElement().nativeElement; + const focusSpy = jest.spyOn(input, "focus"); + + const today = new Date(); + const event = new KeyboardEvent("keydown", { key: "Escape" }); + + component.onDayKeydown(event, today); + + expect(hideMock).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("Escape in month-grid should return to calendar-grid", () => { + component.currentView.set("month-grid"); + + const event = new KeyboardEvent("keydown", { key: "Escape" }); + component.onCalendarKeyDown(event); + + expect(component.currentView()).toBe("calendar-grid"); + }); + + it("weekNumbers should generate exactly one number per week row", () => { + const rows = component.weekRows(); + const weeks = component.weekNumbers(); + + expect(weeks.length).toBe(rows.length); + }); + + it("onInputClick should do nothing when allowManualInput=true", () => { + fixture.componentRef.setInput("allowManualInput", true); + + const pop = component.popover().floatUiComponent(); + const hideSpy = jest.spyOn(pop, "hide"); + const showSpy = jest.spyOn(pop, "show"); + + component.onInputClick(); + + expect(hideSpy).not.toHaveBeenCalled(); + expect(showSpy).not.toHaveBeenCalled(); + }); + + it("onInputClick should hide popover and focus input when popover is open", () => { + fixture.componentRef.setInput("allowManualInput", false); + + const pop = component.popover().floatUiComponent(); + pop.state = true; + + const hideSpy = jest.spyOn(pop, "hide"); + const focusSpy = jest.spyOn( + component.inputElement().nativeElement, + "focus", + ); + + component.onInputClick(); + + expect(hideSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("onInputClick should show popover and call openCalendar when popover closed", () => { + fixture.componentRef.setInput("allowManualInput", false); + + const pop = component.popover().floatUiComponent(); + pop.state = false; + + const showSpy = jest.spyOn(pop, "show"); + const openSpy = jest.spyOn(component, "openCalendar"); + + component.onInputClick(); + + expect(showSpy).toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalled(); + }); + + it("focusDate should update activeDate but NOT change month when already same month", () => { + const spyMonth = jest.spyOn(component.month, "set"); + + const date = new Date( + component.month().getFullYear(), + component.month().getMonth(), + 15, + ); + + component["focusDate"](date); + expect(component.activeDate()).toEqual(date); + expect(spyMonth).not.toHaveBeenCalled(); + }); + + it("focusDate should update month when focusing date from different month", () => { + const spyMonth = jest.spyOn(component.month, "set"); + + const nextMonthDate = new Date( + component.month().getFullYear(), + component.month().getMonth() + 1, + 10, + ); + + component["focusDate"](nextMonthDate); + expect(spyMonth).toHaveBeenCalled(); + }); + + it("focusDate should focus the correct button after timeout", () => { + jest.useFakeTimers(); + + const mockContainer = document.createElement("div"); + const date = new Date(2024, 4, 20); + const key = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ).getTime(); + + const fakeBtn = document.createElement("button"); + fakeBtn.setAttribute("data-date-key", String(key)); + + const focusSpy = jest.spyOn(fakeBtn, "focus"); + + mockContainer.appendChild(fakeBtn); + + jest.spyOn(component, "gridElement").mockReturnValue({ + nativeElement: mockContainer, + } as unknown as ElementRef); + + component["focusDate"](date); + + jest.runAllTimers(); + jest.useRealTimers(); + + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + }); + + describe("Year page navigation", () => { + it("prevYearPage should decrement page when hasPrevYearPage=true", () => { + component.yearPageIndex.set(2); + fixture.detectChanges(); + + component.prevYearPage(); + expect(component.yearPageIndex()).toBe(1); + }); + + it("prevYearPage should NOT decrement when hasPrevYearPage=false", () => { + component.yearPageIndex.set(0); + fixture.detectChanges(); + + component.prevYearPage(); + expect(component.yearPageIndex()).toBe(0); + }); + + it("nextYearPage should increment page when hasNextYearPage=true", () => { + component.yearPageIndex.set(0); + + fixture.componentRef.setInput("endYear", 200); + fixture.detectChanges(); + + component.nextYearPage(); + expect(component.yearPageIndex()).toBe(1); + }); + + it("nextYearPage should NOT increment when hasNextYearPage=false", () => { + const currentYear = new Date().getFullYear(); + fixture.componentRef.setInput("startYear", currentYear); + fixture.componentRef.setInput("endYear", currentYear + 11); + component.yearPageIndex.set(0); + fixture.detectChanges(); + + component.nextYearPage(); + expect(component.yearPageIndex()).toBe(0); + }); + }); + + describe("onDayKeydown navigation keys", () => { + const today = new Date(2024, 4, 15); + + let focusSpy: jest.SpyInstance; + type FocusType = { focusDate: (date: Date) => void }; + + beforeEach(() => { + focusSpy = jest.spyOn(component as unknown as FocusType, "focusDate"); + }); + + function trigger(key: string) { + const evt = new KeyboardEvent("keydown", { key }); + component.onDayKeydown(evt, today); + } + + it("ArrowLeft should move date -1 day", () => { + trigger("ArrowLeft"); + const expected = new Date(2024, 4, 14).getTime(); + expect(focusSpy).toHaveBeenCalledWith(new Date(expected)); + }); + + it("ArrowRight should move date +1 day", () => { + trigger("ArrowRight"); + expect(focusSpy).toHaveBeenCalledWith(new Date(2024, 4, 16)); + }); + + it("ArrowUp should move date -7 days", () => { + trigger("ArrowUp"); + expect(focusSpy).toHaveBeenCalledWith(new Date(2024, 4, 8)); + }); + + it("ArrowDown should move date +7 days", () => { + trigger("ArrowDown"); + expect(focusSpy).toHaveBeenCalledWith(new Date(2024, 4, 22)); + }); + + it("Home should jump to start of week", () => { + const weekday = (today.getDay() + 6) % 7; + const expected = new Date(today); + expected.setDate(today.getDate() - weekday); + + trigger("Home"); + expect(focusSpy).toHaveBeenCalledWith(expected); + }); + + it("End should jump to end of week", () => { + const weekday = (today.getDay() + 6) % 7; + const expected = new Date(today); + expected.setDate(today.getDate() + (6 - weekday)); + + trigger("End"); + expect(focusSpy).toHaveBeenCalledWith(expected); + }); + + it("PageUp should move one month back", () => { + trigger("PageUp"); + expect(focusSpy).toHaveBeenCalledWith(new Date(2024, 3, 15)); + }); + + it("PageDown should move one month forward", () => { + trigger("PageDown"); + expect(focusSpy).toHaveBeenCalledWith(new Date(2024, 5, 15)); + }); + + it("Enter selects the date", () => { + const selectSpy = jest.spyOn(component, "selectDay"); + + trigger("Enter"); + + expect(selectSpy).toHaveBeenCalledWith({ + date: today, + disabled: false, + inCurrentMonth: true, + }); + }); + + it("Space selects the date", () => { + const selectSpy = jest.spyOn(component, "selectDay"); + + trigger(" "); + + expect(selectSpy).toHaveBeenCalled(); + }); + + it("Default key should NOT call focusDate", () => { + focusSpy.mockClear(); + trigger("X"); + expect(focusSpy).not.toHaveBeenCalled(); + }); + }); + + describe("parseDate", () => { + function parse(str: string) { + return component["parseDate"](str); + } + + it("should return null for formats not split into 3 parts", () => { + expect(parse("")).toBeNull(); + expect(parse("12.05")).toBeNull(); + expect(parse("12-05-2024")).toBeNull(); + expect(parse("12/05/2024")).toBeNull(); + }); + + it("should return null when day, month, or year are not valid numbers", () => { + expect(parse("aa.bb.cccc")).toBeNull(); + expect(parse("1..2024")).toBeNull(); + expect(parse(".02.2024")).toBeNull(); + expect(parse("15.NaN.2024")).toBeNull(); + }); + + it("should return null for impossible dates after constructing Date", () => { + expect(parse("31.02.2024")).toBeNull(); + expect(parse("10.13.2024")).toBeNull(); + expect(parse("00.12.2024")).toBeNull(); + expect(parse("10.00.2024")).toBeNull(); + }); + + it("should return a valid Date for correct input", () => { + const result = parse("10.03.2024"); + expect(result).toEqual(new Date(2024, 2, 10)); + }); + }); + + describe("matches()", () => { + type PrivateMatchesAPI = { + matches: (m: unknown, date: Date) => boolean; + }; + + const matches = (m: unknown, date: Date) => + (component as unknown as PrivateMatchesAPI).matches(m, date); + + const ref = new Date(2024, 4, 15); + + it("should match when m is a Date (same day)", () => { + expect(matches(new Date(2024, 4, 15), ref)).toBe(true); + }); + + it("should NOT match when m is a Date (different day)", () => { + expect(matches(new Date(2024, 4, 16), ref)).toBe(false); + }); + + it("should match when m is an array containing matching date", () => { + expect(matches([new Date(2024, 4, 10), new Date(2024, 4, 15)], ref)).toBe( + true, + ); + }); + + it("should NOT match when array has no matching dates", () => { + expect(matches([new Date(2024, 4, 10), new Date(2024, 4, 20)], ref)).toBe( + false, + ); + }); + + it("should match when m is a function returning true", () => { + expect(matches((d: Date) => d.getDate() === 15, ref)).toBe(true); + }); + + it("should NOT match when m is a function returning false", () => { + expect(matches((d: Date) => d.getDate() === 1, ref)).toBe(false); + }); + + it("should match when m has 'before' and date < before", () => { + expect(matches({ before: new Date(2024, 5, 1) }, ref)).toBe(true); + }); + + it("should NOT match when m has 'before' and date >= before", () => { + expect(matches({ before: new Date(2024, 4, 15) }, ref)).toBe(false); + }); + + it("should match when m has 'after' and date > after", () => { + expect(matches({ after: new Date(2024, 3, 1) }, ref)).toBe(true); + }); + + it("should NOT match when m has 'after' and date <= after", () => { + expect(matches({ after: new Date(2024, 4, 15) }, ref)).toBe(false); + }); + + it("should match when m has 'from' and date >= from", () => { + expect(matches({ from: new Date(2024, 4, 1) }, ref)).toBe(true); + }); + + it("should NOT match when m has 'from' and date < from", () => { + expect(matches({ from: new Date(2024, 4, 20) }, ref)).toBe(false); + }); + + it("should match when m has 'from' and 'to' and date is in range", () => { + expect( + matches( + { from: new Date(2024, 4, 10), to: new Date(2024, 4, 20) }, + ref, + ), + ).toBe(true); + }); + + it("should NOT match when m has 'from' and 'to' and date is outside range", () => { + expect( + matches({ from: new Date(2024, 4, 1), to: new Date(2024, 4, 10) }, ref), + ).toBe(false); + }); + + it("should return false for unknown matcher shapes", () => { + expect(matches({ invalid: true }, ref)).toBe(false); + }); + }); + + describe("getFirstEnabledDayOfMonth()", () => { + const getFirst = (y: number, m: number) => + component["getFirstEnabledDayOfMonth"](y, m); + + it("should return the 1st of month when no disabled rules exist", () => { + fixture.componentRef.setInput("disabled", null); + fixture.detectChanges(); + + const result = getFirst(2024, 4); + expect(result).toEqual(new Date(2024, 4, 1)); + }); + + it("should return the first *enabled* day if early days are disabled", () => { + const disabledDates = [new Date(2024, 4, 1), new Date(2024, 4, 2)]; + fixture.componentRef.setInput("disabled", disabledDates); + fixture.detectChanges(); + + const result = getFirst(2024, 4); + + expect(result).toEqual(new Date(2024, 4, 3)); + }); + + it("should return null if ALL days of the month are disabled", () => { + const disabledAll = []; + + for (let d = 1; d <= 29; d++) { + disabledAll.push(new Date(2024, 1, d)); + } + + fixture.componentRef.setInput("disabled", disabledAll); + fixture.detectChanges(); + + const result = getFirst(2024, 1); + expect(result).toBeNull(); + }); + + it("should work with matcher objects (before rule disables all days)", () => { + fixture.componentRef.setInput("disabled", { + before: new Date(2030, 0, 1), + }); + fixture.detectChanges(); + + const result = getFirst(2024, 4); + expect(result).toBeNull(); + }); + + it("should work with matcher objects (after rule disables all days)", () => { + fixture.componentRef.setInput("disabled", { + after: new Date(2020, 0, 1), + }); + fixture.detectChanges(); + + const result = getFirst(2024, 4); + expect(result).toBeNull(); + }); + + it("should work with function matcher (disable weekends)", () => { + fixture.componentRef.setInput("disabled", (d: Date) => { + const dow = d.getDay(); + return dow === 0 || dow === 6; + }); + fixture.detectChanges(); + + const result = getFirst(2024, 1); + expect(result).toEqual(new Date(2024, 1, 1)); + }); + }); +}); diff --git a/tedi/components/form/date-picker/date-picker.component.ts b/tedi/components/form/date-picker/date-picker.component.ts new file mode 100644 index 000000000..5cb638ee8 --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.component.ts @@ -0,0 +1,653 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + computed, + model, + input, + inject, + signal, + OnInit, + viewChild, + ElementRef, +} from "@angular/core"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { ClosingButtonComponent } from "../../buttons/closing-button/closing-button.component"; +import { IconComponent } from "../../base/icon/icon.component"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { DropdownComponent } from "../../overlay/dropdown/dropdown.component"; +import { DropdownTriggerDirective } from "../../overlay/dropdown/dropdown-trigger/dropdown-trigger.directive"; +import { DropdownContentComponent } from "../../overlay/dropdown/dropdown-content/dropdown-content.component"; +import { DropdownItemComponent } from "../../overlay/dropdown/dropdown-item/dropdown-item.component"; +import { SeparatorComponent } from "../../helpers/separator/separator.component"; +import { PopoverComponent } from "../../overlay/popover/popover.component"; +import { PopoverTriggerComponent } from "../../overlay/popover/popover-trigger/popover-trigger.component"; +import { PopoverContentComponent } from "../../overlay/popover/popover-content/popover-content.component"; + +export interface DatePickerDay { + date: Date; + disabled: boolean; + inCurrentMonth: boolean; +} + +export type DatePickerInputState = "default" | "error" | "valid"; +export type DatePickerInputSize = "default" | "small"; +export type DatePickerSelectorMode = "none" | "label" | "grid" | "dropdown"; +export type DatePickerView = "month-grid" | "year-grid" | "calendar-grid"; + +export type DatePickerMatcher = + | Date + | Date[] + | { before: Date } + | { after: Date } + | { from: Date; to?: Date } + | ((date: Date) => boolean); + +let datePickerId = 0; + +@Component({ + standalone: true, + selector: "tedi-date-picker", + templateUrl: "./date-picker.component.html", + styleUrl: "./date-picker.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ButtonComponent, + IconComponent, + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ClosingButtonComponent, + SeparatorComponent, + PopoverComponent, + PopoverTriggerComponent, + PopoverContentComponent, + ], +}) +export class DatePickerComponent implements OnInit { + private readonly today = new Date(); + readonly uniqueId = `tedi-date-picker-id-${datePickerId++}`; + + /** Selected date */ + readonly selected = model(null); + + /** Currently shown month */ + readonly month = model(this.today); + + /** Disabled dates that cannot be selected. */ + readonly disabled = input( + null, + ); + + /** Shows or hides the calendar navigation controls (previous/next month buttons). */ + readonly showNavigation = input(true); + + /** Month selector mode: none | label | grid | dropdown */ + readonly monthMode = input("dropdown"); + + /** Year selector mode: none | label | grid | dropdown */ + readonly yearMode = input("dropdown"); + + /** Explicit starting year for the year dropdown list. If null, a dynamic fallback range (current year - 100) is used. */ + readonly startYear = input(null); + + /** Explicit ending year for the year dropdown list. If null, a dynamic fallback range (current year + 20) is used. */ + readonly endYear = input(null); + + /** Input id */ + readonly inputId = input(); + + /** Input placeholder */ + readonly inputPlaceholder = input(); + + /** Input state */ + readonly inputState = input("default"); + + /** Input size */ + readonly inputSize = input("default"); + + /** Is input disabled? */ + readonly inputDisabled = input(false); + + /** Is manual typing into input allowed? */ + readonly allowManualInput = input(true); + + /** Should show week numbers before calendar grid? */ + readonly showWeekNumbers = input(false); + + /** Current view of datepicker (months grid, years grid or calendar grid) */ + readonly currentView = signal("calendar-grid"); + + /** Shown input value */ + readonly inputValue = signal(""); + + /** Keyboard active date (what receives keyboard focus) */ + readonly activeDate = signal(null); + + private readonly YEARS_PER_PAGE = 12; + readonly yearPageIndex = signal(0); + readonly selectedYear = computed(() => this.month().getFullYear()); + + readonly years = computed(() => { + const current = this.today.getFullYear(); + + const start = this.startYear() ?? current - 100; + const end = this.endYear() ?? current + 20; + + const safeStart = Math.min(start, end); + const safeEnd = Math.max(start, end); + + return Array.from( + { length: safeEnd - safeStart + 1 }, + (_, i) => safeStart + i, + ); + }); + + readonly pagedYears = computed(() => { + const start = this.yearPageIndex() * this.YEARS_PER_PAGE; + return this.years().slice(start, start + this.YEARS_PER_PAGE); + }); + + readonly hasPrevYearPage = computed(() => { + return this.yearPageIndex() > 0; + }); + + readonly hasNextYearPage = computed(() => { + const all = this.years().length; + return (this.yearPageIndex() + 1) * this.YEARS_PER_PAGE < all; + }); + + readonly weekRows = computed(() => { + const cells = this.days(); + const rows: DatePickerDay[][] = []; + + for (let i = 0; i < cells.length; i += 7) { + rows.push(cells.slice(i, i + 7)); + } + + return rows; + }); + + readonly weekNumbers = computed(() => { + return this.weekRows().map((week) => this.getISOWeek(week[0].date)); + }); + + readonly canGoPrev = computed(() => { + const current = this.month(); + const year = current.getFullYear(); + const month = current.getMonth(); + + const prevMonth = month - 1; + const prevYear = prevMonth < 0 ? year - 1 : year; + const finalPrevMonth = (prevMonth + 12) % 12; + + return this.getFirstEnabledDayOfMonth(prevYear, finalPrevMonth) !== null; + }); + + readonly canGoNext = computed(() => { + const current = this.month(); + const year = current.getFullYear(); + const month = current.getMonth(); + + const nextMonth = month + 1; + const nextYear = nextMonth > 11 ? year + 1 : year; + const finalNextMonth = nextMonth % 12; + + return this.getFirstEnabledDayOfMonth(nextYear, finalNextMonth) !== null; + }); + + readonly days = computed(() => { + const month = this.month(); + const year = month.getFullYear(); + const monthIndex = month.getMonth(); + + const firstOfMonth = new Date(year, monthIndex, 1); + const daysInMonth = new Date(year, monthIndex + 1, 0).getDate(); + const firstWeekday = (firstOfMonth.getDay() + 6) % 7; + + const trailing = (7 - ((firstWeekday + daysInMonth) % 7)) % 7; + const cells: DatePickerDay[] = []; + + /** Previous month days */ + for (let i = 0; i < firstWeekday; i++) { + const date = new Date(year, monthIndex, i - firstWeekday + 1); + const disabled = this.isDisabled(date); + + cells.push({ + date, + inCurrentMonth: false, + disabled, + }); + } + + /** Current month days */ + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(year, monthIndex, day); + const disabled = this.isDisabled(date); + + cells.push({ + date, + inCurrentMonth: true, + disabled, + }); + } + + /** Next month days */ + const lastDate = new Date(year, monthIndex, daysInMonth); + + for (let i = 1; i <= trailing; i++) { + const date = new Date(lastDate); + date.setDate(lastDate.getDate() + i); + const disabled = this.isDisabled(date); + + cells.push({ + date, + inCurrentMonth: false, + disabled, + }); + } + + return cells; + }); + + readonly inputElement = + viewChild.required>("inputElement"); + readonly gridElement = + viewChild.required>("gridElement"); + readonly popover = viewChild.required(PopoverComponent); + + readonly translationService = inject(TediTranslationService); + + readonly weekDays = [ + this.translationService.track("date-picker.monday-short"), + this.translationService.track("date-picker.tuesday-short"), + this.translationService.track("date-picker.wednesday-short"), + this.translationService.track("date-picker.thursday-short"), + this.translationService.track("date-picker.friday-short"), + this.translationService.track("date-picker.saturday-short"), + this.translationService.track("date-picker.sunday-short"), + ]; + + readonly monthShortNames = [ + this.translationService.track("date-picker.january-short"), + this.translationService.track("date-picker.february-short"), + this.translationService.track("date-picker.march-short"), + this.translationService.track("date-picker.april-short"), + this.translationService.track("date-picker.may-short"), + this.translationService.track("date-picker.june-short"), + this.translationService.track("date-picker.july-short"), + this.translationService.track("date-picker.august-short"), + this.translationService.track("date-picker.september-short"), + this.translationService.track("date-picker.october-short"), + this.translationService.track("date-picker.november-short"), + this.translationService.track("date-picker.december-short"), + ]; + + readonly monthNames = [ + this.translationService.track("date-picker.january"), + this.translationService.track("date-picker.february"), + this.translationService.track("date-picker.march"), + this.translationService.track("date-picker.april"), + this.translationService.track("date-picker.may"), + this.translationService.track("date-picker.june"), + this.translationService.track("date-picker.july"), + this.translationService.track("date-picker.august"), + this.translationService.track("date-picker.september"), + this.translationService.track("date-picker.october"), + this.translationService.track("date-picker.november"), + this.translationService.track("date-picker.december"), + ]; + + ngOnInit(): void { + const selected = this.selected(); + this.inputValue.set(selected ? this.format(selected) : ""); + this.activeDate.set(selected ?? this.today); + } + + getTabIndex(date: Date): number { + const active = this.activeDate(); + return active && date.toDateString() === active.toDateString() ? 0 : -1; + } + + prevMonth() { + const date = new Date(this.month()); + date.setMonth(date.getMonth() - 1); + this.month.set(date); + } + + nextMonth() { + const date = new Date(this.month()); + date.setMonth(date.getMonth() + 1); + this.month.set(date); + } + + prevYearPage() { + if (this.hasPrevYearPage()) { + this.yearPageIndex.set(this.yearPageIndex() - 1); + } + } + + nextYearPage() { + if (this.hasNextYearPage()) { + this.yearPageIndex.set(this.yearPageIndex() + 1); + } + } + + selectDay(day: DatePickerDay) { + if (day.disabled) return; + + this.selected.set(day.date); + this.inputValue.set(this.format(day.date)); + } + + isSelected(date: Date): boolean { + return ( + !!this.selected() && + date.toDateString() === this.selected()!.toDateString() + ); + } + + isToday(date: Date): boolean { + return date.toDateString() === this.today.toDateString(); + } + + isDisabled(date: Date): boolean { + const rules = this.disabled(); + if (!rules) return false; + + const matchers = Array.isArray(rules) ? rules : [rules]; + return matchers.some((m) => this.matches(m, date)); + } + + onMonthClick() { + this.currentView.set("month-grid"); + } + + onMonthSelect(index?: string) { + if (!index) return; + + const updated = new Date(this.month()); + updated.setMonth(Number(index)); + this.month.set(updated); + + if (this.currentView() === "month-grid") { + this.currentView.set("calendar-grid"); + } + } + + onYearClick() { + const selected = this.month().getFullYear(); + const index = this.years().indexOf(selected); + this.yearPageIndex.set(Math.floor(index / this.YEARS_PER_PAGE)); + this.currentView.set("year-grid"); + } + + onYearSelect(index?: string) { + const updated = new Date(this.month()); + updated.setFullYear(Number(index)); + this.month.set(updated); + + if (this.currentView() === "year-grid") { + this.currentView.set("calendar-grid"); + } + } + + onDayKeydown(event: KeyboardEvent, current: Date) { + let target: Date | null = null; + + switch (event.key) { + case "ArrowLeft": + target = new Date(current); + target.setDate(current.getDate() - 1); + break; + + case "ArrowRight": + target = new Date(current); + target.setDate(current.getDate() + 1); + break; + + case "ArrowUp": + target = new Date(current); + target.setDate(current.getDate() - 7); + break; + + case "ArrowDown": + target = new Date(current); + target.setDate(current.getDate() + 7); + break; + + case "Home": + target = new Date(current); + target.setDate(current.getDate() - ((current.getDay() + 6) % 7)); + break; + + case "End": + target = new Date(current); + target.setDate(current.getDate() + (6 - ((current.getDay() + 6) % 7))); + break; + + case "PageUp": + target = new Date(current); + target.setMonth(current.getMonth() - 1); + break; + + case "PageDown": + target = new Date(current); + target.setMonth(current.getMonth() + 1); + break; + + case "Enter": + case " ": + event.preventDefault(); + this.selectDay({ + date: current, + disabled: false, + inCurrentMonth: true, + }); + return; + + case "Escape": + this.popover().floatUiComponent().hide(); + this.inputElement().nativeElement.focus(); + return; + + default: + return; + } + + if (target) { + event.preventDefault(); + this.focusDate(target); + } + } + + onCalendarKeyDown(event: KeyboardEvent) { + if (this.currentView() === "calendar-grid") return; + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + + this.currentView.set("calendar-grid"); + const active = this.selected() ?? this.today; + setTimeout(() => this.focusDate(active)); + } + } + + onInput(event: Event) { + if (!this.allowManualInput()) return; + + const value = (event.target as HTMLInputElement).value; + this.inputValue.set(value); + + if (value === "") { + this.selected.set(null); + } + } + + onInputBlur() { + if (!this.allowManualInput()) return; + + const selected = this.selected(); + const parsed = this.parseDate(this.inputValue()); + + if (parsed) { + this.selected.set(parsed); + this.month.set(parsed); + } else { + this.inputValue.set(selected ? this.format(selected) : ""); + } + } + + onInputClick() { + if (this.allowManualInput()) return; + + if (this.popover().floatUiComponent().state) { + this.popover().floatUiComponent().hide(); + this.inputElement().nativeElement.focus(); + } else { + this.popover().floatUiComponent().show(); + this.openCalendar(); + } + } + + clearInput() { + this.inputValue.set(""); + this.selected.set(null); + } + + openCalendar() { + const active = this.selected() ?? this.today; + this.activeDate.set(active); + setTimeout(() => this.focusDate(active)); + } + + private focusDate(date: Date) { + this.activeDate.set(date); + const currentMonth = this.month(); + + if ( + currentMonth.getFullYear() !== date.getFullYear() || + currentMonth.getMonth() !== date.getMonth() + ) { + this.month.set(new Date(date)); + } + + setTimeout(() => { + const container = this.gridElement().nativeElement; + if (!container) return; + + const key = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ).getTime(); + + const btn = container.querySelector( + `[data-date-key="${key}"]`, + ); + + if (btn && document.activeElement !== btn) { + btn.focus({ preventScroll: true }); + } + }); + } + + private parseDate(str: string): Date | null { + const parts = str.trim().split("."); + if (parts.length !== 3) return null; + + const [dd, mm, yyyy] = parts.map(Number); + if (!dd || !mm || !yyyy) return null; + + const date = new Date(yyyy, mm - 1, dd); + + if ( + date.getFullYear() !== yyyy || + date.getMonth() !== mm - 1 || + date.getDate() !== dd + ) { + return null; + } + + return date; + } + + private format(date: Date): string { + const d = String(date.getDate()).padStart(2, "0"); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const y = date.getFullYear(); + return `${d}.${m}.${y}`; + } + + private isSameDay(a: Date, b: Date): boolean { + return ( + a.getDate() === b.getDate() && + a.getMonth() === b.getMonth() && + a.getFullYear() === b.getFullYear() + ); + } + + private matches(m: DatePickerMatcher, date: Date): boolean { + if (m instanceof Date) { + return this.isSameDay(m, date); + } + + if (Array.isArray(m)) { + return m.some((d) => this.isSameDay(d, date)); + } + + if (typeof m === "function") { + return m(date); + } + + if ("before" in m) { + return date < m.before; + } + + if ("after" in m) { + return date > m.after; + } + + if ("from" in m) { + const { from, to } = m; + if (to) return date >= from && date <= to; + + return date >= from; + } + + return false; + } + + private getFirstEnabledDayOfMonth(year: number, month: number): Date | null { + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month, day); + + if (!this.isDisabled(date)) { + return date; + } + } + + return null; + } + + private getISOWeek(date: Date): number { + const target = new Date(date); + target.setHours(0, 0, 0, 0); + + const day = target.getDay(); + const isoDay = day === 0 ? 7 : day; + + target.setDate(target.getDate() + (4 - isoDay)); + const yearStart = new Date(target.getFullYear(), 0, 1); + + const diffInDays = Math.floor( + (target.getTime() - yearStart.getTime()) / 86400000, + ); + return Math.floor(diffInDays / 7) + 1; + } +} diff --git a/tedi/components/form/date-picker/date-picker.stories.ts b/tedi/components/form/date-picker/date-picker.stories.ts new file mode 100644 index 000000000..60dab5f70 --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.stories.ts @@ -0,0 +1,217 @@ +import { + type Meta, + type StoryObj, + argsToTemplate, + moduleMetadata, +} from "@storybook/angular"; +import { DatePickerComponent } from "./date-picker.component"; + +/** + * Figma ↗
    + * Zeroheight ↗ + */ + +export default { + title: "TEDI-Ready/Components/Form/DatePicker", + component: DatePickerComponent, + decorators: [ + moduleMetadata({ + imports: [DatePickerComponent], + }), + ], + argTypes: { + selected: { + description: "Selected date", + control: { type: "date" }, + table: { + category: "inputs", + type: { summary: "Date | null" }, + defaultValue: { summary: "null" }, + }, + }, + month: { + description: "Currently shown month", + control: { type: "date" }, + table: { + category: "inputs", + type: { summary: "Date | null" }, + defaultValue: { summary: "new Date()" }, + }, + }, + disabled: { + description: " Disabled dates that cannot be selected.", + control: { type: "object" }, + table: { + category: "inputs", + type: { + summary: "DatePickerMatcher | DatePickerMatcher[] | null", + detail: `Date \n| Date[] \n| { before: Date } \n| { after: Date } \n| { from: Date; to?: Date } \n| ((date: Date) => boolean) + `, + }, + defaultValue: { summary: "null" }, + }, + }, + showControls: { + description: + "Shows or hides the calendar navigation controls (previous/next month buttons).", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + monthMode: { + description: "Month selector mode: none | label | grid | dropdown", + control: "radio", + options: ["none", "label", "grid", "dropdown"], + table: { + category: "inputs", + type: { + summary: "DatePickerSelectorMode", + detail: "none | label | grid | dropdown", + }, + defaultValue: { summary: "dropdown" }, + }, + }, + yearMode: { + description: "Year selector mode: none | label | grid | dropdown", + control: "radio", + options: ["none", "label", "grid", "dropdown"], + table: { + category: "inputs", + type: { + summary: "DatePickerSelectorMode", + detail: "none | label | grid | dropdown", + }, + defaultValue: { summary: "dropdown" }, + }, + }, + startYear: { + description: + "Explicit starting year for the year dropdown list. If null, a dynamic fallback range (current year - 100) is used.", + control: { type: "number" }, + table: { + category: "inputs", + type: { summary: "number | null" }, + defaultValue: { summary: "null" }, + }, + }, + endYear: { + description: + "Explicit ending year for the year dropdown list. If null, a dynamic fallback range (current year + 20) is used.", + control: { type: "number" }, + table: { + category: "inputs", + type: { summary: "number | null" }, + defaultValue: { summary: "null" }, + }, + }, + inputId: { + description: "Input id", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + inputPlaceholder: { + description: "Input placeholder", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + inputState: { + control: "radio", + options: ["default", "error", "valid"], + description: "Input state", + table: { + category: "inputs", + type: { + summary: "DatePickerInputState", + detail: `"default" | "error" | "valid"`, + }, + defaultValue: { summary: "default" }, + }, + }, + inputSize: { + control: "radio", + options: ["default", "small"], + description: "Input size", + table: { + category: "inputs", + type: { summary: "DatePickerInputSize", detail: `"default" | "small"` }, + defaultValue: { summary: "default" }, + }, + }, + inputDisabled: { + description: "Is input disabled?", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + allowManualInput: { + description: "Is manual typing into input allowed?", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + showWeekNumbers: { + description: "Should show week numbers before calendar grid?", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + }, +} as Meta; + +const today = new Date(); +const inTwoDays = new Date(today); +inTwoDays.setDate(today.getDate() + 2); + +export const Default: StoryObj = { + args: (() => { + const today = new Date(); + const next = new Date(today); + next.setDate(today.getDate() + 1); + + return { + selected: next, + month: today, + showControls: true, + monthMode: "dropdown", + yearMode: "dropdown", + disabled: null, + startYear: null, + endYear: null, + inputId: "date-picker-id-1", + inputPlaceholder: "Enter date...", + inputState: "default", + inputSize: "default", + inputDisabled: false, + allowManualInput: true, + showWeekNumbers: false, + }; + })(), + render: (args) => ({ + props: { + ...args, + selected: args.selected ? new Date(args.selected) : null, + month: args.month ? new Date(args.month) : null, + }, + template: ` + + `, + }), +}; diff --git a/tedi/components/form/index.ts b/tedi/components/form/index.ts index e8ac0177e..9f93af829 100644 --- a/tedi/components/form/index.ts +++ b/tedi/components/form/index.ts @@ -1,4 +1,5 @@ export * from "./checkbox/checkbox.component"; +export * from "./date-picker/date-picker.component"; export * from "./feedback-text/feedback-text.component"; export * from "./label/label.component"; export * from "./number-field/number-field.component"; diff --git a/tedi/components/layout/header/header-language/header-language.component.ts b/tedi/components/layout/header/header-language/header-language.component.ts index 3103b7ff7..bb3ac9666 100644 --- a/tedi/components/layout/header/header-language/header-language.component.ts +++ b/tedi/components/layout/header/header-language/header-language.component.ts @@ -1,35 +1,56 @@ -import { NgFor } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, forwardRef, inject, input, output, ViewChild, ViewEncapsulation } from '@angular/core'; -import { IconComponent } from '../../../base/icon/icon.component'; -import { TextComponent } from '../../../base/text/text.component'; -import { PopoverComponent } from '../../../overlay/popover/popover.component'; -import { PopoverTriggerComponent } from '../../../overlay/popover/popover-trigger/popover-trigger.component'; -import { PopoverContentComponent } from '../../../overlay/popover/popover-content/popover-content.component'; -import { TediTranslationService, Language } from '../../../../services/translation/translation.service'; -import { TediTranslationPipe } from '../../../../services/translation/translation.pipe'; +import { NgFor } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + computed, + forwardRef, + inject, + input, + output, + ViewChild, + ViewEncapsulation, +} from "@angular/core"; +import { IconComponent } from "../../../base/icon/icon.component"; +import { TextComponent } from "../../../base/text/text.component"; +import { PopoverComponent } from "../../../overlay/popover/popover.component"; +import { PopoverTriggerComponent } from "../../../overlay/popover/popover-trigger/popover-trigger.component"; +import { PopoverContentComponent } from "../../../overlay/popover/popover-content/popover-content.component"; +import { + TediTranslationService, + Language, +} from "../../../../services/translation/translation.service"; +import { TediTranslationPipe } from "../../../../services/translation/translation.pipe"; export type HeaderLanguage = { [L in Language]?: string; -} +}; @Component({ - selector: 'tedi-header-language', + selector: "tedi-header-language", standalone: true, - imports: [NgFor, IconComponent, TextComponent, PopoverComponent, PopoverTriggerComponent, PopoverContentComponent, TediTranslationPipe], - templateUrl: './header-language.component.html', - styleUrl: './header-language.component.scss', + imports: [ + NgFor, + IconComponent, + TextComponent, + PopoverComponent, + PopoverTriggerComponent, + PopoverContentComponent, + TediTranslationPipe, + ], + templateUrl: "./header-language.component.html", + styleUrl: "./header-language.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: "tedi-header-language" - } + class: "tedi-header-language", + }, }) export class HeaderLanguageComponent { - /** + /** * Languages object. * Key is value in 'Language' type. * Value should be text shown in the UI. - */ + */ languages = input.required(); /** * This is event emitter for changing language @@ -45,6 +66,6 @@ export class HeaderLanguageComponent { handleChangeLang(lang: Language) { this.languageChange.emit(lang); this.translationService.setLanguage(lang); - this.popover?.floatUiComponent.hide(); + this.popover?.floatUiComponent().hide(); } } diff --git a/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.html b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.html new file mode 100644 index 000000000..0047ed9be --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.html @@ -0,0 +1,4 @@ + +
      + +
    diff --git a/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss new file mode 100644 index 000000000..abfd74efe --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss @@ -0,0 +1,14 @@ +tedi-dropdown-content { + width: max-content; + min-width: var(--_tedi-dropdown-trigger-width); + display: flex; + flex-direction: column; + border-radius: var(--form-select-area-radius); + border: 1px solid var(--card-border-primary); + box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2); + overflow: auto; + + ul { + margin: 0; + } +} diff --git a/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts new file mode 100644 index 000000000..0d38ebf70 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectionStrategy, + Component, + contentChildren, + inject, + input, + ViewEncapsulation, +} from "@angular/core"; +import { DropdownItemComponent } from "../dropdown-item/dropdown-item.component"; +import { DropdownComponent } from "../dropdown.component"; + +export type DropdownRole = "menu" | "listbox"; + +@Component({ + selector: "tedi-dropdown-content", + standalone: true, + templateUrl: "./dropdown-content.component.html", + styleUrl: "./dropdown-content.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + role: "presentation", + "[attr.aria-labelledby]": "dropdown.containerId() + '_trigger'", + }, +}) +export class DropdownContentComponent { + /** + * Role for content, use listbox for list and menu for actions + * @default menu + */ + readonly dropdownRole = input("menu"); + + readonly dropdown = inject(DropdownComponent); + readonly items = contentChildren(DropdownItemComponent); +} diff --git a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss new file mode 100644 index 000000000..78e442d87 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss @@ -0,0 +1,37 @@ +li[tedi-dropdown-item] { + width: 100%; + min-height: 40px; + display: flex; + align-items: center; + gap: var(--dropdown-item-inner-spacing); + color: var(--dropdown-item-default-text); + background: var(--dropdown-item-default-background); + padding: var(--dropdown-item-padding-y, 8px) var(--dropdown-item-padding-x); + cursor: pointer; + + &:hover { + color: var(--dropdown-item-hover-text); + background: var(--dropdown-item-hover-background); + } + + &:focus-visible { + outline: var(--borders-02) solid var(--primary-500); + outline-offset: calc(-1 * var(--borders-02)); + } + + &[aria-selected="true"] { + color: var(--dropdown-item-active-text); + background: var(--dropdown-item-active-background); + + &:focus-visible { + color: var(--dropdown-item-default-text); + background: var(--dropdown-item-default-background); + } + } + + &[aria-disabled="true"] { + color: var(--general-text-disabled); + background: var(--dropdown-item-disabled-background); + cursor: not-allowed; + } +} diff --git a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts new file mode 100644 index 000000000..13099b8d9 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts @@ -0,0 +1,108 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostListener, + inject, + input, + ViewEncapsulation, +} from "@angular/core"; +import { DropdownComponent } from "../dropdown.component"; +import { DropdownContentComponent } from "../dropdown-content/dropdown-content.component"; + +@Component({ + selector: "li[tedi-dropdown-item]", + standalone: true, + template: "", + styleUrl: "./dropdown-item.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[attr.role]": + "dropdownContent.dropdownRole() === 'menu' ? 'menuitem' : 'option'", + "[attr.aria-selected]": + "dropdownContent.dropdownRole() === 'listbox' ? isSelected() : null", + "[attr.aria-disabled]": "disabled() ? 'true' : null", + "[tabindex]": + "dropdownContent.dropdownRole() === 'menu' ? '-1' : (disabled() ? null : '-1')", + }, +}) +export class DropdownItemComponent { + /** Item value */ + readonly value = input(); + + /** Is item disabled? */ + readonly disabled = input(false); + + readonly host = inject>(ElementRef); + readonly dropdown = inject(DropdownComponent); + readonly dropdownContent = inject(DropdownContentComponent); + + isSelected() { + return this.dropdown.value() === this.value(); + } + + focus() { + this.host.nativeElement.focus(); + } + + @HostListener("click") + onClick() { + if (this.disabled()) return; + + this.onItemSelect(); + } + + @HostListener("keydown", ["$event"]) + onKeydown(event: KeyboardEvent) { + const key = event.key; + + if (this.disabled()) { + event.preventDefault(); + return; + } + + switch (key) { + case "ArrowDown": + event.preventDefault(); + this.dropdown.focusNextItem(this.host.nativeElement); + break; + + case "ArrowUp": + event.preventDefault(); + this.dropdown.focusPrevItem(this.host.nativeElement); + break; + + case "Home": + event.preventDefault(); + this.dropdown.focusFirstItem(); + break; + + case "End": + event.preventDefault(); + this.dropdown.focusLastItem(); + break; + + case "Enter": + case " ": + event.preventDefault(); + this.onItemSelect(); + break; + + case "Escape": + event.preventDefault(); + this.dropdown.hideDropdown(); + this.dropdown.dropdownTrigger()?.host.nativeElement.focus(); + break; + } + } + + private onItemSelect() { + if (this.dropdownContent.dropdownRole() === "listbox") { + this.dropdown.value.set(this.value()); + } + + this.dropdown.hideDropdown(); + this.dropdown.dropdownTrigger()?.host.nativeElement.focus(); + } +} diff --git a/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.component.scss b/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.component.scss new file mode 100644 index 000000000..3d25474ce --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.component.scss @@ -0,0 +1,3 @@ +tedi-dropdown-trigger { + display: inline-flex; +} diff --git a/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts b/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts new file mode 100644 index 000000000..4a60b32ea --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts @@ -0,0 +1,76 @@ +import { + Directive, + ElementRef, + HostListener, + inject, + input, +} from "@angular/core"; +import { DropdownComponent } from "../dropdown.component"; + +export type DropdownTriggerAriaHasPopup = "menu" | "listbox" | "true"; + +@Directive({ + standalone: true, + selector: "[tedi-dropdown-trigger]", + host: { + "[attr.id]": "dropdown.containerId() + '_trigger'", + "[attr.aria-controls]": "dropdown.containerId()", + "[attr.aria-expanded]": "dropdown.floatUiComponent().state", + "[attr.aria-haspopup]": "'menu'", + "[attr.role]": "isButton ? null : 'button'", + "[attr.tabindex]": "isButton ? null : '0'", + }, +}) +export class DropdownTriggerDirective { + /** Defines the aria-haspopup attribute for the trigger, informing assistive technologies whether it opens a menu or listbox. Improves accessibility by describing the type of popup. */ + readonly ariaHaspopup = input("menu"); + + readonly host = inject>(ElementRef); + readonly dropdown = inject(DropdownComponent); + + get isButton(): boolean { + return this.host.nativeElement.tagName === "BUTTON"; + } + + @HostListener("click") + onClick() { + this.dropdown.toggleDropdown(); + } + + @HostListener("keydown", ["$event"]) + onKeydown(event: KeyboardEvent) { + const key = event.key; + + switch (key) { + case "ArrowDown": + event.preventDefault(); + this.openAndFocusFirst(); + break; + + case "ArrowUp": + event.preventDefault(); + this.openAndFocusLast(); + break; + + case "Escape": + event.preventDefault(); + this.dropdown.hideDropdown(); + this.host.nativeElement.focus(); + break; + } + } + + private openAndFocusFirst() { + if (!this.dropdown.floatUiComponent().state) { + this.dropdown.showDropdown(); + } + this.dropdown.focusFirstItem?.(); + } + + private openAndFocusLast() { + if (!this.dropdown.floatUiComponent().state) { + this.dropdown.showDropdown(); + } + this.dropdown.focusLastItem?.(); + } +} diff --git a/tedi/components/overlay/dropdown/dropdown.component.html b/tedi/components/overlay/dropdown/dropdown.component.html new file mode 100644 index 000000000..1f69387c0 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.component.html @@ -0,0 +1,18 @@ +
    + +
    + + + diff --git a/tedi/components/overlay/dropdown/dropdown.component.scss b/tedi/components/overlay/dropdown/dropdown.component.scss new file mode 100644 index 000000000..751e96195 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.component.scss @@ -0,0 +1,13 @@ +tedi-dropdown { + display: inline-flex; +} + +float-ui-content { + .float-ui-container-dropdown { + padding: 0; + border: 0; + border-radius: var(--dropdown-item-radius); + box-shadow: 0px 1px 5px 0px var(--alpha-20, rgba(0, 0, 0, 0.2)); + z-index: var(--z-index-dropdown); + } +} diff --git a/tedi/components/overlay/dropdown/dropdown.component.spec.ts b/tedi/components/overlay/dropdown/dropdown.component.spec.ts new file mode 100644 index 000000000..09a415582 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.component.spec.ts @@ -0,0 +1,566 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { DropdownComponent } from "./dropdown.component"; +import { DropdownTriggerDirective } from "./dropdown-trigger/dropdown-trigger.directive"; +import { DropdownContentComponent } from "./dropdown-content/dropdown-content.component"; +import { DropdownItemComponent } from "./dropdown-item/dropdown-item.component"; +import { NgxFloatUiContentComponent } from "ngx-float-ui"; + +@Component({ + standalone: true, + template: ` + + + + +
  • Item A
  • +
  • Item B
  • +
  • + Item C (disabled) +
  • +
    +
    + `, + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ], +}) +class TestHostComponent { + value = "b"; + role: "menu" | "listbox" = "listbox"; +} + +describe("DropdownComponent", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let hostEl: HTMLElement; + let dropdown: DropdownComponent; + let floatUi: NgxFloatUiContentComponent; + + beforeAll(() => { + (Element.prototype as any).scrollIntoView = jest.fn(); + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + hostEl = fixture.nativeElement; + + fixture.detectChanges(); + + const dropdownDebug = fixture.debugElement.query( + By.directive(DropdownComponent), + ); + dropdown = dropdownDebug.componentInstance as DropdownComponent; + + floatUi = dropdown.floatUiComponent() as NgxFloatUiContentComponent; + }); + + const getTrigger = () => + hostEl.querySelector("[tedi-dropdown-trigger]") as HTMLButtonElement; + + const getItems = () => + Array.from( + hostEl.querySelectorAll("li[tedi-dropdown-item]"), + ) as HTMLLIElement[]; + + it("should create host & dropdown", () => { + expect(host).toBeTruthy(); + expect(dropdown).toBeTruthy(); + }); + + it("showDropdown() should open dropdown, set display to block and set active item", () => { + (floatUi as any).state = false; + const showSpy = jest.spyOn(floatUi, "show"); + + dropdown.showDropdown(); + fixture.detectChanges(); + + expect(showSpy).toHaveBeenCalled(); + expect(dropdown.floatUiDisplay()).toBe("block"); + + const items = getItems(); + const activeItem = items.find((li) => li.getAttribute("tabindex") === "0"); + expect(activeItem).toBeDefined(); + expect(activeItem?.textContent).toContain("Item B"); + }); + + it("hideDropdown() should close dropdown, reset display and tabindices", () => { + (floatUi as any).state = true; + const hideSpy = jest.spyOn(floatUi, "hide"); + + dropdown.hideDropdown(); + fixture.detectChanges(); + + expect(hideSpy).toHaveBeenCalled(); + expect(dropdown.floatUiDisplay()).toBe("inline"); + + const items = getItems(); + const role = dropdown.dropdownContent().dropdownRole(); + items.forEach((li, index) => { + const disabled = index === 2; + const tabindex = li.getAttribute("tabindex"); + + if (role === "listbox" && disabled) { + expect(tabindex).toBeNull(); + } else { + expect(tabindex).toBe("-1"); + } + }); + }); + + it("toggleDropdown() should open when closed and close when open", () => { + (floatUi as any).state = false; + const showSpy = jest.spyOn(floatUi, "show"); + const hideSpy = jest.spyOn(floatUi, "hide"); + + dropdown.toggleDropdown(); + fixture.detectChanges(); + expect(showSpy).toHaveBeenCalled(); + + (floatUi as any).state = true; + dropdown.toggleDropdown(); + fixture.detectChanges(); + expect(hideSpy).toHaveBeenCalled(); + }); + + it("focusFirstItem() should focus first enabled item", () => { + dropdown.focusFirstItem(); + fixture.detectChanges(); + + const items = getItems(); + const first = items[0]; + + expect(document.activeElement).toBe(first); + expect(first.getAttribute("tabindex")).toBe("0"); + }); + + it("focusLastItem() should focus last enabled item (skipping disabled)", () => { + dropdown.focusLastItem(); + fixture.detectChanges(); + + const items = getItems(); + const expected = items[1]; + + expect(document.activeElement).toBe(expected); + expect(expected.getAttribute("tabindex")).toBe("0"); + }); + + it("focusNextItem() should move focus to next enabled item", () => { + const items = getItems(); + + dropdown.focusNextItem(items[0]); + fixture.detectChanges(); + expect(document.activeElement).toBe(items[1]); + }); + + it("focusPrevItem() should move focus to previous enabled item", () => { + const items = getItems(); + + dropdown.focusPrevItem(items[1]); + fixture.detectChanges(); + expect(document.activeElement).toBe(items[0]); + }); + + it("DropdownTrigger: ArrowDown should open dropdown and focus first item", () => { + const trigger = getTrigger(); + + const event = new KeyboardEvent("keydown", { key: "ArrowDown" }); + trigger.dispatchEvent(event); + fixture.detectChanges(); + + const items = getItems(); + const first = items[0]; + expect(document.activeElement).toBe(first); + }); + + it("DropdownTrigger: Escape should hide dropdown and keep focus on trigger", () => { + (floatUi as any).state = true; + const hideSpy = jest.spyOn(floatUi, "hide"); + const trigger = getTrigger(); + const focusSpy = jest.spyOn(trigger, "focus"); + + const event = new KeyboardEvent("keydown", { key: "Escape" }); + trigger.dispatchEvent(event); + fixture.detectChanges(); + + expect(hideSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("DropdownItem: Enter selects value and hides dropdown in listbox mode", () => { + host.role = "listbox"; + fixture.detectChanges(); + + const items = getItems(); + const second = items[1]; + + (floatUi as any).state = true; + const hideSpy = jest.spyOn(floatUi, "hide"); + + const event = new KeyboardEvent("keydown", { key: "Enter" }); + second.dispatchEvent(event); + fixture.detectChanges(); + + expect(dropdown.value()).toBe("b"); + expect(hideSpy).toHaveBeenCalled(); + }); + + it("DropdownItem: disabled item should ignore click and keyboard", () => { + const items = getItems(); + const disabledItem = items[2]; + + const hideSpy = jest.spyOn(floatUi, "hide"); + + disabledItem.click(); + const event = new KeyboardEvent("keydown", { key: "Enter" }); + disabledItem.dispatchEvent(event); + fixture.detectChanges(); + + expect(dropdown.value()).toBe("b"); + expect(hideSpy).not.toHaveBeenCalled(); + }); + + describe("handleOutsideClick()", () => { + it("should return early when dropdown is closed (state=false)", () => { + (floatUi as any).state = false; + + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + const triggerFocusSpy = jest.spyOn( + dropdown.dropdownTrigger()!.host.nativeElement, + "focus", + ); + + dropdown.handleOutsideClick(new Event("pointerdown")); + + expect(hideSpy).not.toHaveBeenCalled(); + expect(triggerFocusSpy).not.toHaveBeenCalled(); + }); + + it("should do nothing when click is inside the trigger element", () => { + (floatUi as any).state = true; + + const triggerEl = dropdown.dropdownTrigger()!.host.nativeElement; + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + const focusSpy = jest.spyOn(triggerEl, "focus"); + + const fakeEvent = { + target: triggerEl, + } as unknown as Event; + + dropdown.handleOutsideClick(fakeEvent); + + expect(hideSpy).not.toHaveBeenCalled(); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + it("should do nothing when click is inside the content element", () => { + (floatUi as any).state = true; + + const contentEl = dropdown.floatUiComponent().elRef.nativeElement; + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + const focusSpy = jest.spyOn( + dropdown.dropdownTrigger()!.host.nativeElement, + "focus", + ); + + const fakeEvent = { + target: contentEl, + } as unknown as Event; + + dropdown.handleOutsideClick(fakeEvent); + + expect(hideSpy).not.toHaveBeenCalled(); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + it("should hide dropdown and focus trigger when clicking outside", () => { + (floatUi as any).state = true; + + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + + const triggerEl = dropdown.dropdownTrigger()!.host.nativeElement; + const focusSpy = jest.spyOn(triggerEl, "focus"); + + const outsideTarget = document.createElement("div"); + + const fakeEvent = { + target: outsideTarget, + } as unknown as Event; + + dropdown.handleOutsideClick(fakeEvent); + + expect(hideSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + }); + + describe("setActiveToSelectedOrFirst()", () => { + it("should activate the selected item when it exists and is enabled", () => { + host.value = "b"; + fixture.detectChanges(); + + const updateSpy = jest.spyOn(dropdown, "updateTabindexes"); + + dropdown.setActiveToSelectedOrFirst(); + fixture.detectChanges(); + + expect((dropdown as any).activeIndex()).toBe(1); + expect(updateSpy).toHaveBeenCalled(); + }); + + it("should fall back to first enabled item when selected item is disabled", () => { + host.value = "c"; + fixture.detectChanges(); + + const updateSpy = jest.spyOn(dropdown, "updateTabindexes"); + + dropdown.setActiveToSelectedOrFirst(); + fixture.detectChanges(); + + expect((dropdown as any).activeIndex()).toBe(0); + expect(updateSpy).toHaveBeenCalled(); + }); + + it("should activate the first enabled item when selected value does not exist", () => { + host.value = "x"; + fixture.detectChanges(); + + dropdown.setActiveToSelectedOrFirst(); + fixture.detectChanges(); + + expect((dropdown as any).activeIndex()).toBe(0); + }); + }); + + describe("DropdownItemComponent (unit behaviors)", () => { + let items: HTMLLIElement[]; + let itemA: HTMLLIElement; + let itemB: HTMLLIElement; + let itemC: HTMLLIElement; + + beforeEach(() => { + items = getItems(); + itemA = items[0]; + itemB = items[1]; + itemC = items[2]; + }); + + it("onClick: enabled item should call onItemSelect()", () => { + (floatUi as any).state = true; + + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + const focusSpy = jest.spyOn(getTrigger(), "focus"); + const setSpy = jest.spyOn(dropdown.value, "set"); + + itemA.click(); + fixture.detectChanges(); + + expect(setSpy).toHaveBeenCalledWith("a"); + expect(hideSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("onClick: disabled item should NOT select or hide dropdown", () => { + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + const setSpy = jest.spyOn(dropdown.value, "set"); + + itemC.click(); + fixture.detectChanges(); + + expect(setSpy).not.toHaveBeenCalled(); + expect(hideSpy).not.toHaveBeenCalled(); + }); + + it("keydown: ArrowDown should call dropdown.focusNextItem()", () => { + const spy = jest.spyOn(dropdown, "focusNextItem"); + + itemA.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith(itemA); + }); + + it("keydown: ArrowUp should call dropdown.focusPrevItem()", () => { + const spy = jest.spyOn(dropdown, "focusPrevItem"); + + itemB.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith(itemB); + }); + + it("keydown: Home should call dropdown.focusFirstItem()", () => { + const spy = jest.spyOn(dropdown, "focusFirstItem"); + + itemB.dispatchEvent(new KeyboardEvent("keydown", { key: "Home" })); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalled(); + }); + + it("keydown: End should call dropdown.focusLastItem()", () => { + const spy = jest.spyOn(dropdown, "focusLastItem"); + + itemA.dispatchEvent(new KeyboardEvent("keydown", { key: "End" })); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalled(); + }); + + it("keydown: Enter should select the item & hide dropdown", () => { + (floatUi as any).state = true; + + const setSpy = jest.spyOn(dropdown.value, "set"); + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + const focusSpy = jest.spyOn(getTrigger(), "focus"); + + itemA.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + + expect(setSpy).toHaveBeenCalledWith("a"); + expect(hideSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("keydown: Space should select the item & hide dropdown", () => { + (floatUi as any).state = true; + + const setSpy = jest.spyOn(dropdown.value, "set"); + + itemB.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); + fixture.detectChanges(); + + expect(setSpy).toHaveBeenCalledWith("b"); + }); + + it("keydown: Escape should hide dropdown & return focus to trigger", () => { + (floatUi as any).state = true; + + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + const focusSpy = jest.spyOn(getTrigger(), "focus"); + + itemB.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + fixture.detectChanges(); + + expect(hideSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("keydown: disabled item should preventDefault and NOT process", () => { + const event = new KeyboardEvent("keydown", { key: "Enter" }); + const preventSpy = jest.spyOn(event, "preventDefault"); + + itemC.dispatchEvent(event); + fixture.detectChanges(); + + const setSpy = jest.spyOn(dropdown.value, "set"); + + expect(preventSpy).toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + }); + }); + + describe("DropdownTriggerDirective", () => { + let trigger: HTMLButtonElement; + + beforeEach(() => { + trigger = getTrigger(); + }); + + it("click should call dropdown.toggleDropdown()", () => { + const spy = jest.spyOn(dropdown, "toggleDropdown"); + + trigger.click(); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalled(); + }); + + it("ArrowDown should open dropdown (if closed) and focus first item", () => { + const showSpy = jest.spyOn(dropdown, "showDropdown"); + const focusSpy = jest.spyOn(dropdown, "focusFirstItem"); + + (floatUi as any).state = false; + + const event = new KeyboardEvent("keydown", { key: "ArrowDown" }); + trigger.dispatchEvent(event); + fixture.detectChanges(); + + expect(showSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("ArrowDown should only focus first item when dropdown already open", () => { + const showSpy = jest.spyOn(dropdown, "showDropdown"); + const focusSpy = jest.spyOn(dropdown, "focusFirstItem"); + + (floatUi as any).state = true; + + const event = new KeyboardEvent("keydown", { key: "ArrowDown" }); + trigger.dispatchEvent(event); + fixture.detectChanges(); + + expect(showSpy).not.toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("ArrowUp should open dropdown (if closed) and focus last item", () => { + const showSpy = jest.spyOn(dropdown, "showDropdown"); + const focusSpy = jest.spyOn(dropdown, "focusLastItem"); + + (floatUi as any).state = false; + + const event = new KeyboardEvent("keydown", { key: "ArrowUp" }); + trigger.dispatchEvent(event); + fixture.detectChanges(); + + expect(showSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("ArrowUp should only focus last item when dropdown already open", () => { + const showSpy = jest.spyOn(dropdown, "showDropdown"); + const focusSpy = jest.spyOn(dropdown, "focusLastItem"); + + (floatUi as any).state = true; + + const event = new KeyboardEvent("keydown", { key: "ArrowUp" }); + trigger.dispatchEvent(event); + fixture.detectChanges(); + + expect(showSpy).not.toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("Escape should hide dropdown and return focus to trigger", () => { + (floatUi as any).state = true; + + const hideSpy = jest.spyOn(dropdown, "hideDropdown"); + const focusSpy = jest.spyOn(trigger, "focus"); + + const event = new KeyboardEvent("keydown", { key: "Escape" }); + trigger.dispatchEvent(event); + fixture.detectChanges(); + + expect(hideSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("should set correct ARIA attributes", () => { + expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(trigger.getAttribute("role")).toBeNull(); + expect(trigger.getAttribute("tabindex")).toBeNull(); + }); + }); +}); diff --git a/tedi/components/overlay/dropdown/dropdown.component.ts b/tedi/components/overlay/dropdown/dropdown.component.ts new file mode 100644 index 000000000..ddbabf85e --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.component.ts @@ -0,0 +1,285 @@ +import { + Component, + input, + ViewEncapsulation, + ChangeDetectionStrategy, + viewChild, + contentChild, + signal, + AfterContentChecked, + OnDestroy, + inject, + PLATFORM_ID, + model, +} from "@angular/core"; +import { + NgxFloatUiContentComponent, + NgxFloatUiModule, + NgxFloatUiPlacements, +} from "ngx-float-ui"; +import { DropdownTriggerDirective } from "./dropdown-trigger/dropdown-trigger.directive"; +import { DropdownContentComponent } from "./dropdown-content/dropdown-content.component"; +import { isPlatformBrowser } from "@angular/common"; + +export type DropdownPosition = `${NgxFloatUiPlacements}`; + +@Component({ + standalone: true, + selector: "tedi-dropdown", + imports: [NgxFloatUiModule], + templateUrl: "./dropdown.component.html", + styleUrl: "./dropdown.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DropdownComponent implements AfterContentChecked, OnDestroy { + /** Current value of dropdown (used with listbox) */ + readonly value = model(); + + /** + * The position of the dropdown relative to the trigger element. + * @default bottom-start + */ + readonly position = input("bottom-start"); + + /** + * Should position to opposite direction when overflowing screen? + * @default true + */ + readonly preventOverflow = input(true); + + /** + * Append floating element to given selector. + * Use 'body' to append at the end of DOM or empty string to append next to trigger element. + * @default "" + */ + readonly appendTo = input(""); + + readonly dropdownTrigger = contentChild.required(DropdownTriggerDirective); + readonly dropdownContent = contentChild.required(DropdownContentComponent); + readonly floatUiComponent = viewChild.required(NgxFloatUiContentComponent); + + private readonly activeIndex = signal(null); + readonly containerId = signal(""); + readonly isContentHovered = signal(false); + readonly floatUiDisplay = signal<"inline" | "block">("inline"); + + private readonly platformId = inject(PLATFORM_ID); + + constructor() { + if (isPlatformBrowser(this.platformId)) { + document.addEventListener("pointerdown", this.handleOutsideClick, true); + } + } + + ngOnDestroy() { + if (isPlatformBrowser(this.platformId)) { + document.removeEventListener( + "pointerdown", + this.handleOutsideClick, + true, + ); + } + } + + ngAfterContentChecked(): void { + const floatUiEl = this.floatUiComponent().elRef + .nativeElement as HTMLElement; + const container = floatUiEl.querySelector( + ".float-ui-container", + ); + + if (container) { + container.setAttribute("tabindex", "-1"); + container.setAttribute("aria-labelledby", container.id + "_trigger"); + this.containerId.set(container.id); + } + } + + showDropdown() { + if (this.floatUiComponent().state) return; + + this.floatUiComponent().show(); + this.floatUiDisplay.set("block"); + this.setActiveToSelectedOrFirst(); + + const floatUiEl = this.floatUiComponent().elRef + .nativeElement as HTMLElement; + const triggerWidth = this.dropdownTrigger()?.host.nativeElement.offsetWidth; + + if (triggerWidth) { + floatUiEl.style.setProperty( + "--_tedi-dropdown-trigger-width", + `${triggerWidth}px`, + ); + } + + setTimeout(() => this.focusActiveItem()); + } + + hideDropdown() { + if (this.floatUiComponent().state) { + this.floatUiComponent().hide(); + this.floatUiDisplay.set("inline"); + this.activeIndex.set(null); + this.updateTabindexes(); + } + } + + toggleDropdown() { + if (this.floatUiComponent().state) { + this.hideDropdown(); + } else { + this.showDropdown(); + } + } + + handleOutsideClick = (event: Event) => { + if (!this.floatUiComponent().state) return; + + const target = event.target as HTMLElement; + + const triggerEl = this.dropdownTrigger().host.nativeElement; + const contentEl = this.floatUiComponent().elRef + .nativeElement as HTMLElement; + + const clickedInside = + triggerEl.contains(target) || contentEl.contains(target); + + if (!clickedInside) { + this.hideDropdown(); + triggerEl.focus(); + } + }; + + focusFirstItem() { + const items = this.dropdownContent().items(); + const index = items.findIndex((item) => !item.disabled()); + + if (index !== -1) { + this.activeIndex.set(index); + this.updateTabindexes(); + items[index].focus(); + } + } + + focusLastItem() { + const items = this.dropdownContent().items(); + + for (let i = items.length - 1; i >= 0; i--) { + if (!items[i].disabled()) { + this.activeIndex.set(i); + this.updateTabindexes(); + items[i].focus(); + return; + } + } + } + + setActiveToSelectedOrFirst() { + const items = this.dropdownContent().items(); + const selectedIndex = items.findIndex( + (item) => item.value() === this.value(), + ); + + if (selectedIndex !== -1 && !items[selectedIndex].disabled()) { + this.activeIndex.set(selectedIndex); + this.updateTabindexes(); + return; + } + + const index = items.findIndex((item) => !item.disabled()); + + if (index !== -1) { + this.activeIndex.set(index); + this.updateTabindexes(); + } + } + + focusActiveItem() { + const index = this.activeIndex(); + if (index == null) return; + + const items = this.dropdownContent().items(); + if (!items[index]) return; + + const el = items[index].host.nativeElement; + el.focus(); + el.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + } + + focusNextItem(fromEl: HTMLLIElement) { + const fromIndex = this.findIndexByElement(fromEl); + if (fromIndex === -1) return; + + const nextIndex = this.getNextEnabledIndex(fromIndex); + if (nextIndex == null) return; + + const items = this.dropdownContent().items(); + this.activeIndex.set(nextIndex); + this.updateTabindexes(); + items[nextIndex].focus(); + } + + focusPrevItem(fromEl: HTMLLIElement) { + const fromIndex = this.findIndexByElement(fromEl); + if (fromIndex === -1) return; + + const prevIndex = this.getPrevEnabledIndex(fromIndex); + if (prevIndex == null) return; + + const items = this.dropdownContent().items(); + this.activeIndex.set(prevIndex); + this.updateTabindexes(); + items[prevIndex].focus(); + } + + updateTabindexes() { + const items = this.dropdownContent().items(); + const role = this.dropdownContent().dropdownRole(); + const active = this.activeIndex(); + + items.forEach((item, i) => { + const el = item.host.nativeElement; + + if (i === active && !item.disabled()) { + el.setAttribute("tabindex", "0"); + } else { + if (role === "listbox" && item.disabled()) { + el.removeAttribute("tabindex"); + } else { + el.setAttribute("tabindex", "-1"); + } + } + }); + } + + private findIndexByElement(el: HTMLLIElement): number { + return this.dropdownContent() + .items() + .findIndex((item) => item.host.nativeElement === el); + } + + private getNextEnabledIndex(fromIndex: number): number | null { + const items = this.dropdownContent().items(); + + for (let i = fromIndex + 1; i < items.length; i++) { + if (!items[i].disabled()) return i; + } + + return null; + } + + private getPrevEnabledIndex(fromIndex: number): number | null { + const items = this.dropdownContent().items(); + + for (let i = fromIndex - 1; i >= 0; i--) { + if (!items[i].disabled()) return i; + } + + return null; + } +} diff --git a/tedi/components/overlay/dropdown/dropdown.stories.ts b/tedi/components/overlay/dropdown/dropdown.stories.ts new file mode 100644 index 000000000..985957a45 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.stories.ts @@ -0,0 +1,164 @@ +import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular"; +import { DropdownComponent, DropdownPosition } from "./dropdown.component"; +import { + DropdownTriggerAriaHasPopup, + DropdownTriggerDirective, +} from "./dropdown-trigger/dropdown-trigger.directive"; +import { + DropdownContentComponent, + DropdownRole, +} from "./dropdown-content/dropdown-content.component"; +import { DropdownItemComponent } from "./dropdown-item/dropdown-item.component"; +import { ButtonComponent } from "../../buttons/button/button.component"; + +const POSITIONS: DropdownPosition[] = [ + "auto", + "auto-start", + "auto-end", + "top", + "top-start", + "top-end", + "bottom", + "bottom-start", + "bottom-end", + "right", + "right-start", + "right-end", + "left", + "left-start", + "left-end", +]; + +/** + * Figma ↗
    + * Zeroheight ↗ + */ + +export default { + title: "TEDI-Ready/Components/Overlay/Dropdown", + component: DropdownComponent, + decorators: [ + moduleMetadata({ + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ButtonComponent, + ], + }), + ], + argTypes: { + value: { + control: "text", + description: "Current value of dropdown (used with listbox)", + table: { + category: "dropdown", + type: { summary: "string" }, + }, + }, + position: { + control: "select", + options: POSITIONS, + description: + "The position of the dropdown relative to the trigger element.", + table: { + category: "dropdown", + type: { summary: "DropdownPosition" }, + defaultValue: { summary: "bottom-start" }, + }, + }, + preventOverflow: { + control: "boolean", + description: + "Should position to opposite direction when overflowing screen?", + table: { + category: "dropdown", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + appendTo: { + control: "text", + description: + "Append floating element to given selector. Use 'body' to append at the end of DOM or empty string to append next to trigger element.", + table: { + category: "dropdown", + type: { summary: "string" }, + defaultValue: { summary: `""` }, + }, + }, + dropdownRole: { + control: "radio", + options: ["menu", "listbox"], + description: + "Role for content, use listbox for list and menu for actions", + table: { + category: "dropdown-content", + type: { summary: "DropdownRole", detail: "menu \nlistbox" }, + defaultValue: { summary: "menu" }, + }, + }, + ariaHasPopup: { + control: "radio", + options: ["menu", "listbox", "true"], + description: + "Defines the aria-haspopup attribute for the trigger, informing assistive technologies whether it opens a menu or listbox. Improves accessibility by describing the type of popup.", + table: { + category: "dropdown-trigger", + type: { + summary: "DropdownTriggerAriaHasPopup", + detail: "menu \nlistbox \ntrue", + }, + defaultValue: { summary: "menu" }, + }, + }, + itemValue: { + name: "value", + description: "Item value", + table: { + category: "dropdown-item", + type: { summary: "string" }, + }, + }, + disabled: { + description: "Is item disabled?", + table: { + category: "dropdown-item", + type: { summary: "boolean" }, + }, + }, + }, +} as Meta; + +type Story = StoryObj< + DropdownComponent & { + dropdownRole: DropdownRole; + ariaHasPopup: DropdownTriggerAriaHasPopup; + } +>; + +export const Default: Story = { + args: { + position: "bottom-start", + preventOverflow: true, + appendTo: "body", + dropdownRole: "menu", + ariaHasPopup: "menu", + }, + render: (args) => ({ + props: args, + template: ` + + + +
  • Access to health data
  • +
  • Declaration of intent
  • +
  • Contacts
  • +
    +
    + `, + }), +}; diff --git a/tedi/components/overlay/dropdown/index.ts b/tedi/components/overlay/dropdown/index.ts new file mode 100644 index 000000000..05c50ff74 --- /dev/null +++ b/tedi/components/overlay/dropdown/index.ts @@ -0,0 +1,4 @@ +export * from "./dropdown-content/dropdown-content.component"; +export * from "./dropdown-item/dropdown-item.component"; +export * from "./dropdown-trigger/dropdown-trigger.directive"; +export * from "./dropdown.component"; diff --git a/tedi/components/overlay/index.ts b/tedi/components/overlay/index.ts index 7b55370d8..012165183 100644 --- a/tedi/components/overlay/index.ts +++ b/tedi/components/overlay/index.ts @@ -1,3 +1,4 @@ +export * from "./dropdown"; export * from "./modal"; export * from "./tooltip"; export * from "./popover"; diff --git a/tedi/components/overlay/popover/popover-content/popover-content.component.spec.ts b/tedi/components/overlay/popover/popover-content/popover-content.component.spec.ts index 08004f46a..b9c66c193 100644 --- a/tedi/components/overlay/popover/popover-content/popover-content.component.spec.ts +++ b/tedi/components/overlay/popover/popover-content/popover-content.component.spec.ts @@ -1,8 +1,12 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, DebugElement } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { PopoverContentComponent, PopoverWidth } from './popover-content.component'; -import { PopoverComponent } from '../popover.component'; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component, DebugElement } from "@angular/core"; +import { By } from "@angular/platform-browser"; +import { + PopoverContentComponent, + PopoverWidth, +} from "./popover-content.component"; +import { PopoverComponent } from "../popover.component"; +import { NgxFloatUiContentComponent } from "ngx-float-ui"; @Component({ template: ` @@ -18,12 +22,12 @@ import { PopoverComponent } from '../popover.component'; imports: [PopoverContentComponent], }) class TestHostComponent { - width: PopoverWidth = 'small'; - title = ''; + width: PopoverWidth = "small"; + title = ""; showClose = false; } -describe('PopoverContentComponent', () => { +describe("PopoverContentComponent", () => { let fixture: ComponentFixture; let hostDE: DebugElement; let popoverMock: Partial; @@ -31,17 +35,17 @@ describe('PopoverContentComponent', () => { beforeEach(() => { hideSpy = jest.fn(); + popoverMock = { - floatUiComponent: { - hide: hideSpy, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, + floatUiComponent: (() => + ({ + hide: hideSpy, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as unknown as NgxFloatUiContentComponent) as any, }; TestBed.configureTestingModule({ - providers: [ - { provide: PopoverComponent, useValue: popoverMock }, - ], + providers: [{ provide: PopoverComponent, useValue: popoverMock }], imports: [TestHostComponent], }); @@ -50,93 +54,93 @@ describe('PopoverContentComponent', () => { fixture.detectChanges(); }); - it('should create component', () => { + it("should create component", () => { const comp = hostDE.componentInstance as PopoverContentComponent; expect(comp).toBeTruthy(); }); it('should have role="dialog" on host', () => { - expect(hostDE.attributes['role']).toBe('dialog'); + expect(hostDE.attributes["role"]).toBe("dialog"); }); - it('should apply default classes (small)', () => { - const classList = hostDE.nativeElement.className.split(' '); - expect(classList).toContain('tedi-popover-content'); - expect(classList).toContain('tedi-popover-content--small'); + it("should apply default classes (small)", () => { + const classList = hostDE.nativeElement.className.split(" "); + expect(classList).toContain("tedi-popover-content"); + expect(classList).toContain("tedi-popover-content--small"); }); - it('should update classes when maxWidth input changes', () => { - fixture.componentInstance.width = 'large'; + it("should update classes when maxWidth input changes", () => { + fixture.componentInstance.width = "large"; fixture.detectChanges(); const classes = hostDE.nativeElement.className; - expect(classes).toContain('tedi-popover-content--large'); - expect(classes).not.toContain('tedi-popover-content--small'); + expect(classes).toContain("tedi-popover-content--large"); + expect(classes).not.toContain("tedi-popover-content--small"); }); - describe('template branches', () => { - it('renders only projected content when no title & no close', () => { + describe("template branches", () => { + it("renders only projected content when no title & no close", () => { // default: title = '', showClose = false - const projected = hostDE.query(By.css('.projected')); + const projected = hostDE.query(By.css(".projected")); expect(projected).toBeTruthy(); // no

    or button - expect(hostDE.query(By.css('h4'))).toBeNull(); - expect(hostDE.query(By.css('button'))).toBeNull(); + expect(hostDE.query(By.css("h4"))).toBeNull(); + expect(hostDE.query(By.css("button"))).toBeNull(); }); - it('renders title only when title set, showClose = false', () => { - fixture.componentInstance.title = 'My Title'; + it("renders title only when title set, showClose = false", () => { + fixture.componentInstance.title = "My Title"; fixture.componentInstance.showClose = false; fixture.detectChanges(); - const h4 = hostDE.query(By.css('h4')); + const h4 = hostDE.query(By.css("h4")); expect(h4).toBeTruthy(); - expect(h4.nativeElement.textContent).toBe('My Title'); + expect(h4.nativeElement.textContent).toBe("My Title"); // aria-labelledby should match id - const id = h4.attributes['id']; - expect(hostDE.attributes['aria-labelledby']).toBe(id); + const id = h4.attributes["id"]; + expect(hostDE.attributes["aria-labelledby"]).toBe(id); // no button - expect(hostDE.query(By.css('button'))).toBeNull(); + expect(hostDE.query(By.css("button"))).toBeNull(); }); - it('renders close only when showClose = true & no title', () => { - fixture.componentInstance.title = ''; + it("renders close only when showClose = true & no title", () => { + fixture.componentInstance.title = ""; fixture.componentInstance.showClose = true; fixture.detectChanges(); - const head = hostDE.query(By.css('.tedi-popover-content__head')); + const head = hostDE.query(By.css(".tedi-popover-content__head")); expect(head).toBeTruthy(); // projected inside a div - expect(head.query(By.css('.projected'))).toBeTruthy(); - const btn = head.query(By.css('button')); + expect(head.query(By.css(".projected"))).toBeTruthy(); + const btn = head.query(By.css("button")); expect(btn).toBeTruthy(); // closing-button default size is small - expect(btn.attributes['size']).toBe('small'); + expect(btn.attributes["size"]).toBe("small"); }); - it('renders title + close when both set', () => { - fixture.componentInstance.title = 'T'; + it("renders title + close when both set", () => { + fixture.componentInstance.title = "T"; fixture.componentInstance.showClose = true; fixture.detectChanges(); - const head = hostDE.query(By.css('.tedi-popover-content__head')); + const head = hostDE.query(By.css(".tedi-popover-content__head")); expect(head).toBeTruthy(); - const h4 = head.query(By.css('h4')); - expect(h4.nativeElement.textContent).toBe('T'); - const btn = head.query(By.css('button')); + const h4 = head.query(By.css("h4")); + expect(h4.nativeElement.textContent).toBe("T"); + const btn = head.query(By.css("button")); expect(btn).toBeTruthy(); // when title present, button has no size attr - expect(btn.attributes['size']).toBeUndefined(); + expect(btn.attributes["size"]).toBeUndefined(); }); }); - it('should call popover.floatUiComponent.hide() on close click', () => { - // set up a branch with a button + it("should call popover.floatUiComponent.hide() on close click", () => { fixture.componentInstance.showClose = true; fixture.detectChanges(); - const btnDe = hostDE.query(By.css('button')); - btnDe.triggerEventHandler('click', {}); + const btnDe = hostDE.query(By.css("button")); + btnDe.triggerEventHandler("click", {}); + expect(hideSpy).toHaveBeenCalled(); }); }); diff --git a/tedi/components/overlay/popover/popover-content/popover-content.component.ts b/tedi/components/overlay/popover/popover-content/popover-content.component.ts index 65b81524b..35b004e38 100644 --- a/tedi/components/overlay/popover/popover-content/popover-content.component.ts +++ b/tedi/components/overlay/popover/popover-content/popover-content.component.ts @@ -1,51 +1,61 @@ import { NgTemplateOutlet } from "@angular/common"; -import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + ViewEncapsulation, +} from "@angular/core"; import { ClosingButtonComponent } from "../../../buttons/closing-button/closing-button.component"; import { PopoverComponent } from "../popover.component"; - + export type PopoverWidth = "none" | "small" | "medium" | "large"; let popoverTitleId = 0; @Component({ - standalone: true, - selector: "tedi-popover-content", - templateUrl: "./popover-content.component.html", - styleUrl: "../popover.component.scss", - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgTemplateOutlet, ClosingButtonComponent], - host: { - "[class]": "classes()", - "role": "dialog", - "[attr.aria-labelledby]": "title() ? titleId : null", - }, + standalone: true, + selector: "tedi-popover-content", + templateUrl: "./popover-content.component.html", + styleUrl: "../popover.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgTemplateOutlet, ClosingButtonComponent], + host: { + "[class]": "classes()", + role: "dialog", + "[attr.aria-labelledby]": "title() ? titleId : null", + }, }) export class PopoverContentComponent { - /** - * The width of the popover. Can be 'none', 'small', 'medium', or 'large'. - * @default small - */ - maxWidth = input("small"); - /** - * Heading title of the content - */ - title = input(""); - /** - * Should content show close button? - * @default false - */ - showClose = input(false); + /** + * The width of the popover. Can be 'none', 'small', 'medium', or 'large'. + * @default small + */ + maxWidth = input("small"); + /** + * Heading title of the content + */ + title = input(""); + /** + * Should content show close button? + * @default false + */ + showClose = input(false); - private popover = inject(PopoverComponent, { optional: true }); - titleId = `popover-title-${popoverTitleId++}`; + private popover = inject(PopoverComponent, { optional: true }); + titleId = `popover-title-${popoverTitleId++}`; - classes = computed(() => { - const classList = ["tedi-popover-content", `tedi-popover-content--${this.maxWidth()}`]; - return classList.join(" "); - }) + classes = computed(() => { + const classList = [ + "tedi-popover-content", + `tedi-popover-content--${this.maxWidth()}`, + ]; + return classList.join(" "); + }); - handleClose() { - this.popover?.floatUiComponent.hide(); - } -} \ No newline at end of file + handleClose() { + this.popover?.floatUiComponent().hide(); + } +} diff --git a/tedi/components/overlay/popover/popover.component.scss b/tedi/components/overlay/popover/popover.component.scss index be4b9c1de..5b90aa011 100644 --- a/tedi/components/overlay/popover/popover.component.scss +++ b/tedi/components/overlay/popover/popover.component.scss @@ -20,17 +20,19 @@ float-ui-content { .float-ui-container-popover { padding: 0; border: var(--borders-01) solid var(--popover-border); + border-radius: var(--popover-radius-rounded); box-shadow: 0px 1px 5px 0px var(--alpha-20, rgba(0, 0, 0, 0.2)); + z-index: var(--z-index-dropdown); - @include mixins.responsive-styles(border-radius, popover-radius-rounded); - - .float-ui-arrow { - width: 24px; - height: 24px; - background: var(--popover-background); - filter: drop-shadow(0 0 5px var(--alpha-20)); - clip-path: inset(0px -5px -5px 0px); - z-index: var(--z-index-dropdown); + &--arrow { + .float-ui-arrow { + width: 24px; + height: 24px; + background: var(--popover-background); + filter: drop-shadow(0 0 5px var(--alpha-20)); + clip-path: inset(0px -5px -5px 0px); + z-index: var(--z-index-dropdown); + } } &--border { @@ -118,11 +120,10 @@ float-ui-content { flex-direction: column; background: var(--popover-background); color: var(--popover-text); - z-index: var(--z-index-dropdown); + border-radius: var(--popover-radius-rounded); @include mixins.responsive-styles(gap, layout-grid-gutters-08); @include mixins.responsive-styles(padding, popover-padding-sm); - @include mixins.responsive-styles(border-radius, popover-radius-rounded); @each $name, $width in $popover-max-width { &--#{$name} { diff --git a/tedi/components/overlay/popover/popover.component.spec.ts b/tedi/components/overlay/popover/popover.component.spec.ts index a3f1ee642..11ecb0e7f 100644 --- a/tedi/components/overlay/popover/popover.component.spec.ts +++ b/tedi/components/overlay/popover/popover.component.spec.ts @@ -1,8 +1,8 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PopoverComponent, PopoverPosition } from './popover.component'; -import { NgxFloatUiContentComponent } from 'ngx-float-ui'; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { PopoverComponent, PopoverPosition } from "./popover.component"; +import { NgxFloatUiContentComponent } from "ngx-float-ui"; -describe('PopoverComponent', () => { +describe("PopoverComponent", () => { let fixture: ComponentFixture; let component: PopoverComponent; let hostEl: HTMLElement; @@ -18,83 +18,97 @@ describe('PopoverComponent', () => { fixture.detectChanges(); }); - it('should create component', () => { + it("should create component", () => { expect(component).toBeTruthy(); }); - it('should have default inputs', () => { - expect(component.openWith()).toBe('click'); - expect(component.position()).toBe('top'); + it("should have default inputs", () => { + expect(component.openWith()).toBe("click"); + expect(component.position()).toBe("top"); expect(component.dismissible()).toBe(true); expect(component.hideOnScroll()).toBe(false); expect(component.withBorder()).toBe(false); }); - it('should initialize the ViewChild floatUiComponent', () => { - expect(component.floatUiComponent).toBeInstanceOf(NgxFloatUiContentComponent); + it("should initialize the ViewChild floatUiComponent", () => { + const instance = component.floatUiComponent(); + expect(instance).toBeInstanceOf(NgxFloatUiContentComponent); }); - it('should apply inline-flex style to trigger wrapper', () => { - const wrapper = hostEl.querySelector('div'); - expect(wrapper?.style.display).toBe('inline-flex'); + it("should apply inline-flex style to trigger wrapper", () => { + const wrapper = hostEl.querySelector("div"); + expect(wrapper?.style.display).toBe("inline-flex"); }); it('should render aria-haspopup="dialog"', () => { - const wrapper = hostEl.querySelector('div'); - expect(wrapper?.getAttribute('aria-haspopup')).toBe('dialog'); + const wrapper = hostEl.querySelector("div"); + expect(wrapper?.getAttribute("aria-haspopup")).toBe("dialog"); }); it('should have appendTo="body" attribute', () => { - const wrapper = hostEl.querySelector('div'); - expect(wrapper?.getAttribute('appendTo')).toBe('body'); + const wrapper = hostEl.querySelector("div"); + expect(wrapper?.getAttribute("appendTo")).toBe("body"); }); - it('should not include the border class by default', () => { - const wrapper = hostEl.querySelector('div'); - expect(wrapper?.classList).not.toContain('float-ui-container--border'); + it("should not include the border class by default", () => { + const wrapper = hostEl.querySelector("div"); + expect(wrapper?.classList).not.toContain("float-ui-container--border"); }); - it('should apply the border class when withBorder is true', () => { - fixture.componentRef.setInput('withBorder', true); + it("should apply the border class when withBorder is true", () => { + fixture.componentRef.setInput("withBorder", true); fixture.detectChanges(); fixture.whenStable().then(() => { - const floatUiContent = hostEl.querySelector('float-ui-content'); - const wrapper = floatUiContent?.querySelector('div'); - expect(wrapper?.classList).toContain('float-ui-container--border'); - }) + const floatUiContent = hostEl.querySelector("float-ui-content"); + const wrapper = floatUiContent?.querySelector("div"); + expect(wrapper?.classList).toContain("float-ui-container--border"); + }); }); - it('should update openWith when input changes', () => { - fixture.componentRef.setInput('openWith', 'hover'); + it("should update openWith when input changes", () => { + fixture.componentRef.setInput("openWith", "hover"); fixture.detectChanges(); - expect(component.openWith()).toBe('hover'); + expect(component.openWith()).toBe("hover"); }); - it('should update position when input changes', () => { - const POSITIONS: PopoverPosition[] = ["top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "right", "right-start", "right-end", "left", "left-start", "left-end"]; + it("should update position when input changes", () => { + const POSITIONS: PopoverPosition[] = [ + "top", + "top-start", + "top-end", + "bottom", + "bottom-start", + "bottom-end", + "right", + "right-start", + "right-end", + "left", + "left-start", + "left-end", + ]; for (const pos of POSITIONS) { - fixture.componentRef.setInput('position', pos); + fixture.componentRef.setInput("position", pos); fixture.detectChanges(); fixture.whenStable().then(() => { - const floatUiContent = hostEl.querySelector('float-ui-content'); - const wrapper = floatUiContent?.querySelector('div'); - const position = pos.split('-')[0]; - expect(wrapper?.getAttribute('data-float-ui-placement')).toBe(position); - }) + const floatUiContent = hostEl.querySelector("float-ui-content"); + const wrapper = floatUiContent?.querySelector("div"); + const position = pos.split("-")[0]; + expect(wrapper?.getAttribute("data-float-ui-placement")).toBe(position); + }); } }); - it('should update dismissible when input changes', () => { - fixture.componentRef.setInput('dismissible', false); + it("should update dismissible when input changes", () => { + fixture.componentRef.setInput("dismissible", false); fixture.detectChanges(); expect(component.dismissible()).toBe(false); }); - it('should update hideOnScroll when input changes', () => { - fixture.componentRef.setInput('hideOnScroll', true); + it("should update hideOnScroll when input changes", () => { + fixture.componentRef.setInput("hideOnScroll", true); fixture.detectChanges(); expect(component.hideOnScroll()).toBe(true); }); diff --git a/tedi/components/overlay/popover/popover.component.ts b/tedi/components/overlay/popover/popover.component.ts index 35c48ba0a..542d3582e 100644 --- a/tedi/components/overlay/popover/popover.component.ts +++ b/tedi/components/overlay/popover/popover.component.ts @@ -4,10 +4,10 @@ import { ViewEncapsulation, ChangeDetectionStrategy, input, - ViewChild, inject, Renderer2, computed, + viewChild, } from "@angular/core"; import { NgxFloatUiContentComponent, @@ -54,13 +54,17 @@ export class PopoverComponent { * @default false */ withBorder = input(false); + /** + * Should show arrow? + */ + withArrow = input(true); /** * Lock scrolling on rest of the page? * @default false */ lockScroll = input(false); - @ViewChild("floatUiComponent") floatUiComponent!: NgxFloatUiContentComponent; + readonly floatUiComponent = viewChild.required(NgxFloatUiContentComponent); private readonly document = inject(DOCUMENT); private readonly renderer = inject(Renderer2); @@ -83,6 +87,10 @@ export class PopoverComponent { classList.push("float-ui-container-popover--border"); } + if (this.withArrow()) { + classList.push("float-ui-container-popover--arrow"); + } + return classList.join(","); }); } diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 38d266301..ef00d7663 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -613,6 +613,329 @@ export const translationsMap = { en: (slideNumber: number) => `Show slide ${slideNumber}`, ru: (slideNumber: number) => `Показать слайд ${slideNumber}`, }, + "date-picker.january": { + description: "Label for january month", + components: ["DatePicker"], + et: "Jaanuar", + en: "January", + ru: "Январь", + }, + "date-picker.february": { + description: "Label for february month", + components: ["DatePicker"], + et: "Veebruar", + en: "February", + ru: "Февраль", + }, + "date-picker.march": { + description: "Label for march month", + components: ["DatePicker"], + et: "Märts", + en: "March", + ru: "Март", + }, + "date-picker.april": { + description: "Label for april month", + components: ["DatePicker"], + et: "Aprill", + en: "April", + ru: "Апрель", + }, + "date-picker.may": { + description: "Label for may month", + components: ["DatePicker"], + et: "Mai", + en: "May", + ru: "Май", + }, + "date-picker.june": { + description: "Label for june month", + components: ["DatePicker"], + et: "Juuni", + en: "June", + ru: "Июнь", + }, + "date-picker.july": { + description: "Label for july month", + components: ["DatePicker"], + et: "Juuli", + en: "July", + ru: "Июль", + }, + "date-picker.august": { + description: "Label for august month", + components: ["DatePicker"], + et: "August", + en: "August", + ru: "Август", + }, + "date-picker.september": { + description: "Label for september month", + components: ["DatePicker"], + et: "September", + en: "September", + ru: "Сентябрь", + }, + "date-picker.october": { + description: "Label for october month", + components: ["DatePicker"], + et: "Oktoober", + en: "October", + ru: "Октябрь", + }, + "date-picker.november": { + description: "Label for november month", + components: ["DatePicker"], + et: "November", + en: "November", + ru: "Ноябрь", + }, + "date-picker.december": { + description: "Label for december month", + components: ["DatePicker"], + et: "Detsember", + en: "December", + ru: "Декабрь", + }, + "date-picker.january-short": { + description: "Short label for january month", + components: ["DatePicker"], + et: "Jaan", + en: "Jan", + ru: "Янв", + }, + "date-picker.february-short": { + description: "Short label for february month", + components: ["DatePicker"], + et: "Veebr", + en: "Feb", + ru: "Фев", + }, + "date-picker.march-short": { + description: "Short label for march month", + components: ["DatePicker"], + et: "Märts", + en: "Mar", + ru: "Мар", + }, + "date-picker.april-short": { + description: "Short label for april month", + components: ["DatePicker"], + et: "Apr", + en: "Apr", + ru: "Апр", + }, + "date-picker.may-short": { + description: "Short label for may month", + components: ["DatePicker"], + et: "Mai", + en: "May", + ru: "Май", + }, + "date-picker.june-short": { + description: "Short label for june month", + components: ["DatePicker"], + et: "Juuni", + en: "Jun", + ru: "Июн", + }, + "date-picker.july-short": { + description: "Short label for july month", + components: ["DatePicker"], + et: "Juuli", + en: "Jul", + ru: "Июл", + }, + "date-picker.august-short": { + description: "Short label for august month", + components: ["DatePicker"], + et: "Aug", + en: "Aug", + ru: "Авг", + }, + "date-picker.september-short": { + description: "Short label for september month", + components: ["DatePicker"], + et: "Sept", + en: "Sep", + ru: "Сен", + }, + "date-picker.october-short": { + description: "Short label for october month", + components: ["DatePicker"], + et: "Okt", + en: "Oct", + ru: "Окт", + }, + "date-picker.november-short": { + description: "Short label for november month", + components: ["DatePicker"], + et: "Nov", + en: "Nov", + ru: "Ноя", + }, + "date-picker.december-short": { + description: "Short label for december month", + components: ["DatePicker"], + et: "Dets", + en: "Dec", + ru: "Дек", + }, + "date-picker.monday": { + description: "Label for Monday", + components: ["DatePicker"], + et: "Esmaspäev", + en: "Monday", + ru: "Понедельник", + }, + "date-picker.tuesday": { + description: "Label for Tuesday", + components: ["DatePicker"], + et: "Teisipäev", + en: "Tuesday", + ru: "Вторник", + }, + "date-picker.wednesday": { + description: "Label for Wednesday", + components: ["DatePicker"], + et: "Kolmapäev", + en: "Wednesday", + ru: "Среда", + }, + "date-picker.thursday": { + description: "Label for Thursday", + components: ["DatePicker"], + et: "Neljapäev", + en: "Thursday", + ru: "Четверг", + }, + "date-picker.friday": { + description: "Label for Friday", + components: ["DatePicker"], + et: "Reede", + en: "Friday", + ru: "Пятница", + }, + "date-picker.saturday": { + description: "Label for Saturday", + components: ["DatePicker"], + et: "Laupäev", + en: "Saturday", + ru: "Суббота", + }, + "date-picker.sunday": { + description: "Label for Sunday", + components: ["DatePicker"], + et: "Pühapäev", + en: "Sunday", + ru: "Воскресенье", + }, + "date-picker.monday-short": { + description: "Short label for Monday", + components: ["DatePicker"], + et: "E", + en: "Mon", + ru: "Пн", + }, + "date-picker.tuesday-short": { + description: "Short label for Tuesday", + components: ["DatePicker"], + et: "T", + en: "Tue", + ru: "Вт", + }, + "date-picker.wednesday-short": { + description: "Short label for Wednesday", + components: ["DatePicker"], + et: "K", + en: "Wed", + ru: "Ср", + }, + "date-picker.thursday-short": { + description: "Short label for Thursday", + components: ["DatePicker"], + et: "N", + en: "Thu", + ru: "Чт", + }, + "date-picker.friday-short": { + description: "Short label for Friday", + components: ["DatePicker"], + et: "R", + en: "Fri", + ru: "Пт", + }, + "date-picker.saturday-short": { + description: "Short label for Saturday", + components: ["DatePicker"], + et: "L", + en: "Sat", + ru: "Сб", + }, + "date-picker.sunday-short": { + description: "Short label for Sunday", + components: ["DatePicker"], + et: "P", + en: "Sun", + ru: "Вс", + }, + "date-picker.go-next-month": { + description: "Label for next month navigation", + components: ["DatePicker"], + et: "Järgmine kuu", + en: "Next month", + ru: "Следующий месяц", + }, + "date-picker.go-prev-month": { + description: "Label for previous month navigation", + components: ["DatePicker"], + et: "Eelmine kuu", + en: "Previous month", + ru: "Предыдущий месяц", + }, + "date-picker.select-month": { + description: "Label for month selection dropdown", + components: ["DatePicker"], + et: "Vali kuu", + en: "Select month", + ru: "Выберите месяц", + }, + "date-picker.select-year": { + description: "Label for year selection dropdown", + components: ["DatePicker"], + et: "Vali aasta", + en: "Select year", + ru: "Выберите год", + }, + "date-picker.clear-date": { + description: + "Label for the button that clears the selected date from the input field.", + components: ["DatePicker"], + et: "Tühjenda kuupäev", + en: "Clear date", + ru: "Очистить дату", + }, + "date-picker.open-calendar": { + description: "Label for the button that opens the date picker calendar.", + components: ["DatePicker"], + et: "Ava kalender", + en: "Open calendar", + ru: "Открыть календарь", + }, + "date-picker.previous-years": { + description: "Label for showing previous years in year-grid.", + components: ["DatePicker"], + et: "Eelnevad aastad", + en: "Previous years", + ru: "Предыдущие годы", + }, + "date-picker.next-years": { + description: "Label for showing next years in year-grid.", + components: ["DatePicker"], + et: "Järgmised aastad", + en: "Next years", + ru: "Следующие годы", + }, }; export type TediTranslationsMap = {