From adb97a01b31096d51a5669dee25e95a82a110cf1 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Thu, 20 Nov 2025 16:14:54 +0200 Subject: [PATCH 1/5] feat(date-picker): add date-picker component #190 --- .../date-picker/date-picker.component.html | 113 +++++++++ .../date-picker/date-picker.component.scss | 165 +++++++++++++ .../form/date-picker/date-picker.component.ts | 230 ++++++++++++++++++ .../form/date-picker/date-picker.stories.ts | 117 +++++++++ tedi/components/form/index.ts | 3 +- tedi/services/translation/translations.ts | 210 ++++++++++++++++ 6 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 tedi/components/form/date-picker/date-picker.component.html create mode 100644 tedi/components/form/date-picker/date-picker.component.scss create mode 100644 tedi/components/form/date-picker/date-picker.component.ts create mode 100644 tedi/components/form/date-picker/date-picker.stories.ts 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..57770434e --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.component.html @@ -0,0 +1,113 @@ +
+
+ @if (showControls()) { + + } + +
+ @if (showMonthDropdown()) { +
+ + +
+ } + @if (showYearDropdown()) { +
+ + +
+ } +
+ + @if (showControls()) { + + } +
+ +
+ @for (wd of weekDays; track wd()) { +
+ {{ wd() }} +
+ } +
+ +
+ @for (day of days(); track day) { + + } +
+
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..ef81a0b9b --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.component.scss @@ -0,0 +1,165 @@ +.tedi-date-picker { + width: fit-content; + border-radius: var(--card-radius-rounded); + border: var(--borders-01) solid var(--card-border-primary); + background: var(--card-background-primary); + box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2); + 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; + } + + &__select-wrapper { + position: relative; + display: inline-flex; + align-items: center; + + padding: 0 var(--layout-grid-gutters-04); + border-radius: var(--button-radius-sm); + + &:has(.tedi-date-picker__select:hover) { + background: var(--button-main-neutral-icon-only-background-hover); + + .tedi-date-picker__select-arrow { + color: var(--button-main-neutral-text-hover); + } + } + + &:has(.tedi-date-picker__select:active) { + background: var(--button-main-neutral-icon-only-background-active); + + .tedi-date-picker__select-arrow { + color: var(--button-main-neutral-text-hover); + } + } + + &:has(.tedi-date-picker__select:focus-visible) { + outline: var(--borders-02) solid var(--primary-500); + outline-offset: var(--borders-01); + } + + .tedi-date-picker__select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: transparent; + + font-size: 1rem; + font-weight: 600; + color: var(--general-text-primary); + border: 0; + cursor: pointer; + } + + .tedi-date-picker__select-arrow { + font-size: 2rem; + color: var(--general-icon-tertiary); + } + } + + &__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); + } + + &__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: grid; + grid-template-columns: repeat(7, 1fr); + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default) + var(--card-padding-md-default); + } + + &__day { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + 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; + } +} 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..b009bcca8 --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.component.ts @@ -0,0 +1,230 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + signal, + computed, + model, + input, + inject, +} from "@angular/core"; +import { ButtonComponent } from "tedi/components/buttons"; +import { IconComponent } from "tedi/components/base"; +import { TediTranslationService } from "tedi/services"; + +export interface DatePickerDay { + date: Date; + disabled: boolean; + inCurrentMonth: boolean; +} + +@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], +}) +export class DatePickerComponent { + /** Selected date */ + readonly selected = model(null); + + private readonly today = new Date(); + + readonly min = input(null); + readonly max = input(null); + + readonly showControls = input(true); + readonly showMonthDropdown = input(true); + readonly showYearDropdown = input(true); + + readonly startYear = input(null); + readonly endYear = input(null); + + 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 months = [ + 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"), + ]; + + private readonly currentMonth = signal(this.today); + + readonly selectedMonth = computed(() => this.currentMonth().getMonth()); + readonly selectedYear = computed(() => this.currentMonth().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 days = computed(() => { + const month = this.currentMonth(); + 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 minDate = this.min(); + const maxDate = this.max(); + + 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 = !!( + (minDate && date < minDate) || + (maxDate && date > maxDate) + ); + + 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 = !!( + (minDate && date < minDate) || + (maxDate && date > maxDate) + ); + + 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 = !!( + (minDate && date < minDate) || + (maxDate && date > maxDate) + ); + + cells.push({ + date, + inCurrentMonth: false, + disabled, + }); + } + + return cells; + }); + + readonly canGoPrev = computed(() => { + if (!this.min()) return true; + + const prev = new Date(this.currentMonth()); + prev.setMonth(prev.getMonth() - 1); + + return ( + prev >= new Date(this.min()!.getFullYear(), this.min()!.getMonth(), 1) + ); + }); + + readonly canGoNext = computed(() => { + if (!this.max()) return true; + + const next = new Date(this.currentMonth()); + next.setMonth(next.getMonth() + 1); + + return ( + next <= new Date(this.max()!.getFullYear(), this.max()!.getMonth(), 1) + ); + }); + + prevMonth() { + const date = new Date(this.currentMonth()); + date.setMonth(date.getMonth() - 1); + this.currentMonth.set(date); + } + + nextMonth() { + const date = new Date(this.currentMonth()); + date.setMonth(date.getMonth() + 1); + this.currentMonth.set(date); + } + + selectDay(day: DatePickerDay) { + if (day.disabled) return; + + this.selected.set(day.date); + } + + isSelected(date: Date): boolean { + return ( + !!this.selected() && + date.toDateString() === this.selected()!.toDateString() + ); + } + + isToday(date: Date): boolean { + return date.toDateString() === this.today.toDateString(); + } + + onMonthSelect(event: Event) { + const select = event.target as HTMLSelectElement; + const monthIndex = Number(select.value); + + const updated = new Date(this.currentMonth()); + updated.setMonth(monthIndex); + + this.currentMonth.set(updated); + } + + onYearSelect(event: Event) { + const select = event.target as HTMLSelectElement; + const year = Number(select.value); + + const updated = new Date(this.currentMonth()); + updated.setFullYear(year); + + this.currentMonth.set(updated); + } +} 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..0e1e7dfaf --- /dev/null +++ b/tedi/components/form/date-picker/date-picker.stories.ts @@ -0,0 +1,117 @@ +import { + type Meta, + type StoryObj, + argsToTemplate, + moduleMetadata, +} from "@storybook/angular"; +import { DatePickerComponent } from "./date-picker.component"; + +export default { + title: "TEDI-Ready/Components/Form/DatePicker", + component: DatePickerComponent, + decorators: [ + moduleMetadata({ + imports: [DatePickerComponent], + }), + ], + argTypes: { + selected: { + description: + "Currently selected date. Supports two-way binding using Angular model().", + control: { type: "date" }, + table: { + category: "inputs", + type: { summary: "Date | null" }, + }, + }, + + min: { + description: + "Minimum allowed date. All dates earlier than this are disabled, including navigation to months before the limit.", + control: { type: "date" }, + table: { + category: "inputs", + type: { summary: "Date | null" }, + }, + }, + + max: { + description: + "Maximum allowed date. All dates after this are disabled, including navigation to months after the limit.", + control: { type: "date" }, + table: { + category: "inputs", + type: { summary: "Date | 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" }, + }, + }, + + showMonthDropdown: { + description: + "Toggle visibility of the month selection dropdown inside the header.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + + showYearDropdown: { + description: + "Toggle visibility of the year selection dropdown inside the header.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + + startYear: { + description: + "Explicit starting year for the year dropdown list. If null, a dynamic fallback range 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 is used.", + control: { type: "number" }, + table: { + category: "inputs", + type: { summary: "number | null" }, + defaultValue: { summary: "null" }, + }, + }, + }, +} as Meta; + +export const Default: StoryObj = { + args: { + showControls: true, + showMonthDropdown: true, + showYearDropdown: true, + }, + render: (args) => ({ + props: args, + template: ` + + `, + }), +}; diff --git a/tedi/components/form/index.ts b/tedi/components/form/index.ts index 09935c35a..ed05bf502 100644 --- a/tedi/components/form/index.ts +++ b/tedi/components/form/index.ts @@ -1,4 +1,5 @@ +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"; -export * from "./toggle/toggle.component"; \ No newline at end of file +export * from "./toggle/toggle.component"; diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 38d266301..251b57141 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -613,6 +613,216 @@ 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.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: "Выберите год", + }, }; export type TediTranslationsMap = { From dd694bc2e93b2645d027689e272b33087391d512 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Fri, 21 Nov 2025 08:29:05 +0200 Subject: [PATCH 2/5] feat(date-picker): add dropdown, change popover #190 --- package-lock.json | 8 +- package.json | 2 +- .../date-picker/date-picker.component.html | 277 ++++++++++------- .../date-picker/date-picker.component.scss | 108 +++++-- .../form/date-picker/date-picker.component.ts | 267 ++++++++++++---- .../form/date-picker/date-picker.stories.ts | 67 ++-- .../dropdown-content.component.html | 4 + .../dropdown-content.component.scss | 14 + .../dropdown-content.component.ts | 35 +++ .../dropdown-item.component.scss | 37 +++ .../dropdown-item/dropdown-item.component.ts | 108 +++++++ .../dropdown-trigger.component.scss | 3 + .../dropdown-trigger.directive.ts | 73 +++++ .../overlay/dropdown/dropdown.component.html | 18 ++ .../overlay/dropdown/dropdown.component.scss | 13 + .../overlay/dropdown/dropdown.component.ts | 285 ++++++++++++++++++ .../overlay/dropdown/dropdown.stories.ts | 45 +++ .../overlay/popover/popover.component.scss | 16 +- .../overlay/popover/popover.component.ts | 8 + 19 files changed, 1161 insertions(+), 227 deletions(-) create mode 100644 tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.html create mode 100644 tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss create mode 100644 tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts create mode 100644 tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss create mode 100644 tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts create mode 100644 tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.component.scss create mode 100644 tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts create mode 100644 tedi/components/overlay/dropdown/dropdown.component.html create mode 100644 tedi/components/overlay/dropdown/dropdown.component.scss create mode 100644 tedi/components/overlay/dropdown/dropdown.component.ts create mode 100644 tedi/components/overlay/dropdown/dropdown.stories.ts diff --git a/package-lock.json b/package-lock.json index d6b600d61..523739655 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@tedi-design-system/angular", "version": "0.0.0-semantic-version", "dependencies": { - "@tedi-design-system/core": "^2.0.0" + "@tedi-design-system/core": "^2.3.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", @@ -9423,9 +9423,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-2.0.0.tgz", - "integrity": "sha512-ODuJgRoQYxyvs702iphOuRd68vTBpmIEe/ol4rFssSVDAZ+pZOCbfd87qvoEHW+iDWDlsxHJIOrzMTl3qsTHAQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-2.3.0.tgz", + "integrity": "sha512-mbx8V13nXzGkI/qOO54GtNt5IdIcPQBe9elx4lS1lCkJjSKHEmrxBSWXMX59KyY8oPjyMpQ0uXjVyK6XaMDK/Q==", "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" diff --git a/package.json b/package.json index d0436c693..461bd063f 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ngx-float-ui": "^19.0.1 || ^20.0.0" }, "dependencies": { - "@tedi-design-system/core": "^2.0.0" + "@tedi-design-system/core": "^2.3.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", diff --git a/tedi/components/form/date-picker/date-picker.component.html b/tedi/components/form/date-picker/date-picker.component.html index 57770434e..110e7e478 100644 --- a/tedi/components/form/date-picker/date-picker.component.html +++ b/tedi/components/form/date-picker/date-picker.component.html @@ -1,113 +1,184 @@ -
-
- @if (showControls()) { +
+ +
+ @if (selected()) { + [iconSize]="18" + class="tedi-date-picker__clear" + aria-label="Clear date" + (click)="clearInput()" + > + } - -
- @if (showMonthDropdown()) { -
- - -
- } - @if (showYearDropdown()) { -
- - -
- } -
+
+ @if (showMonthDropdown()) { + + + + @for (month of months; track i; let i = $index) { +
  • + {{ month() }} +
  • + } +
    +
    + } + @if (showYearDropdown()) { + + + + @for (year of years(); track year) { +
  • + {{ year }} +
  • + } +
    +
    + } +
    - @if (showControls()) { - - } -
    + @if (showControls()) { + + } +
    -
    - @for (wd of weekDays; track wd()) { -
    - {{ wd() }} -
    - } -
    +
    + @for (wd of weekDays; track wd()) { +
    + {{ wd() }} +
    + } +
    -
    - @for (day of days(); track day) { - + }
    - } @else { - {{ day.date.getDate() }} - } - - } +
    + +
    diff --git a/tedi/components/form/date-picker/date-picker.component.scss b/tedi/components/form/date-picker/date-picker.component.scss index ef81a0b9b..de7457096 100644 --- a/tedi/components/form/date-picker/date-picker.component.scss +++ b/tedi/components/form/date-picker/date-picker.component.scss @@ -1,10 +1,51 @@ +tedi-date-picker { + display: block; +} + .tedi-date-picker { - width: fit-content; - border-radius: var(--card-radius-rounded); - border: var(--borders-01) solid var(--card-border-primary); - background: var(--card-background-primary); - box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2); - user-select: none; + &__input-wrapper { + display: flex; + min-height: var(--form-field-height); + gap: var(--form-field-inner-spacing); + align-self: stretch; + border-radius: var(--form-field-radius); + border: 1px solid var(--form-input-border-default); + background: var(--form-input-background-default); + padding-right: var(--form-field-padding-x-md-default); + } + + &__input { + flex: 1; + padding: var(--form-field-padding-y-md-has-button) + var(--form-field-padding-x-md-default); + padding-right: 0; + color: var(--form-input-text-filled); + font-size: var(--body-regular-size); + border: 0; + } + + &__input-buttons { + align-self: center; + display: flex; + align-items: center; + gap: var(--layout-grid-gutters-04); + } + + &__toggle { + width: var(--button-xs-icon-size, 24px) !important; + border-radius: var(--button-radius-sm) !important; + min-height: var(--form-field-button-height-sm) !important; + max-height: var(--form-field-button-height-sm, 24px) !important; + padding: var(--button-sm-icon-padding) !important; + } + + &__calendar { + width: fit-content; + display: block; + border-radius: var(--card-radius-rounded); + background: var(--card-background-primary); + user-select: none; + } &__header { display: flex; @@ -22,54 +63,61 @@ margin: 0 auto; } - &__select-wrapper { - position: relative; + &__dropdown-trigger { display: inline-flex; align-items: center; - - padding: 0 var(--layout-grid-gutters-04); + 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; - &:has(.tedi-date-picker__select:hover) { + &:hover { + color: var(--button-main-neutral-text-hover); background: var(--button-main-neutral-icon-only-background-hover); - .tedi-date-picker__select-arrow { + tedi-icon { color: var(--button-main-neutral-text-hover); } } - &:has(.tedi-date-picker__select:active) { + &:active { + color: var(--button-main-neutral-text-active); background: var(--button-main-neutral-icon-only-background-active); - .tedi-date-picker__select-arrow { - color: var(--button-main-neutral-text-hover); + tedi-icon { + color: var(--button-main-neutral-text-active); } } - &:has(.tedi-date-picker__select:focus-visible) { + &:focus-visible { outline: var(--borders-02) solid var(--primary-500); outline-offset: var(--borders-01); } - .tedi-date-picker__select { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - background: transparent; - - font-size: 1rem; - font-weight: 600; - color: var(--general-text-primary); - border: 0; - cursor: pointer; - } - - .tedi-date-picker__select-arrow { + tedi-icon { font-size: 2rem; color: var(--general-icon-tertiary); } } + &__dropdown-content { + max-height: 15rem; + + &--month { + width: 10rem; + } + + &--year { + width: 8.75rem; + } + } + &__nav { font-size: var(--button-icon-inner-icon-only-size) !important; } diff --git a/tedi/components/form/date-picker/date-picker.component.ts b/tedi/components/form/date-picker/date-picker.component.ts index b009bcca8..016307725 100644 --- a/tedi/components/form/date-picker/date-picker.component.ts +++ b/tedi/components/form/date-picker/date-picker.component.ts @@ -2,15 +2,28 @@ import { Component, ViewEncapsulation, ChangeDetectionStrategy, - signal, computed, model, input, inject, + signal, } from "@angular/core"; -import { ButtonComponent } from "tedi/components/buttons"; +import { + ButtonComponent, + ClosingButtonComponent, +} from "tedi/components/buttons"; import { IconComponent } from "tedi/components/base"; import { TediTranslationService } from "tedi/services"; +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 "tedi/components/helpers"; +import { + PopoverComponent, + PopoverTriggerComponent, + PopoverContentComponent, +} from "tedi/components/overlay"; export interface DatePickerDay { date: Date; @@ -18,6 +31,16 @@ export interface DatePickerDay { inCurrentMonth: boolean; } +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", @@ -25,24 +48,62 @@ export interface DatePickerDay { styleUrl: "./date-picker.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonComponent, IconComponent], + imports: [ + ButtonComponent, + IconComponent, + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ClosingButtonComponent, + SeparatorComponent, + PopoverComponent, + PopoverTriggerComponent, + PopoverContentComponent, + ], }) export class DatePickerComponent { + private readonly today = new Date(); + readonly uniqueId = `tedi-date-picker-id-${datePickerId++}`; + /** Selected date */ readonly selected = model(null); - private readonly today = new Date(); + /** Currently shown month */ + readonly month = model(this.today); - readonly min = input(null); - readonly max = input(null); + /** Disabled dates that cannot be selected. */ + readonly disabled = input( + null, + ); + /** Shows or hides the calendar navigation controls (previous/next month buttons). */ readonly showControls = input(true); + + /** Toggle visibility of the month selection dropdown in the header. */ readonly showMonthDropdown = input(true); + + /** Toggle visibility of the year selection dropdown in the header. */ readonly showYearDropdown = input(true); + /** 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 placeholder */ + readonly placeholder = input(); + + readonly inputValue = computed(() => { + const selected = this.selected(); + if (!selected) return ""; + + return this.format(selected); + }); + + readonly isCalendarOpen = signal(false); + readonly translationService = inject(TediTranslationService); readonly weekDays = [ @@ -70,10 +131,7 @@ export class DatePickerComponent { this.translationService.track("date-picker.december"), ]; - private readonly currentMonth = signal(this.today); - - readonly selectedMonth = computed(() => this.currentMonth().getMonth()); - readonly selectedYear = computed(() => this.currentMonth().getFullYear()); + readonly selectedYear = computed(() => this.month().getFullYear()); readonly years = computed(() => { const current = this.today.getFullYear(); @@ -91,7 +149,7 @@ export class DatePickerComponent { }); readonly days = computed(() => { - const month = this.currentMonth(); + const month = this.month(); const year = month.getFullYear(); const monthIndex = month.getMonth(); @@ -99,19 +157,13 @@ export class DatePickerComponent { const daysInMonth = new Date(year, monthIndex + 1, 0).getDate(); const firstWeekday = (firstOfMonth.getDay() + 6) % 7; - const minDate = this.min(); - const maxDate = this.max(); - 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 = !!( - (minDate && date < minDate) || - (maxDate && date > maxDate) - ); + const disabled = this.isDisabled(date); cells.push({ date, @@ -123,10 +175,7 @@ export class DatePickerComponent { /** Current month days */ for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, monthIndex, day); - const disabled = !!( - (minDate && date < minDate) || - (maxDate && date > maxDate) - ); + const disabled = this.isDisabled(date); cells.push({ date, @@ -141,11 +190,7 @@ export class DatePickerComponent { for (let i = 1; i <= trailing; i++) { const date = new Date(lastDate); date.setDate(lastDate.getDate() + i); - - const disabled = !!( - (minDate && date < minDate) || - (maxDate && date > maxDate) - ); + const disabled = this.isDisabled(date); cells.push({ date, @@ -158,37 +203,39 @@ export class DatePickerComponent { }); readonly canGoPrev = computed(() => { - if (!this.min()) return true; + const current = this.month(); + const year = current.getFullYear(); + const month = current.getMonth(); - const prev = new Date(this.currentMonth()); - prev.setMonth(prev.getMonth() - 1); + const prevMonth = month - 1; + const prevYear = prevMonth < 0 ? year - 1 : year; + const finalPrevMonth = (prevMonth + 12) % 12; - return ( - prev >= new Date(this.min()!.getFullYear(), this.min()!.getMonth(), 1) - ); + return this.getFirstEnabledDayOfMonth(prevYear, finalPrevMonth) !== null; }); readonly canGoNext = computed(() => { - if (!this.max()) return true; + const current = this.month(); + const year = current.getFullYear(); + const month = current.getMonth(); - const next = new Date(this.currentMonth()); - next.setMonth(next.getMonth() + 1); + const nextMonth = month + 1; + const nextYear = nextMonth > 11 ? year + 1 : year; + const finalNextMonth = nextMonth % 12; - return ( - next <= new Date(this.max()!.getFullYear(), this.max()!.getMonth(), 1) - ); + return this.getFirstEnabledDayOfMonth(nextYear, finalNextMonth) !== null; }); prevMonth() { - const date = new Date(this.currentMonth()); + const date = new Date(this.month()); date.setMonth(date.getMonth() - 1); - this.currentMonth.set(date); + this.month.set(date); } nextMonth() { - const date = new Date(this.currentMonth()); + const date = new Date(this.month()); date.setMonth(date.getMonth() + 1); - this.currentMonth.set(date); + this.month.set(date); } selectDay(day: DatePickerDay) { @@ -197,6 +244,14 @@ export class DatePickerComponent { this.selected.set(day.date); } + 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)); + } + isSelected(date: Date): boolean { return ( !!this.selected() && @@ -208,23 +263,125 @@ export class DatePickerComponent { return date.toDateString() === this.today.toDateString(); } - onMonthSelect(event: Event) { - const select = event.target as HTMLSelectElement; - const monthIndex = Number(select.value); + onMonthSelect(index?: string) { + if (!index) return; + + const updated = new Date(this.month()); + updated.setMonth(Number(index)); + this.month.set(updated); + } + + onYearSelect(index?: string) { + const updated = new Date(this.month()); + updated.setFullYear(Number(index)); + this.month.set(updated); + } + + toggleCalendar() { + this.isCalendarOpen.update((v) => !v); + } + + private rawInput = ""; + + onInput(event: Event) { + const value = (event.target as HTMLInputElement).value; + this.rawInput = value; + } + + onInputBlur() { + const selected = this.selected(); + const parsed = this.parseDate(this.rawInput); + + if (parsed) { + this.selected.set(parsed); + this.month.set(parsed); + } else { + this.rawInput = selected ? this.format(selected) : ""; + } + } + + clearInput() { + this.selected.set(null); + this.rawInput = ""; + } + + 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 updated = new Date(this.currentMonth()); - updated.setMonth(monthIndex); + const date = new Date(yyyy, mm - 1, dd); - this.currentMonth.set(updated); + if ( + date.getFullYear() !== yyyy || + date.getMonth() !== mm - 1 || + date.getDate() !== dd + ) { + return null; + } + + return date; } - onYearSelect(event: Event) { - const select = event.target as HTMLSelectElement; - const year = Number(select.value); + 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); + } - const updated = new Date(this.currentMonth()); - updated.setFullYear(year); + 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; + } + } - this.currentMonth.set(updated); + return null; } } diff --git a/tedi/components/form/date-picker/date-picker.stories.ts b/tedi/components/form/date-picker/date-picker.stories.ts index 0e1e7dfaf..3854bdd10 100644 --- a/tedi/components/form/date-picker/date-picker.stories.ts +++ b/tedi/components/form/date-picker/date-picker.stories.ts @@ -16,35 +16,36 @@ export default { ], argTypes: { selected: { - description: - "Currently selected date. Supports two-way binding using Angular model().", + description: "Selected date", control: { type: "date" }, table: { category: "inputs", type: { summary: "Date | null" }, + defaultValue: { summary: "null" }, }, }, - - min: { - description: - "Minimum allowed date. All dates earlier than this are disabled, including navigation to months before the limit.", + month: { + description: "Currently shown month", control: { type: "date" }, table: { category: "inputs", type: { summary: "Date | null" }, + defaultValue: { summary: "new Date()" }, }, }, - - max: { - description: - "Maximum allowed date. All dates after this are disabled, including navigation to months after the limit.", - control: { type: "date" }, + disabled: { + description: " Disabled dates that cannot be selected.", + control: { type: "object" }, table: { category: "inputs", - type: { summary: "Date | null" }, + 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).", @@ -55,10 +56,9 @@ export default { defaultValue: { summary: "true" }, }, }, - showMonthDropdown: { description: - "Toggle visibility of the month selection dropdown inside the header.", + "Toggle visibility of the month selection dropdown in the header.", control: { type: "boolean" }, table: { category: "inputs", @@ -66,10 +66,9 @@ export default { defaultValue: { summary: "true" }, }, }, - showYearDropdown: { description: - "Toggle visibility of the year selection dropdown inside the header.", + "Toggle visibility of the year selection dropdown in the header.", control: { type: "boolean" }, table: { category: "inputs", @@ -77,10 +76,9 @@ export default { defaultValue: { summary: "true" }, }, }, - startYear: { description: - "Explicit starting year for the year dropdown list. If null, a dynamic fallback range is used.", + "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", @@ -88,10 +86,9 @@ export default { defaultValue: { summary: "null" }, }, }, - endYear: { description: - "Explicit ending year for the year dropdown list. If null, a dynamic fallback range is used.", + "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", @@ -102,14 +99,30 @@ export default { }, } as Meta; +const today = new Date(); +const inTwoDays = new Date(today); +inTwoDays.setDate(today.getDate() + 2); + export const Default: StoryObj = { - args: { - showControls: true, - showMonthDropdown: true, - showYearDropdown: true, - }, + args: (() => { + const today = new Date(); + const next = new Date(today); + next.setDate(today.getDate() + 1); + + return { + selected: next, + month: today, + showControls: true, + showMonthDropdown: true, + showYearDropdown: true, + }; + })(), render: (args) => ({ - props: 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/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..57b889ac9 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts @@ -0,0 +1,73 @@ +import { + Directive, + ElementRef, + HostListener, + inject, + input, +} from "@angular/core"; +import { DropdownComponent } from "../dropdown.component"; + +@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 { + readonly ariaHaspopup = input<"menu" | "listbox" | "true">("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.ts b/tedi/components/overlay/dropdown/dropdown.component.ts new file mode 100644 index 000000000..b8f4cff14 --- /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 body + */ + 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..be3d12def --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.stories.ts @@ -0,0 +1,45 @@ +import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular"; +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 { ButtonComponent } from "../../buttons/button/button.component"; + +export default { + title: "TEDI-Ready/Components/Overlay/Dropdown", + component: DropdownComponent, + decorators: [ + moduleMetadata({ + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ButtonComponent, + ], + }), + ], +} as Meta; + +export const Default: StoryObj = { + args: { + position: "bottom-start", + preventOverflow: true, + appendTo: "body", + }, + render: (args) => ({ + props: args, + template: ` + + + +
  • Access to health data
  • +
  • Declaration of intent
  • +
  • Contacts
  • +
    +
    + `, + }), +}; diff --git a/tedi/components/overlay/popover/popover.component.scss b/tedi/components/overlay/popover/popover.component.scss index be4b9c1de..5e0b2375f 100644 --- a/tedi/components/overlay/popover/popover.component.scss +++ b/tedi/components/overlay/popover/popover.component.scss @@ -24,13 +24,15 @@ float-ui-content { @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 { diff --git a/tedi/components/overlay/popover/popover.component.ts b/tedi/components/overlay/popover/popover.component.ts index 35c48ba0a..72cc638ee 100644 --- a/tedi/components/overlay/popover/popover.component.ts +++ b/tedi/components/overlay/popover/popover.component.ts @@ -54,6 +54,10 @@ export class PopoverComponent { * @default false */ withBorder = input(false); + /** + * Should show arrow? + */ + withArrow = input(true); /** * Lock scrolling on rest of the page? * @default false @@ -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(","); }); } From 3ebe2153ca3625c9ee7ba101e75b65926d2c89d0 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Fri, 21 Nov 2025 11:30:03 +0200 Subject: [PATCH 3/5] feat(date-picker): wcag improvements #190 --- .../date-picker/date-picker.component.html | 366 ++++++++++-------- .../date-picker/date-picker.component.scss | 95 ++++- .../form/date-picker/date-picker.component.ts | 199 ++++++++-- .../form/date-picker/date-picker.stories.ts | 61 +++ .../header-language.component.ts | 59 ++- .../popover-content.component.spec.ts | 112 +++--- .../popover-content.component.ts | 86 ++-- .../overlay/popover/popover.component.scss | 7 +- .../overlay/popover/popover.component.spec.ts | 96 +++-- .../overlay/popover/popover.component.ts | 4 +- tedi/services/translation/translations.ts | 15 + 11 files changed, 723 insertions(+), 377 deletions(-) diff --git a/tedi/components/form/date-picker/date-picker.component.html b/tedi/components/form/date-picker/date-picker.component.html index 110e7e478..9c8937bd8 100644 --- a/tedi/components/form/date-picker/date-picker.component.html +++ b/tedi/components/form/date-picker/date-picker.component.html @@ -1,184 +1,210 @@ -
    - -
    - @if (selected()) { + +
    + @if (selected()) { + + + } + + - - } - - - - - -
    -
    - @if (showControls()) { - + + +
    +
    + @if (showControls()) { + + } +
    + @if (showMonthDropdown()) { + - - - } -
    - @if (showMonthDropdown()) { - - - - @for (month of months; track i; let i = $index) { -
  • - {{ month() }} -
  • - } -
    -
    - } - @if (showYearDropdown()) { - + + - - - @for (year of years(); track year) { -
  • - {{ year }} -
  • - } -
    -
    - } -
    - - @if (showControls()) { - + + + @for (year of years(); track year) { +
  • + {{ year }} +
  • + } +
    +
    }
    -
    - @for (wd of weekDays; track wd()) { -
    - {{ wd() }} -
    - } -
    + @if (showControls()) { + + } +
    -
    - @for (day of days(); track day) { -
    - } @else { - {{ day.date.getDate() }} - } - - } -
    + } + + } +
    + }
    -
    -
    -
    +
    + +
    diff --git a/tedi/components/form/date-picker/date-picker.component.scss b/tedi/components/form/date-picker/date-picker.component.scss index de7457096..e3b55056f 100644 --- a/tedi/components/form/date-picker/date-picker.component.scss +++ b/tedi/components/form/date-picker/date-picker.component.scss @@ -1,42 +1,89 @@ tedi-date-picker { - display: block; -} + 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); -.tedi-date-picker { - &__input-wrapper { - display: flex; - min-height: var(--form-field-height); - gap: var(--form-field-inner-spacing); - align-self: stretch; - border-radius: var(--form-field-radius); - border: 1px 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: var(--form-field-padding-y-md-has-button) - var(--form-field-padding-x-md-default); - padding-right: 0; + 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, 24px) !important; + width: var(--button-xs-icon-size) !important; + height: var(--form-field-button-height-sm) !important; border-radius: var(--button-radius-sm) !important; - min-height: var(--form-field-button-height-sm) !important; - max-height: var(--form-field-button-height-sm, 24px) !important; - padding: var(--button-sm-icon-padding) !important; + font-size: 1.125rem !important; + + &:disabled { + cursor: not-allowed; + } } &__calendar { @@ -142,12 +189,18 @@ tedi-date-picker { } &__grid { - display: grid; - grid-template-columns: repeat(7, 1fr); + display: flex; + flex-direction: column; + gap: 0; 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); + } + &__day { width: var(--form-calendar-date-width); height: var(--form-calendar-date-width); diff --git a/tedi/components/form/date-picker/date-picker.component.ts b/tedi/components/form/date-picker/date-picker.component.ts index 016307725..aa3b2feae 100644 --- a/tedi/components/form/date-picker/date-picker.component.ts +++ b/tedi/components/form/date-picker/date-picker.component.ts @@ -7,23 +7,22 @@ import { input, inject, signal, + OnInit, + viewChild, + ElementRef, } from "@angular/core"; -import { - ButtonComponent, - ClosingButtonComponent, -} from "tedi/components/buttons"; -import { IconComponent } from "tedi/components/base"; -import { TediTranslationService } from "tedi/services"; +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 "tedi/components/helpers"; -import { - PopoverComponent, - PopoverTriggerComponent, - PopoverContentComponent, -} from "tedi/components/overlay"; +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; @@ -31,6 +30,9 @@ export interface DatePickerDay { inCurrentMonth: boolean; } +export type DatePickerInputState = "default" | "error" | "valid"; +export type DatePickerInputSize = "default" | "small"; + export type DatePickerMatcher = | Date | Date[] @@ -62,7 +64,7 @@ let datePickerId = 0; PopoverContentComponent, ], }) -export class DatePickerComponent { +export class DatePickerComponent implements OnInit { private readonly today = new Date(); readonly uniqueId = `tedi-date-picker-id-${datePickerId++}`; @@ -92,17 +94,31 @@ export class DatePickerComponent { /** 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 placeholder = input(); + readonly inputPlaceholder = input(); - readonly inputValue = computed(() => { - const selected = this.selected(); - if (!selected) return ""; + /** Input state */ + readonly inputState = input("default"); - return this.format(selected); - }); + /** Input size */ + readonly inputSize = input("default"); + + /** Is input disabled? */ + readonly inputDisabled = input(false); + + readonly inputValue = signal(""); - readonly isCalendarOpen = signal(false); + /** Keyboard active date (what receives keyboard focus) */ + readonly activeDate = signal(null); + + readonly inputElement = + viewChild.required>("inputElement"); + readonly gridElement = + viewChild.required>("gridElement"); + readonly popover = viewChild.required(PopoverComponent); readonly translationService = inject(TediTranslationService); @@ -202,6 +218,17 @@ export class DatePickerComponent { return cells; }); + 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 canGoPrev = computed(() => { const current = this.month(); const year = current.getFullYear(); @@ -226,6 +253,17 @@ export class DatePickerComponent { return this.getFirstEnabledDayOfMonth(nextYear, finalNextMonth) !== null; }); + 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); @@ -242,6 +280,7 @@ export class DatePickerComponent { if (day.disabled) return; this.selected.set(day.date); + this.inputValue.set(this.format(day.date)); } isDisabled(date: Date): boolean { @@ -277,32 +316,136 @@ export class DatePickerComponent { this.month.set(updated); } - toggleCalendar() { - this.isCalendarOpen.update((v) => !v); - } + 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; + } - private rawInput = ""; + if (target) { + event.preventDefault(); + this.focusDate(target); + } + } onInput(event: Event) { const value = (event.target as HTMLInputElement).value; - this.rawInput = value; + this.inputValue.set(value); + + if (value === "") { + this.selected.set(null); + } } onInputBlur() { const selected = this.selected(); - const parsed = this.parseDate(this.rawInput); + const parsed = this.parseDate(this.inputValue()); if (parsed) { this.selected.set(parsed); this.month.set(parsed); } else { - this.rawInput = selected ? this.format(selected) : ""; + this.inputValue.set(selected ? this.format(selected) : ""); } } clearInput() { + this.inputValue.set(""); this.selected.set(null); - this.rawInput = ""; + } + + 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(); + } + }); } private parseDate(str: string): Date | null { diff --git a/tedi/components/form/date-picker/date-picker.stories.ts b/tedi/components/form/date-picker/date-picker.stories.ts index 3854bdd10..8fe17548e 100644 --- a/tedi/components/form/date-picker/date-picker.stories.ts +++ b/tedi/components/form/date-picker/date-picker.stories.ts @@ -6,6 +6,11 @@ import { } from "@storybook/angular"; import { DatePickerComponent } from "./date-picker.component"; +/** + * Figma ↗
    + * Zeroheight ↗ + */ + export default { title: "TEDI-Ready/Components/Form/DatePicker", component: DatePickerComponent, @@ -96,6 +101,54 @@ export default { 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" }, + }, + }, }, } as Meta; @@ -115,6 +168,14 @@ export const Default: StoryObj = { showControls: true, showMonthDropdown: true, showYearDropdown: true, + disabled: null, + startYear: null, + endYear: null, + inputId: "date-picker-id-1", + inputPlaceholder: "Enter date...", + inputState: "default", + inputSize: "default", + inputDisabled: false, }; })(), render: (args) => ({ 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/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 5e0b2375f..5b90aa011 100644 --- a/tedi/components/overlay/popover/popover.component.scss +++ b/tedi/components/overlay/popover/popover.component.scss @@ -20,9 +20,9 @@ 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)); - - @include mixins.responsive-styles(border-radius, popover-radius-rounded); + z-index: var(--z-index-dropdown); &--arrow { .float-ui-arrow { @@ -120,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 72cc638ee..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, @@ -64,7 +64,7 @@ export class PopoverComponent { */ lockScroll = input(false); - @ViewChild("floatUiComponent") floatUiComponent!: NgxFloatUiContentComponent; + readonly floatUiComponent = viewChild.required(NgxFloatUiContentComponent); private readonly document = inject(DOCUMENT); private readonly renderer = inject(Renderer2); diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 251b57141..4e36aeb1d 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -823,6 +823,21 @@ export const translationsMap = { 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: "Открыть календарь", + }, }; export type TediTranslationsMap = { From f4f2b53ff6fcc76d37fbec550c9f182d48656334 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Mon, 24 Nov 2025 09:02:15 +0200 Subject: [PATCH 4/5] feat(date-picker): add date-picker tests, dropdown tests #190 --- .../date-picker/date-picker.component.html | 348 +++++++--- .../date-picker/date-picker.component.scss | 91 ++- .../date-picker/date-picker.component.spec.ts | 651 ++++++++++++++++++ .../form/date-picker/date-picker.component.ts | 277 +++++--- .../form/date-picker/date-picker.stories.ts | 54 +- .../dropdown-trigger.directive.ts | 5 +- .../dropdown/dropdown.component.spec.ts | 344 +++++++++ .../overlay/dropdown/dropdown.component.ts | 2 +- .../overlay/dropdown/dropdown.stories.ts | 131 +++- tedi/components/overlay/dropdown/index.ts | 4 + tedi/components/overlay/index.ts | 3 +- tedi/services/translation/translations.ts | 98 +++ 12 files changed, 1799 insertions(+), 209 deletions(-) create mode 100644 tedi/components/form/date-picker/date-picker.component.spec.ts create mode 100644 tedi/components/overlay/dropdown/dropdown.component.spec.ts create mode 100644 tedi/components/overlay/dropdown/index.ts diff --git a/tedi/components/form/date-picker/date-picker.component.html b/tedi/components/form/date-picker/date-picker.component.html index 9c8937bd8..a7e5a7959 100644 --- a/tedi/components/form/date-picker/date-picker.component.html +++ b/tedi/components/form/date-picker/date-picker.component.html @@ -12,10 +12,13 @@ [attr.placeholder]="inputPlaceholder()" [attr.aria-expanded]="!inputDisabled() && popover().floatUiComponent().state" [attr.aria-controls]="uniqueId" + [attr.aria-readonly]="!allowManualInput()" + [readOnly]="!allowManualInput()" [value]="inputValue()" [disabled]="inputDisabled()" (input)="onInput($event)" (blur)="onInputBlur()" + (click)="onInputClick()" />
    @if (selected()) { @@ -53,70 +56,121 @@ -
    +
    - @if (showControls()) { - - } -
    - @if (showMonthDropdown()) { - + + + } + +
    + @if (monthMode() === "dropdown") { + + + + @for (month of monthNames; track i; let i = $index) { +
  • + {{ month() }} +
  • + } +
    +
    + } @else if (monthMode() === "grid") { - + {{ monthNames[month().getMonth()]() }} +
    + } + + @if (yearMode() === "dropdown") { + - @for (month of months; track i; let i = $index) { -
  • - {{ month() }} -
  • - } - -
    - } - @if (showYearDropdown()) { - + + + @for (year of years(); track year) { +
  • + {{ year }} +
  • + } +
    +
    + } @else if (yearMode() === "grid") { - - @for (year of years(); track year) { -
  • - {{ year }} -
  • - } -
    -
    - } -
    + } @else if (yearMode() === "label") { +
    + {{ selectedYear() }} +
    + } +
    - @if (showControls()) { + @if (showNavigation()) { + + } + } @else if (currentView() === "month-grid") { +
    + {{ monthNames[month().getMonth()]() }} +
    + } @else if (currentView() === "year-grid") { +
    {{ selectedYear() }}
    + }
    -
    - @for (wd of weekDays; track $index; let i = $index) { -
    - {{ wd() }} -
    - } -
    + @if (currentView() === "calendar-grid") { +
    + @if (showWeekNumbers()) { +
    + } + @for (wd of weekDays; track $index; let i = $index) { +
    + {{ wd() }} +
    + } +
    -
    - @for (week of weekRows(); track $index) { -
    - @for (day of week; track day.date) { -
    - } @else { - {{ day.date.getDate() }} - } - - } -
    - } -
    + } + + } +
    + } + + } @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 index e3b55056f..ce60198fe 100644 --- a/tedi/components/form/date-picker/date-picker.component.scss +++ b/tedi/components/form/date-picker/date-picker.component.scss @@ -165,6 +165,11 @@ tedi-date-picker { } } + &__label { + color: var(--general-text-primary); + font-weight: 500; + } + &__nav { font-size: var(--button-icon-inner-icon-only-size) !important; } @@ -173,6 +178,10 @@ tedi-date-picker { display: grid; grid-template-columns: repeat(7, 1fr); padding: 0 var(--card-padding-md-default); + + &--numbered { + grid-template-columns: repeat(8, 1fr); + } } &__weekday { @@ -191,7 +200,6 @@ tedi-date-picker { &__grid { display: flex; flex-direction: column; - gap: 0; padding: 0 var(--card-padding-md-default) var(--card-padding-md-default) var(--card-padding-md-default); } @@ -199,6 +207,22 @@ tedi-date-picker { &__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 { @@ -207,7 +231,6 @@ tedi-date-picker { display: flex; justify-content: center; align-items: center; - gap: 10px; flex-shrink: 0; border: none; background: none; @@ -263,4 +286,68 @@ tedi-date-picker { 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 index aa3b2feae..5cb638ee8 100644 --- a/tedi/components/form/date-picker/date-picker.component.ts +++ b/tedi/components/form/date-picker/date-picker.component.ts @@ -32,6 +32,8 @@ export interface DatePickerDay { 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 @@ -80,13 +82,13 @@ export class DatePickerComponent implements OnInit { ); /** Shows or hides the calendar navigation controls (previous/next month buttons). */ - readonly showControls = input(true); + readonly showNavigation = input(true); - /** Toggle visibility of the month selection dropdown in the header. */ - readonly showMonthDropdown = input(true); + /** Month selector mode: none | label | grid | dropdown */ + readonly monthMode = input("dropdown"); - /** Toggle visibility of the year selection dropdown in the header. */ - readonly showYearDropdown = input(true); + /** 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); @@ -109,44 +111,23 @@ export class DatePickerComponent implements OnInit { /** Is input disabled? */ readonly inputDisabled = input(false); - readonly inputValue = signal(""); - - /** Keyboard active date (what receives keyboard focus) */ - readonly activeDate = signal(null); + /** Is manual typing into input allowed? */ + readonly allowManualInput = input(true); - readonly inputElement = - viewChild.required>("inputElement"); - readonly gridElement = - viewChild.required>("gridElement"); - readonly popover = viewChild.required(PopoverComponent); + /** Should show week numbers before calendar grid? */ + readonly showWeekNumbers = input(false); - readonly translationService = inject(TediTranslationService); + /** Current view of datepicker (months grid, years grid or calendar grid) */ + readonly currentView = signal("calendar-grid"); - 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"), - ]; + /** Shown input value */ + readonly inputValue = signal(""); - readonly months = [ - 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"), - ]; + /** 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(() => { @@ -164,6 +145,59 @@ export class DatePickerComponent implements OnInit { ); }); + 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(); @@ -218,40 +252,53 @@ export class DatePickerComponent implements OnInit { return cells; }); - 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 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; + readonly inputElement = + viewChild.required>("inputElement"); + readonly gridElement = + viewChild.required>("gridElement"); + readonly popover = viewChild.required(PopoverComponent); - return this.getFirstEnabledDayOfMonth(prevYear, finalPrevMonth) !== null; - }); + readonly translationService = inject(TediTranslationService); - readonly canGoNext = computed(() => { - const current = this.month(); - const year = current.getFullYear(); - const month = current.getMonth(); + 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"), + ]; - const nextMonth = month + 1; - const nextYear = nextMonth > 11 ? year + 1 : year; - const finalNextMonth = nextMonth % 12; + 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"), + ]; - return this.getFirstEnabledDayOfMonth(nextYear, finalNextMonth) !== null; - }); + 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(); @@ -276,6 +323,18 @@ export class DatePickerComponent implements OnInit { 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; @@ -283,14 +342,6 @@ export class DatePickerComponent implements OnInit { this.inputValue.set(this.format(day.date)); } - 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)); - } - isSelected(date: Date): boolean { return ( !!this.selected() && @@ -302,18 +353,45 @@ export class DatePickerComponent implements OnInit { 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) { @@ -385,7 +463,22 @@ export class DatePickerComponent implements OnInit { } } + 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); @@ -395,6 +488,8 @@ export class DatePickerComponent implements OnInit { } onInputBlur() { + if (!this.allowManualInput()) return; + const selected = this.selected(); const parsed = this.parseDate(this.inputValue()); @@ -406,6 +501,18 @@ export class DatePickerComponent implements OnInit { } } + 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); @@ -443,7 +550,7 @@ export class DatePickerComponent implements OnInit { ); if (btn && document.activeElement !== btn) { - btn.focus(); + btn.focus({ preventScroll: true }); } }); } @@ -527,4 +634,20 @@ export class DatePickerComponent implements OnInit { 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 index 8fe17548e..60dab5f70 100644 --- a/tedi/components/form/date-picker/date-picker.stories.ts +++ b/tedi/components/form/date-picker/date-picker.stories.ts @@ -61,24 +61,30 @@ export default { defaultValue: { summary: "true" }, }, }, - showMonthDropdown: { - description: - "Toggle visibility of the month selection dropdown in the header.", - control: { type: "boolean" }, + monthMode: { + description: "Month selector mode: none | label | grid | dropdown", + control: "radio", + options: ["none", "label", "grid", "dropdown"], table: { category: "inputs", - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, + type: { + summary: "DatePickerSelectorMode", + detail: "none | label | grid | dropdown", + }, + defaultValue: { summary: "dropdown" }, }, }, - showYearDropdown: { - description: - "Toggle visibility of the year selection dropdown in the header.", - control: { type: "boolean" }, + yearMode: { + description: "Year selector mode: none | label | grid | dropdown", + control: "radio", + options: ["none", "label", "grid", "dropdown"], table: { category: "inputs", - type: { summary: "boolean" }, - defaultValue: { summary: "true" }, + type: { + summary: "DatePickerSelectorMode", + detail: "none | label | grid | dropdown", + }, + defaultValue: { summary: "dropdown" }, }, }, startYear: { @@ -149,6 +155,24 @@ export default { 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; @@ -166,8 +190,8 @@ export const Default: StoryObj = { selected: next, month: today, showControls: true, - showMonthDropdown: true, - showYearDropdown: true, + monthMode: "dropdown", + yearMode: "dropdown", disabled: null, startYear: null, endYear: null, @@ -176,6 +200,8 @@ export const Default: StoryObj = { inputState: "default", inputSize: "default", inputDisabled: false, + allowManualInput: true, + showWeekNumbers: false, }; })(), render: (args) => ({ diff --git a/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts b/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts index 57b889ac9..4a60b32ea 100644 --- a/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts +++ b/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts @@ -7,6 +7,8 @@ import { } from "@angular/core"; import { DropdownComponent } from "../dropdown.component"; +export type DropdownTriggerAriaHasPopup = "menu" | "listbox" | "true"; + @Directive({ standalone: true, selector: "[tedi-dropdown-trigger]", @@ -20,7 +22,8 @@ import { DropdownComponent } from "../dropdown.component"; }, }) export class DropdownTriggerDirective { - readonly ariaHaspopup = input<"menu" | "listbox" | "true">("menu"); + /** 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); 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..69abbf2e0 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.component.spec.ts @@ -0,0 +1,344 @@ +/* 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); + }); + }); +}); diff --git a/tedi/components/overlay/dropdown/dropdown.component.ts b/tedi/components/overlay/dropdown/dropdown.component.ts index b8f4cff14..ddbabf85e 100644 --- a/tedi/components/overlay/dropdown/dropdown.component.ts +++ b/tedi/components/overlay/dropdown/dropdown.component.ts @@ -51,7 +51,7 @@ export class DropdownComponent implements AfterContentChecked, OnDestroy { /** * 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 body + * @default "" */ readonly appendTo = input(""); diff --git a/tedi/components/overlay/dropdown/dropdown.stories.ts b/tedi/components/overlay/dropdown/dropdown.stories.ts index be3d12def..985957a45 100644 --- a/tedi/components/overlay/dropdown/dropdown.stories.ts +++ b/tedi/components/overlay/dropdown/dropdown.stories.ts @@ -1,10 +1,39 @@ import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular"; -import { DropdownComponent } from "./dropdown.component"; -import { DropdownTriggerDirective } from "./dropdown-trigger/dropdown-trigger.directive"; -import { DropdownContentComponent } from "./dropdown-content/dropdown-content.component"; +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, @@ -19,22 +48,112 @@ export default { ], }), ], + 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; -export const Default: StoryObj = { +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 7439217de..90694ce40 100644 --- a/tedi/components/overlay/index.ts +++ b/tedi/components/overlay/index.ts @@ -1,2 +1,3 @@ +export * from "./dropdown"; export * from "./tooltip"; -export * from "./popover"; \ No newline at end of file +export * from "./popover"; diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 4e36aeb1d..ef00d7663 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -697,6 +697,90 @@ export const translationsMap = { 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"], @@ -838,6 +922,20 @@ export const translationsMap = { 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 = { From 184187b3ebd06ad7fb2316c51ebbedcbb6014ee0 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Mon, 24 Nov 2025 09:34:30 +0200 Subject: [PATCH 5/5] feat(dropdown): add missing tests #190 --- .../dropdown/dropdown.component.spec.ts | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/tedi/components/overlay/dropdown/dropdown.component.spec.ts b/tedi/components/overlay/dropdown/dropdown.component.spec.ts index 69abbf2e0..09a415582 100644 --- a/tedi/components/overlay/dropdown/dropdown.component.spec.ts +++ b/tedi/components/overlay/dropdown/dropdown.component.spec.ts @@ -341,4 +341,226 @@ describe("DropdownComponent", () => { 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(); + }); + }); });