From b6e46ba553997e63a524a695a2fa0c752a9d2988 Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Mon, 25 Aug 2025 11:46:33 +0200 Subject: [PATCH 001/126] Enhance Calendar component with negative and indicator date features, update styles, and improve date handling --- .../calendar/src/calendar.stories.ts | 54 +++++++++++++++++-- packages/components/calendar/src/calendar.ts | 11 +++- .../components/calendar/src/month-view.scss | 40 +++++++++++--- .../components/calendar/src/month-view.ts | 11 +++- .../components/calendar/src/select-day.scss | 10 ++-- .../components/calendar/src/select-day.ts | 27 +++++++--- packages/components/calendar/src/utils.ts | 10 ++++ .../components/date-field/src/date-field.ts | 2 + packages/components/shared/src/converters.ts | 1 + .../shared/src/converters/date-list.ts | 13 +++++ .../components/shared/src/converters/date.ts | 1 + 11 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 packages/components/shared/src/converters/date-list.ts diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index a9eaa1a342..ba864333f3 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -6,7 +6,17 @@ import { type Calendar } from './calendar.js'; type Props = Pick< Calendar, - 'firstDayOfWeek' | 'locale' | 'max' | 'min' | 'month' | 'readonly' | 'selected' | 'showToday' | 'showWeekNumbers' + | 'firstDayOfWeek' + | 'locale' + | 'indicator' + | 'max' + | 'min' + | 'month' + | 'readonly' + | 'selected' + | 'showToday' + | 'showWeekNumbers' + | 'negative' >; type Story = StoryObj; @@ -38,9 +48,27 @@ export default { }, selected: { control: 'date' + }, + negative: { + control: 'date' + }, + indicator: { + control: 'date' } }, - render: ({ firstDayOfWeek, locale, max, min, month, readonly, selected, showToday, showWeekNumbers }) => { + render: ({ + firstDayOfWeek, + indicator, + locale, + max, + min, + month, + negative, + readonly, + selected, + showToday, + showWeekNumbers + }) => { const parseDate = (value: string | Date | undefined): Date | undefined => { if (!value) { return undefined; @@ -60,6 +88,8 @@ export default { min=${ifDefined(parseDate(min)?.toISOString())} month=${ifDefined(parseDate(month)?.toISOString())} selected=${ifDefined(parseDate(selected)?.toISOString())} + negative=${ifDefined(negative?.map(date => date.toISOString()).join(','))} + indicator=${ifDefined(indicator?.map(date => date.toISOString()).join(','))} > `; } @@ -89,7 +119,25 @@ export const Readonly: Story = { export const Selected: Story = { args: { - selected: new Date(2024, 8, 15) + selected: new Date(1755640800000), + showToday: true, + month: new Date(1755640800000) + } +}; + +export const Negative: Story = { + args: { + negative: [new Date('2025-08-20'), new Date('2025-08-07')], + showToday: true, + month: new Date(1755640800000) + } +}; + +export const Indicator: Story = { + args: { + indicator: [new Date('2025-08-25'), new Date('2025-08-05')], + showToday: true, + month: new Date(1755640800000) } }; diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 04170c4c5b..c136d65ddd 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -1,6 +1,6 @@ import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { type EventEmitter, LocaleMixin, event } from '@sl-design-system/shared'; -import { dateConverter } from '@sl-design-system/shared/converters.js'; +import { dateConverter, dateListConverter } from '@sl-design-system/shared/converters.js'; import { type SlChangeEvent, type SlSelectEvent, type SlToggleEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; import { property, state } from 'lit/decorators.js'; @@ -66,6 +66,12 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The selected date. */ @property({ converter: dateConverter }) selected?: Date; + /** The list of dates that should have 'negative' styling. */ + @property({ converter: dateListConverter }) negative?: Date[]; + + /** The list of dates that should have 'negative' styling. */ + @property({ converter: dateListConverter }) indicator?: Date[]; + /** Highlights today's date when set. */ @property({ type: Boolean, attribute: 'show-today' }) showToday?: boolean; @@ -86,6 +92,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { } override render(): TemplateResult { + console.log(this.negative); return html` part !== ''); }; @@ -243,6 +251,7 @@ export class MonthView extends LocaleMixin(LitElement) { event.preventDefault(); event.stopPropagation(); + console.log(day.date); this.selectEvent.emit(day.date); } } diff --git a/packages/components/calendar/src/select-day.scss b/packages/components/calendar/src/select-day.scss index 1dfe3585d1..7e696d7d94 100644 --- a/packages/components/calendar/src/select-day.scss +++ b/packages/components/calendar/src/select-day.scss @@ -16,8 +16,8 @@ [part='header'] { align-items: center; display: flex; - padding-block-start: var(--sl-size-075); - padding-inline: var(--sl-size-075); + padding-block: var(--sl-size-075); + padding-inline: var(--sl-size-150) var(--sl-size-075); } .current-month, @@ -31,7 +31,11 @@ } .current-year { - margin-inline: var(--sl-size-050) auto; + margin-inline: var(--sl-size-200) auto; +} + +.next-month { + margin-inline: var(--sl-size-100) auto; } .days-of-week { diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index a557c12fb5..8f42b0908c 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -83,6 +83,12 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The selected date. */ @property({ converter: dateConverter }) selected?: Date; + /** The list of dates that should have 'negative' styling. */ + @property({ converter: dateConverter }) negative?: Date[]; + + /** The list of dates that should have an indicator. */ + @property({ converter: dateConverter }) indicator?: Date[]; + /** @internal Emits when the user selects a day. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; @@ -106,6 +112,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); + console.log(this.negative); if (changes.has('firstDayOfWeek') || changes.has('locale')) { const { locale, firstDayOfWeek } = this, @@ -131,13 +138,13 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { override render(): TemplateResult { return html`
- + - + - + - + @@ -156,8 +164,9 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { str`Next month, ${format(this.nextMonth!, this.locale, { month: 'long', year: 'numeric' })}`, { id: 'sl.calendar.nextMonth' } )} + class="next-month" fill="ghost" - variant="primary" + variant="secondary" > @@ -185,6 +194,8 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { .firstDayOfWeek=${this.firstDayOfWeek} .month=${this.previousMonth} .selected=${this.selected} + .negative=${this.negative} + .indicator=${this.indicator} aria-hidden="true" inert max=${ifDefined(this.max?.toISOString())} @@ -200,6 +211,8 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { .firstDayOfWeek=${this.firstDayOfWeek} .month=${this.month} .selected=${this.selected} + .negative=${this.negative} + .indicator=${this.indicator} locale=${ifDefined(this.locale)} max=${ifDefined(this.max?.toISOString())} min=${ifDefined(this.min?.toISOString())} @@ -211,6 +224,8 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { .firstDayOfWeek=${this.firstDayOfWeek} .month=${this.nextMonth} .selected=${this.selected} + .negative=${this.negative} + .indicator=${this.indicator} aria-hidden="true" inert locale=${ifDefined(this.locale)} diff --git a/packages/components/calendar/src/utils.ts b/packages/components/calendar/src/utils.ts index ca5a1b7705..43416c35ea 100644 --- a/packages/components/calendar/src/utils.ts +++ b/packages/components/calendar/src/utils.ts @@ -9,6 +9,7 @@ export interface Day { future?: boolean; highlight?: boolean; lastDayOfMonth?: boolean; + negative?: boolean; nextMonth?: boolean; past?: boolean; previousMonth?: boolean; @@ -121,6 +122,15 @@ export function isSameDate(day1?: Date, day2?: Date): boolean { ); } +export function isDateInList(date: Date, list?: Date[] | string): boolean { + if (!list) return false; + if (typeof list === 'string') { + list = list.split(',').map(item => new Date(item)); + } + console.log(typeof list); + return list.some(item => isSameDate(item, date)); +} + export function normalizeDateTime(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } diff --git a/packages/components/date-field/src/date-field.ts b/packages/components/date-field/src/date-field.ts index cba718ad15..9613779ce4 100644 --- a/packages/components/date-field/src/date-field.ts +++ b/packages/components/date-field/src/date-field.ts @@ -168,6 +168,7 @@ export class DateField extends LocaleMixin(FormControlMixin(ScopedElementsMixin( } if (changes.has('value')) { + console.log(this.value); this.input.value = this.value && this.#formatter ? this.#formatter.format(this.value) : ''; this.updateValidity(); } @@ -276,6 +277,7 @@ export class DateField extends LocaleMixin(FormControlMixin(ScopedElementsMixin( event.stopPropagation(); this.value = event.detail; + this.value.setHours(0, 0, 0); // we don't need a time for the date picker. this.changeEvent.emit(this.value); this.textField?.updateValidity(); diff --git a/packages/components/shared/src/converters.ts b/packages/components/shared/src/converters.ts index a8c8b2a295..f469ccf400 100644 --- a/packages/components/shared/src/converters.ts +++ b/packages/components/shared/src/converters.ts @@ -1 +1,2 @@ export * from './converters/date.js'; +export * from './converters/date-list.js'; diff --git a/packages/components/shared/src/converters/date-list.ts b/packages/components/shared/src/converters/date-list.ts new file mode 100644 index 0000000000..98663dd0fa --- /dev/null +++ b/packages/components/shared/src/converters/date-list.ts @@ -0,0 +1,13 @@ +export const dateListConverter = { + fromAttribute: (value: string): Array => { + const dates = value + .split(',') + .filter(dateStr => !isNaN(Date.parse(dateStr))) + .map(dateStr => new Date(dateStr)); + return dates; + }, + toAttribute: (values: Array): string => { + const dates = values.filter(value => value instanceof Date).map(date => date.toISOString()); + return dates.join(','); + } +}; diff --git a/packages/components/shared/src/converters/date.ts b/packages/components/shared/src/converters/date.ts index 64476b0ecf..cb50d30aaa 100644 --- a/packages/components/shared/src/converters/date.ts +++ b/packages/components/shared/src/converters/date.ts @@ -2,6 +2,7 @@ export const dateConverter = { fromAttribute: (value: string): Date | undefined => { const date = Date.parse(value); + console.log(date); return isNaN(date) ? undefined : new Date(date); }, toAttribute: (value: Date | undefined): string => { From 288260f01726198e49396785a1ce08dd5571b924 Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Wed, 27 Aug 2025 10:05:36 +0200 Subject: [PATCH 002/126] Update Calendar component stories and styles to enhance negative and indicator date handling --- .../calendar/src/calendar.stories.ts | 36 ++++++++++++++- .../components/calendar/src/month-view.scss | 46 +++++++++++++------ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index ba864333f3..997cb12c18 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -127,7 +127,7 @@ export const Selected: Story = { export const Negative: Story = { args: { - negative: [new Date('2025-08-20'), new Date('2025-08-07')], + negative: [new Date(), new Date('2025-08-07')], showToday: true, month: new Date(1755640800000) } @@ -135,7 +135,7 @@ export const Negative: Story = { export const Indicator: Story = { args: { - indicator: [new Date('2025-08-25'), new Date('2025-08-05')], + indicator: [new Date(), new Date('2025-08-05')], showToday: true, month: new Date(1755640800000) } @@ -153,3 +153,35 @@ export const WeekNumbers: Story = { showWeekNumbers: true } }; + +export const All: Story = { + args: { + indicator: [ + getOffsetDate(0), + getOffsetDate(1), + getOffsetDate(6), + getOffsetDate(-6), + getOffsetDate(3), + getOffsetDate(8), + getOffsetDate(-8) + ], + negative: [ + getOffsetDate(2), + getOffsetDate(7), + getOffsetDate(-7), + getOffsetDate(3), + getOffsetDate(8), + getOffsetDate(-8) + ], + showToday: true, + month: new Date(), + max: getOffsetDate(5), + min: getOffsetDate(-5) + } +}; + +function getOffsetDate(offset: number): Date { + const date = new Date(); + date.setDate(date.getDate() + offset); + return date; +} diff --git a/packages/components/calendar/src/month-view.scss b/packages/components/calendar/src/month-view.scss index 96814ce1ee..f3a0ac0a5e 100644 --- a/packages/components/calendar/src/month-view.scss +++ b/packages/components/calendar/src/month-view.scss @@ -15,11 +15,12 @@ button { appearance: none; border: 0; border-radius: var(--sl-size-borderRadius-default); + box-sizing: border-box; color: var(--sl-color-foreground-plain); cursor: pointer; font: inherit; outline: transparent solid var(--sl-size-outlineWidth-default); - outline-offset: var(--sl-space-offset-focused); + outline-offset: var(--sl-size-outlineOffset-default); padding: 0; @media (prefers-reduced-motion: no-preference) { @@ -48,6 +49,7 @@ button { align-items: center; aspect-ratio: 1; background: color-mix(in srgb, var(--_bg-color), var(--_bg-mix-color) calc(100% * var(--_bg-opacity))); + border-radius: var(--sl-size-borderRadius-default); box-sizing: border-box; color: var(--sl-color-foreground-plain); display: inline-flex; @@ -62,13 +64,6 @@ button { color: var(--sl-color-foreground-disabled); } -[part~='highlight'] { - --_bg-color: var(--sl-color-background-info-subtle); - - border-radius: 50%; - color: var(--sl-color-foreground-info-plain); -} - [part~='today'] { border: var(--sl-size-borderWidth-subtle) solid var(--sl-color-border-bold); border-radius: var(--sl-size-borderRadius-default); @@ -82,14 +77,42 @@ button { color: var(--sl-color-foreground-selected-onBold); } +[part~='today'][part~='selected'] { + border-color: var(--sl-color-border-primary-plain); + box-shadow: inset 0 0 0 var(--sl-size-borderWidth-subtle) var(--sl-elevation-surface-raised-default); +} + [part~='negative'] { + --_bg-color: var(--sl-color-background-negative-subtlest); + --_bg-mix-color: var(--sl-color-background-negative-interactive-plain); + + color: var(--sl-color-foreground-negative-bold); +} + +[part~='negative'][part~='selected'] { --_bg-color: var(--sl-color-background-negative-bold); --_bg-mix-color: var(--sl-color-background-negative-interactive-bold); - border-radius: var(--sl-size-borderRadius-default); color: var(--sl-color-foreground-negative-onBold); } +[part~='negative'][part~='today'] { + border-color: var(--sl-color-border-negative-plain); +} + +[part~='unselectable'] { + &[part~='selected'] { + --_bg-color: var(--sl-color-background-disabled); + --_bg-mix-color: var(--sl-color-background-disabled); + + color: var(--sl-color-foreground-disabled); + } + + &[part~='today'][part~='selected'] { + border-color: var(--sl-color-border-disabled); + } +} + [part~='indicator']::after { background: var(--sl-color-background-accent-blue-bold); block-size: var(--sl-size-075); @@ -104,11 +127,6 @@ button { position: absolute; } -[part~='today'][part~='selected'] { - border-color: var(--sl-color-border-primary-plain); - box-shadow: inset 0 0 0 var(--sl-size-borderWidth-subtle) var(--sl-elevation-surface-raised-default); -} - [part~='week-day'], [part~='week-number'] { color: var(--sl-color-foreground-subtlest); From 46692a851aa5fb89c1584a9f8d7823da49053fbb Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Thu, 28 Aug 2025 11:16:01 +0200 Subject: [PATCH 003/126] Refactor Calendar component stories to improve rendering logic and enhance negative and indicator date handling; update styles in month-view.scss for better layout control. --- .../calendar/src/calendar.stories.ts | 84 ++++++++++++------- .../components/calendar/src/month-view.scss | 6 +- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index 997cb12c18..ed1f03c9b1 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -155,33 +155,61 @@ export const WeekNumbers: Story = { }; export const All: Story = { - args: { - indicator: [ - getOffsetDate(0), - getOffsetDate(1), - getOffsetDate(6), - getOffsetDate(-6), - getOffsetDate(3), - getOffsetDate(8), - getOffsetDate(-8) - ], - negative: [ - getOffsetDate(2), - getOffsetDate(7), - getOffsetDate(-7), - getOffsetDate(3), - getOffsetDate(8), - getOffsetDate(-8) - ], - showToday: true, - month: new Date(), - max: getOffsetDate(5), - min: getOffsetDate(-5) + render: () => { + const parseDate = (value: string | Date | undefined): Date | undefined => { + if (!value) { + return undefined; + } + + return value instanceof Date ? value : new Date(value); + }; + const getOffsetDate = (offset: number): Date => { + const date = new Date(); + date.setDate(date.getDate() + offset); + return date; + }; + const negative = { + indicator: [ + getOffsetDate(0), + getOffsetDate(1), + getOffsetDate(6), + getOffsetDate(-6), + getOffsetDate(3), + getOffsetDate(8), + getOffsetDate(-8) + ], + negative: [ + getOffsetDate(2), + getOffsetDate(7), + getOffsetDate(-7), + getOffsetDate(3), + getOffsetDate(8), + getOffsetDate(-8) + ], + showToday: true, + month: new Date(), + max: getOffsetDate(5), + min: getOffsetDate(-5) + }; + return html` + date.toISOString()).join(','))} + indicator=${ifDefined(negative.indicator?.map(date => date.toISOString()).join(','))} + > + date.toISOString()).join(','))} + indicator=${ifDefined(negative.indicator?.map(date => date.toISOString()).join(','))} + > + `; } }; - -function getOffsetDate(offset: number): Date { - const date = new Date(); - date.setDate(date.getDate() + offset); - return date; -} diff --git a/packages/components/calendar/src/month-view.scss b/packages/components/calendar/src/month-view.scss index f3a0ac0a5e..97997fec54 100644 --- a/packages/components/calendar/src/month-view.scss +++ b/packages/components/calendar/src/month-view.scss @@ -9,6 +9,7 @@ table { td, th { padding: var(--sl-size-075); + position: relative; } button { @@ -55,7 +56,6 @@ button { display: inline-flex; inline-size: var(--sl-size-300); justify-content: center; - position: relative; } [part~='previous-month'], @@ -82,7 +82,7 @@ button { box-shadow: inset 0 0 0 var(--sl-size-borderWidth-subtle) var(--sl-elevation-surface-raised-default); } -[part~='negative'] { +[part~='negative']:not([part~='unselectable']) { --_bg-color: var(--sl-color-background-negative-subtlest); --_bg-mix-color: var(--sl-color-background-negative-interactive-plain); @@ -122,7 +122,7 @@ button { color: var(--sl-color-foreground-info-onBold); content: ''; inline-size: var(--sl-size-075); - inset-block-end: calc(-1 * var(--sl-size-075)); + inset-block-end: calc(-1 * var(--sl-size-010)); inset-inline-start: calc(50% - var(--sl-size-075) / 2); position: absolute; } From 06258fda9763b3ff6ba270becaa7b4a78063643f Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Thu, 28 Aug 2025 17:12:09 +0200 Subject: [PATCH 004/126] Remove console logs and simplify conditions in Calendar and MonthView components --- packages/components/calendar/src/calendar.ts | 2 -- packages/components/calendar/src/month-view.scss | 7 ++++--- packages/components/calendar/src/month-view.ts | 3 +-- packages/components/calendar/src/select-day.ts | 1 - 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index c136d65ddd..123df84fc2 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -92,7 +92,6 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { } override render(): TemplateResult { - console.log(this.negative); return html` ${day.date.getDate()}` : html` `;\n } else if (day.currentMonth) {\n return html``;\n } else {\n return html`${day.date.getDate()}`;\n }\n },\n styles: `\n sl-month-view::part(finish) {\n background: var(--sl-color-success-plain);\n border-radius: 50%;\n color: var(--sl-color-text-inverted);\n }\n\n sl-month-view::part(finish):hover {\n background: var(--sl-color-success-bold);\n }\n\n sl-month-view::part(finish):active {\n background: var(--sl-color-success-heavy);\n }\n `\n }\n};\n\nexport const Selected: Story = {\n args: {\n month: new Date(2024, 11, 10),\n selected: new Date(2024, 11, 4)\n }\n};\n\nexport const Today: Story = {\n args: {\n showToday: true\n }\n};\n\nexport const WeekNumbers: Story = {\n args: {\n showWeekNumbers: true\n }\n};\n"], + "mappings": ";AAAA,SAAS,cAAc;AACvB,SAAS,YAAY;AAErB,SAAS,MAAM,eAAe;AAC9B,SAAS,iBAAiB;AAC1B,OAAO;AAoBP,KAAK,SAAS,MAAM;AAEpB,eAAe;AAAA,EACb,OAAO;AAAA,EACP,MAAM,CAAC,OAAO;AAAA,EACd,MAAM;AAAA,IACJ,qBAAqB;AAAA,IACrB,OAAO,oBAAI,KAAK;AAAA,IAChB,UAAU;AAAA,IACV,WAAW;AAAA,IACX,iBAAiB;AAAA,EACnB;AAAA,EACA,UAAU;AAAA,IACR,gBAAgB;AAAA,MACd,SAAS;AAAA,IACX;AAAA,IACA,QAAQ;AAAA,MACN,SAAS;AAAA,MACT,SAAS,CAAC,MAAM,SAAS,MAAM,MAAM,MAAM,MAAM,MAAM,SAAS,MAAM,MAAM,IAAI;AAAA,IAClF;AAAA,IACA,KAAK;AAAA,MACH,SAAS;AAAA,IACX;AAAA,IACA,KAAK;AAAA,MACH,SAAS;AAAA,IACX;AAAA,IACA,OAAO;AAAA,MACL,SAAS;AAAA,IACX;AAAA,IACA,UAAU;AAAA,MACR,OAAO,EAAE,SAAS,KAAK;AAAA,IACzB;AAAA,IACA,UAAU;AAAA,MACR,SAAS;AAAA,IACX;AAAA,IACA,QAAQ;AAAA,MACN,OAAO,EAAE,SAAS,KAAK;AAAA,IACzB;AAAA,EACF;AAAA,EACA,QAAQ,CAAC;AAAA,IACP;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,MAAM;AAAA,MACF,SACE;AAAA;AAAA,cAEM,MAAM;AAAA;AAAA,YAGZ,OAAO;AAAA;AAAA,gCAEiB,mBAAmB;AAAA,kBACjC,QAAQ;AAAA,oBACN,SAAS;AAAA,2BACF,eAAe;AAAA,0BAChB,UAAU,cAAc,CAAC;AAAA,eACpC,UAAU,MAAM,CAAC;AAAA,YACpB,UAAU,KAAK,YAAY,CAAC,CAAC;AAAA,YAC7B,UAAU,KAAK,YAAY,CAAC,CAAC;AAAA,cAC3B,UAAU,OAAO,YAAY,CAAC,CAAC;AAAA,iBAC5B,UAAU,UAAU,YAAY,CAAC,CAAC;AAAA,kBACjC,QAAQ;AAAA;AAAA;AAG1B;AAEO,aAAM,QAAe,CAAC;AAEtB,aAAM,iBAAwB;AAAA,EACnC,MAAM;AAAA,IACJ,gBAAgB;AAAA,EAClB;AACF;AAEO,aAAM,sBAA6B;AAAA,EACxC,MAAM;AAAA,IACJ,qBAAqB;AAAA,EACvB;AACF;AAEO,aAAM,SAAgB;AAAA,EAC3B,MAAM;AAAA,IACJ,OAAO,IAAI,KAAK,MAAM,GAAG,CAAC;AAAA,IAC1B,KAAK,IAAI,KAAK,MAAM,GAAG,EAAE;AAAA,IACzB,KAAK,IAAI,KAAK,MAAM,GAAG,EAAE;AAAA,EAC3B;AACF;AAEO,aAAM,WAAkB;AAAA,EAC7B,MAAM;AAAA,IACJ,UAAU;AAAA,EACZ;AACF;AAEO,aAAM,WAAkB;AAAA,EAC7B,MAAM;AAAA,IACJ,UAAU,CAAC,KAAU,cAAyB;AAC5C,YAAM,QAAQ,UAAU,YAAY,GAAG;AAEvC,UAAI,IAAI,gBAAgB,CAAC,GAAG,GAAG,GAAG,IAAI,IAAI,EAAE,EAAE,SAAS,IAAI,KAAK,QAAQ,CAAC,GAAG;AAC1E,cAAM,KAAK,WAAW;AAAA,MACxB;AAEA,UAAI,IAAI,gBAAgB,IAAI,KAAK,QAAQ,MAAM,IAAI;AACjD,cAAM,KAAK,QAAQ;AAEnB,eAAO,qBAAqB,MAAM,KAAK,GAAG,CAAC;AAAA,MAC7C,WAAW,IAAI,cAAc;AAC3B,eAAO,qBAAqB,MAAM,KAAK,GAAG,CAAC,IAAI,IAAI,KAAK,QAAQ,CAAC;AAAA,MACnE,OAAO;AACL,eAAO,mBAAmB,MAAM,KAAK,GAAG,CAAC,IAAI,IAAI,KAAK,QAAQ,CAAC;AAAA,MACjE;AAAA,IACF;AAAA,IACA,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeV;AACF;AAEO,aAAM,WAAkB;AAAA,EAC7B,MAAM;AAAA,IACJ,OAAO,IAAI,KAAK,MAAM,IAAI,EAAE;AAAA,IAC5B,UAAU,IAAI,KAAK,MAAM,IAAI,CAAC;AAAA,EAChC;AACF;AAEO,aAAM,QAAe;AAAA,EAC1B,MAAM;AAAA,IACJ,WAAW;AAAA,EACb;AACF;AAEO,aAAM,cAAqB;AAAA,EAChC,MAAM;AAAA,IACJ,iBAAiB;AAAA,EACnB;AACF;", + "names": [] +} diff --git a/packages/components/calendar/src/select-day.scss b/packages/components/calendar/src/select-day.scss index 7e696d7d94..f7ea054ca9 100644 --- a/packages/components/calendar/src/select-day.scss +++ b/packages/components/calendar/src/select-day.scss @@ -16,6 +16,7 @@ [part='header'] { align-items: center; display: flex; + justify-content: space-between; padding-block: var(--sl-size-075); padding-inline: var(--sl-size-150) var(--sl-size-075); } @@ -31,11 +32,15 @@ } .current-year { - margin-inline: var(--sl-size-200) auto; + margin-inline-start: var(--sl-size-200); +} + +.previous-month { + margin-inline-start: auto; } .next-month { - margin-inline: var(--sl-size-100) auto; + margin-inline-start: var(--sl-size-100) auto; } .days-of-week { diff --git a/packages/components/calendar/src/select-month.stories.ts b/packages/components/calendar/src/select-month.stories.ts new file mode 100644 index 0000000000..bef7eb044d --- /dev/null +++ b/packages/components/calendar/src/select-month.stories.ts @@ -0,0 +1,38 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html, nothing } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import '../register.js'; +import { SelectMonth } from './select-month.js'; + +type Props = Pick & { styles?: string }; +type Story = StoryObj; + +customElements.define('sl-select-month', SelectMonth); + +export default { + title: 'Date & Time/Select Month', + tags: ['draft'], + args: { + month: new Date() + }, + argTypes: { + month: { + control: 'date' + }, + styles: { + table: { disable: true } + } + }, + render: ({ month, styles }) => html` + ${styles + ? html` + + ` + : nothing} + + ` +} satisfies Meta; + +export const Basic: Story = {}; diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index f3606f9754..c90cf740ab 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -85,6 +85,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { currentYear = this.month.getFullYear(); return html` + dfdf
${currentYear} Date: Wed, 3 Sep 2025 09:15:33 +0200 Subject: [PATCH 007/126] Refactor SelectMonth component to replace sl-button with button for month selection; improve button styles and interactions for better accessibility and user experience. --- .../components/calendar/src/select-month.scss | 46 +++++++++++++++++++ .../components/calendar/src/select-month.ts | 8 ++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/components/calendar/src/select-month.scss b/packages/components/calendar/src/select-month.scss index 0d642277cd..9aff6203ea 100644 --- a/packages/components/calendar/src/select-month.scss +++ b/packages/components/calendar/src/select-month.scss @@ -39,3 +39,49 @@ li { flex: 1; } } + +button { + appearance: none; + border: 0; + border-radius: var(--sl-size-borderRadius-default); + box-sizing: border-box; + color: var(--sl-color-foreground-plain); + cursor: pointer; + font: inherit; + outline: transparent solid var(--sl-size-outlineWidth-default); + outline-offset: var(--sl-size-outlineOffset-default); + padding: var(--sl-space-new-2xs, 2px) var(--sl-space-new-md, 8px); + + @media (prefers-reduced-motion: no-preference) { + transition: 0.2s ease-in-out; + transition-property: background, border-radius, color; + } + + &:hover { + --_bg-opacity: var(--sl-opacity-interactive-plain-hover); + } + + &:active { + --_bg-opacity: var(--sl-opacity-interactive-plain-active); + } + + &:focus-visible { + outline-color: var(--sl-color-border-focused); + } +} + +[part~='month'] { + --_bg-color: transparent; + --_bg-mix-color: var(--sl-color-background-info-interactive-plain); + --_bg-opacity: var(--sl-opacity-interactive-plain-idle); + + align-items: center; + aspect-ratio: 1; + background: color-mix(in srgb, var(--_bg-color), var(--_bg-mix-color) calc(100% * var(--_bg-opacity))); + border-radius: var(--sl-size-borderRadius-default); + box-sizing: border-box; + color: var(--sl-color-foreground-plain); + display: inline-flex; + inline-size: var(--sl-size-300); + justify-content: center; +} diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index c90cf740ab..22fe210ea7 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -85,7 +85,6 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { currentYear = this.month.getFullYear(); return html` - dfdf
${currentYear} html`
  • - this.#onClick(value)} - .fill=${currentMonth === value ? 'solid' : 'ghost'} - .variant=${currentMonth === value ? 'primary' : 'default'} ?autofocus=${currentMonth === value} aria-label=${long} aria-pressed=${ifDefined(currentMonth === value ? 'true' : undefined)} > ${short} - +
  • ` )} From cfca76b9c586ed1d97395de7d7f7a4b7846d1c3d Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Mon, 8 Sep 2025 10:33:27 +0200 Subject: [PATCH 008/126] Enhance calendar component: add min/max date properties to SelectMonth, improve month selection logic, and update styles for better layout and accessibility. --- packages/components/calendar/src/calendar.ts | 3 + .../components/calendar/src/select-day.ts | 78 +++++++++++++-- .../components/calendar/src/select-month.scss | 38 ++++++-- .../calendar/src/select-month.stories.ts | 67 ++++++++++--- .../components/calendar/src/select-month.ts | 97 +++++++++++++++---- packages/components/calendar/src/utils.ts | 7 ++ 6 files changed, 241 insertions(+), 49 deletions(-) diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 123df84fc2..97d6dd9978 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -117,8 +117,11 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { () => html` ` ], diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 475ea0c5c1..78046aae3b 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -135,16 +135,76 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } override render(): TemplateResult { + const canSelectNextYear = this.displayMonth + ? !this.max || (this.max && this.displayMonth.getFullYear() + 1 <= this.max.getFullYear()) + : false, + canSelectPreviousYear = this.displayMonth + ? !this.min || (this.min && this.displayMonth.getFullYear() - 1 >= this.min.getFullYear()) + : false, + canSelectNextMonth = this.nextMonth + ? !this.max || (this.max && this.nextMonth?.getTime() + 1 <= this.max.getTime()) + : false, + canSelectPreviousMonth = this.previousMonth + ? !this.min || + (this.min && this.previousMonth?.getTime() >= new Date(this.min.getFullYear(), this.min.getMonth()).getTime()) + : false; + console.log({ + displayMonth: this.displayMonth, + next: this.nextMonth, + previous: this.previousMonth, + max: this.max, + min: this.min, + canSelectNextMonth, + canSelectPreviousMonth + }); return html`
    - - - - - - - - + ${canSelectPreviousMonth || canSelectNextMonth + ? html` + + + + + ` + : html` + + `} + ${canSelectPreviousYear || canSelectNextYear + ? html` + + + + + ` + : html` + + `} @@ -166,6 +227,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { class="next-month" fill="ghost" variant="secondary" + ?disabled=${!canSelectNextMonth} > diff --git a/packages/components/calendar/src/select-month.scss b/packages/components/calendar/src/select-month.scss index 9aff6203ea..7b070385bb 100644 --- a/packages/components/calendar/src/select-month.scss +++ b/packages/components/calendar/src/select-month.scss @@ -23,7 +23,8 @@ sl-button { ol { display: grid; - gap: var(--sl-size-050); + flex: 1; + gap: var(--sl-size-200) var(--sl-size-100); grid-template-columns: repeat(3, auto); list-style: none; margin: 0; @@ -31,13 +32,11 @@ ol { } li { + align-items: center; display: inline-flex; + justify-content: center; margin: 0; padding: 0; - - sl-button { - flex: 1; - } } button { @@ -50,7 +49,6 @@ button { font: inherit; outline: transparent solid var(--sl-size-outlineWidth-default); outline-offset: var(--sl-size-outlineOffset-default); - padding: var(--sl-space-new-2xs, 2px) var(--sl-space-new-md, 8px); @media (prefers-reduced-motion: no-preference) { transition: 0.2s ease-in-out; @@ -76,12 +74,36 @@ button { --_bg-opacity: var(--sl-opacity-interactive-plain-idle); align-items: center; - aspect-ratio: 1; background: color-mix(in srgb, var(--_bg-color), var(--_bg-mix-color) calc(100% * var(--_bg-opacity))); + border: var(--sl-size-borderWidth-subtle) solid transparent; border-radius: var(--sl-size-borderRadius-default); box-sizing: border-box; color: var(--sl-color-foreground-plain); display: inline-flex; - inline-size: var(--sl-size-300); + flex: 0; justify-content: center; + padding: calc(var(--sl-size-025) - var(--sl-size-borderWidth-subtle)) + calc(var(--sl-size-100) - var(--sl-size-borderWidth-action)); +} + +:host([show-today]) [part~='today'] { + border: var(--sl-size-borderWidth-subtle) solid var(--sl-color-border-bold); + border-radius: var(--sl-size-borderRadius-default); +} + +[part~='unselectable'] { + color: var(--sl-color-foreground-disabled); +} + +[part~='selected'] { + --_bg-color: var(--sl-color-background-selected-bold); + --_bg-mix-color: var(--sl-color-background-selected-interactive-bold); + + border-radius: var(--sl-size-borderRadius-default); + color: var(--sl-color-foreground-selected-onBold); +} + +:host([show-today]) [part~='today'][part~='selected'] { + border-color: var(--sl-color-border-primary-plain); + box-shadow: inset 0 0 0 var(--sl-size-borderWidth-subtle) var(--sl-elevation-surface-raised-default); } diff --git a/packages/components/calendar/src/select-month.stories.ts b/packages/components/calendar/src/select-month.stories.ts index bef7eb044d..a2e26c5476 100644 --- a/packages/components/calendar/src/select-month.stories.ts +++ b/packages/components/calendar/src/select-month.stories.ts @@ -1,10 +1,11 @@ import { type Meta, type StoryObj } from '@storybook/web-components-vite'; import { html, nothing } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { useArgs } from 'storybook/internal/preview-api'; import '../register.js'; import { SelectMonth } from './select-month.js'; -type Props = Pick & { styles?: string }; +type Props = Pick & { styles?: string }; type Story = StoryObj; customElements.define('sl-select-month', SelectMonth); @@ -13,26 +14,66 @@ export default { title: 'Date & Time/Select Month', tags: ['draft'], args: { - month: new Date() + month: new Date(), + max: new Date(new Date().getFullYear(), 11, 31), + min: new Date(new Date().getFullYear(), 0, 1), + showToday: true }, argTypes: { month: { control: 'date' }, + max: { + control: 'date' + }, + min: { + control: 'date' + }, + showToday: { + control: 'boolean' + }, styles: { table: { disable: true } } }, - render: ({ month, styles }) => html` - ${styles - ? html` - - ` - : nothing} - - ` + render: ({ month, max, min, styles, showToday }) => { + const [_, updateArgs] = useArgs(); + const parseDate = (value: string | Date | undefined): Date | undefined => { + if (!value) { + return undefined; + } + + return value instanceof Date ? value : new Date(value); + }; + + const onSelectMonth = (event: CustomEvent) => { + console.log('Month selected:', event.detail.getFullYear(), event.detail.getMonth()); + updateArgs({ month: new Date(event.detail.getFullYear(), event.detail.getMonth(), 1).getTime() }); //needs to be set to the 'time' otherwise Storybook chokes on the date format 🤷 + }; + + return html` + ${styles + ? html` + + ` + : nothing} + + `; + } } satisfies Meta; -export const Basic: Story = {}; +export const Basic: Story = { + args: { + max: new Date(2025, 10, 1), + min: new Date(2025, 2, 1), + month: new Date(2025, 7, 1) + } +}; diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 22fe210ea7..8e7ba41aa6 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -1,6 +1,7 @@ import { msg, str } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { Button } from '@sl-design-system/button'; +import { FormatDate } from '@sl-design-system/format-date'; import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, @@ -10,11 +11,12 @@ import { event } from '@sl-design-system/shared'; import { dateConverter } from '@sl-design-system/shared/converters.js'; -import { type SlSelectEvent } from '@sl-design-system/shared/events.js'; +import { type SlSelectEvent, SlToggleEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; import { property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import styles from './select-month.scss.js'; +import { Month } from './utils.js'; declare global { interface HTMLElementTagNameMap { @@ -26,6 +28,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { static get scopedElements(): ScopedElementsMap { return { 'sl-button': Button, + 'sl-format-date': FormatDate, 'sl-icon': Icon }; } @@ -55,8 +58,29 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The month/year to display. */ @property({ converter: dateConverter }) month = new Date(); + /** + * The maximum date selectable in the month. + * @default undefined + */ + @property({ converter: dateConverter }) max?: Date; + + /** + * The minimum date selectable in the month. + * @default undefined + */ + @property({ converter: dateConverter }) min?: Date; + + /** + * Highlights the current month when set. + * @default false + */ + @property({ type: Boolean, attribute: 'show-today' }) showToday?: boolean; + /** @internal The months to display. */ - @state() months: Array<{ short: string; long: string; value: number }> = []; + @state() months: Month[] = []; + + /** @internal Emits when the user clicks the month/year button. */ + @event({ name: 'sl-toggle' }) toggleEvent!: EventEmitter>; /** @internal Emits when the user selects a month. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; @@ -64,9 +88,9 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); - if (changes.has('locale')) { + if (changes.has('locale') || changes.has('month') || changes.has('min') || changes.has('max')) { const formatShort = new Intl.DateTimeFormat(this.locale, { month: 'short' }), - formatLong = new Intl.DateTimeFormat(this.locale, { month: 'long', year: 'numeric' }); + formatLong = new Intl.DateTimeFormat(this.locale, { month: 'long' }); this.months = Array.from({ length: 12 }, (_, i) => { const date = new Date(this.month.getFullYear(), i, 1); @@ -74,7 +98,11 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { return { short: formatShort.format(date), long: formatLong.format(date), - value: i + value: i, + unselectable: !( + (!this.min || date >= new Date(this.min.getFullYear(), this.min.getMonth(), 1)) && + (!this.max || date <= new Date(this.max.getFullYear(), this.max.getMonth(), 1)) + ) }; }); } @@ -82,16 +110,26 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { override render(): TemplateResult { const currentMonth = this.month.getMonth(), - currentYear = this.month.getFullYear(); + currentYear = this.month.getFullYear(), + canSelectNextYear = !this.max || (this.max && this.month.getFullYear() + 1 <= this.max.getFullYear()), + canSelectPreviousYear = !this.min || (this.min && this.month.getFullYear() - 1 >= this.min.getFullYear()); return html`
    - ${currentYear} + ${canSelectPreviousYear || canSelectNextYear + ? html` + + + + + ` + : html`${currentYear}`} @@ -100,34 +138,53 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { aria-label=${msg(str`Next year, ${currentYear + 1}`, { id: 'sl.calendar.nextYear' })} fill="ghost" variant="primary" + ?disabled=${!canSelectNextYear} >
      - ${this.months.map( - ({ short, long, value }) => html` + ${this.months.map(month => { + const parts = this.getMonthParts(month).join(' '); + return html`
    1. - + ${month.unselectable + ? html`${month.long}` + : html` + + `}
    2. - ` - )} + `; + })}
    `; } + /** Returns an array of part names for a day. */ + getMonthParts = (month: Month): string[] => { + return [ + 'month', + month.value === new Date().getMonth() && this.month.getFullYear() === new Date().getFullYear() ? 'today' : '', + month.unselectable ? 'unselectable' : '', + this.month.getMonth() === month.value && this.month.getFullYear() === new Date().getFullYear() ? 'selected' : '' + ].filter(part => part !== ''); + }; + #onClick(month: number): void { this.selectEvent.emit(new Date(this.month.getFullYear(), month)); } + #onToggleYearSelect(): void { + this.toggleEvent.emit('year'); + } + #onKeydown(event: KeyboardEvent): void { if (event.key === 'Escape') { event.preventDefault(); diff --git a/packages/components/calendar/src/utils.ts b/packages/components/calendar/src/utils.ts index 2a8f36c9f9..ee4404696a 100644 --- a/packages/components/calendar/src/utils.ts +++ b/packages/components/calendar/src/utils.ts @@ -26,6 +26,13 @@ export interface Week { days: Day[]; } +export interface Month { + short: string; + long: string; + value: number; + unselectable?: boolean; +} + export type WeekDayNamesStyle = 'long' | 'short' | 'narrow'; export type WeekDayNames = { From 3538ee1d27cb7aac457e18a81375021f2f46c40e Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Mon, 8 Sep 2025 15:27:03 +0200 Subject: [PATCH 009/126] Refactor SelectYear component: update year property to use dateConverter, enhance year selection logic, and improve button accessibility and styles. --- packages/components/calendar/src/calendar.ts | 7 +- .../components/calendar/src/select-day.ts | 9 --- .../src/select-month.stories.d copy.ts | 39 +++++++++ .../components/calendar/src/select-year.scss | 74 ++++++++++++++++- .../calendar/src/select-year.stories.ts | 79 +++++++++++++++++++ .../components/calendar/src/select-year.ts | 75 ++++++++++++++---- 6 files changed, 249 insertions(+), 34 deletions(-) create mode 100644 packages/components/calendar/src/select-month.stories.d copy.ts create mode 100644 packages/components/calendar/src/select-year.stories.ts diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 97d6dd9978..3c448beac5 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -125,12 +125,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { > ` ], - [ - 'year', - () => html` - - ` - ] + ['year', () => html``] ])} `; } diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 78046aae3b..539e730fce 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -148,15 +148,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { ? !this.min || (this.min && this.previousMonth?.getTime() >= new Date(this.min.getFullYear(), this.min.getMonth()).getTime()) : false; - console.log({ - displayMonth: this.displayMonth, - next: this.nextMonth, - previous: this.previousMonth, - max: this.max, - min: this.min, - canSelectNextMonth, - canSelectPreviousMonth - }); return html`
    ${canSelectPreviousMonth || canSelectNextMonth diff --git a/packages/components/calendar/src/select-month.stories.d copy.ts b/packages/components/calendar/src/select-month.stories.d copy.ts new file mode 100644 index 0000000000..ee58932251 --- /dev/null +++ b/packages/components/calendar/src/select-month.stories.d copy.ts @@ -0,0 +1,39 @@ +import { type StoryObj } from '@storybook/web-components-vite'; +import '../register.js'; +import { SelectMonth } from './select-month.js'; +type Props = Pick & { + styles?: string; +}; +type Story = StoryObj; +declare const _default: { + title: string; + tags: string[]; + args: { + month: Date; + max: Date; + min: Date; + showToday: true; + }; + argTypes: { + month: { + control: 'date'; + }; + max: { + control: 'date'; + }; + min: { + control: 'date'; + }; + showToday: { + control: 'boolean'; + }; + styles: { + table: { + disable: true; + }; + }; + }; + render({ month, max, min, styles, showToday }: Props): import('lit-html').TemplateResult<1>; +}; +export default _default; +export declare const Basic: Story; diff --git a/packages/components/calendar/src/select-year.scss b/packages/components/calendar/src/select-year.scss index 1b8c68d59b..11a6f1508b 100644 --- a/packages/components/calendar/src/select-year.scss +++ b/packages/components/calendar/src/select-year.scss @@ -23,19 +23,89 @@ sl-button { ol { display: grid; + flex: 1; gap: var(--sl-size-050); + gap: var(--sl-size-200) var(--sl-size-100); grid-template-columns: repeat(3, auto); + justify-items: center; list-style: none; margin: 0; padding: 0; } li { + align-items: center; display: inline-flex; + justify-content: center; margin: 0; padding: 0; +} + +button { + appearance: none; + border: 0; + border-radius: var(--sl-size-borderRadius-default); + box-sizing: border-box; + color: var(--sl-color-foreground-plain); + cursor: pointer; + font: inherit; + outline: transparent solid var(--sl-size-outlineWidth-default); + outline-offset: var(--sl-size-outlineOffset-default); - sl-button { - flex: 1; + @media (prefers-reduced-motion: no-preference) { + transition: 0.2s ease-in-out; + transition-property: background, border-radius, color; } + + &:hover { + --_bg-opacity: var(--sl-opacity-interactive-plain-hover); + } + + &:active { + --_bg-opacity: var(--sl-opacity-interactive-plain-active); + } + + &:focus-visible { + outline-color: var(--sl-color-border-focused); + } +} + +[part~='year'] { + --_bg-color: transparent; + --_bg-mix-color: var(--sl-color-background-info-interactive-plain); + --_bg-opacity: var(--sl-opacity-interactive-plain-idle); + + align-items: center; + background: color-mix(in srgb, var(--_bg-color), var(--_bg-mix-color) calc(100% * var(--_bg-opacity))); + border: var(--sl-size-borderWidth-subtle) solid transparent; + border-radius: var(--sl-size-borderRadius-default); + box-sizing: border-box; + color: var(--sl-color-foreground-plain); + display: inline-flex; + flex: 0; + justify-content: center; + padding: calc(var(--sl-size-025) - var(--sl-size-borderWidth-subtle)) + calc(var(--sl-size-100) - var(--sl-size-borderWidth-action)); +} + +:host([show-today]) [part~='today'] { + border: var(--sl-size-borderWidth-subtle) solid var(--sl-color-border-bold); + border-radius: var(--sl-size-borderRadius-default); +} + +[part~='unselectable'] { + color: var(--sl-color-foreground-disabled); +} + +[part~='selected'] { + --_bg-color: var(--sl-color-background-selected-bold); + --_bg-mix-color: var(--sl-color-background-selected-interactive-bold); + + border-radius: var(--sl-size-borderRadius-default); + color: var(--sl-color-foreground-selected-onBold); +} + +:host([show-today]) [part~='today'][part~='selected'] { + border-color: var(--sl-color-border-primary-plain); + box-shadow: inset 0 0 0 var(--sl-size-borderWidth-subtle) var(--sl-elevation-surface-raised-default); } diff --git a/packages/components/calendar/src/select-year.stories.ts b/packages/components/calendar/src/select-year.stories.ts new file mode 100644 index 0000000000..5e7d81035a --- /dev/null +++ b/packages/components/calendar/src/select-year.stories.ts @@ -0,0 +1,79 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html, nothing } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { useArgs } from 'storybook/internal/preview-api'; +import '../register.js'; +import { SelectYear } from './select-year.js'; + +type Props = Pick & { styles?: string }; +type Story = StoryObj; + +customElements.define('sl-select-year', SelectYear); + +export default { + title: 'Date & Time/Select Year', + tags: ['draft'], + args: { + year: new Date(), + max: new Date(new Date().getFullYear(), 11, 31), + min: new Date(new Date().getFullYear(), 0, 1), + showToday: true + }, + argTypes: { + year: { + control: 'date' + }, + max: { + control: 'date' + }, + min: { + control: 'date' + }, + showToday: { + control: 'boolean' + }, + styles: { + table: { disable: true } + } + }, + render: ({ year, max, min, styles, showToday }) => { + const [_, updateArgs] = useArgs(); + const parseDate = (value: string | Date | undefined): Date | undefined => { + if (!value) { + return undefined; + } + + return value instanceof Date ? value : new Date(value); + }; + + const onSelectYear = (event: CustomEvent) => { + console.log('Year selected:', event.detail.getFullYear()); + updateArgs({ year: new Date(event.detail.getFullYear()).getTime() }); //needs to be set to the 'time' otherwise Storybook chokes on the date format 🤷 + }; + + return html` + ${styles + ? html` + + ` + : nothing} + + `; + } +} satisfies Meta; + +export const Basic: Story = { + args: { + max: new Date(2025, 10, 1), + min: new Date(2025, 2, 1), + year: new Date(2025, 7, 1) + } +}; diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index 0ec2c27c86..5098c38589 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -3,6 +3,7 @@ import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-ele import { Button } from '@sl-design-system/button'; import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, EventsController, RovingTabindexController, event } from '@sl-design-system/shared'; +import { dateConverter } from '@sl-design-system/shared/converters.js'; import { type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; import { property, state } from 'lit/decorators.js'; @@ -50,7 +51,25 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; /** The current year. */ - @property({ type: Number }) year = new Date().getFullYear(); + @property({ converter: dateConverter }) year = new Date(); + + /** + * The maximum date selectable in the month. + * @default undefined + */ + @property({ converter: dateConverter }) max?: Date; + + /** + * The minimum date selectable in the month. + * @default undefined + */ + @property({ converter: dateConverter }) min?: Date; + + /** + * Highlights the current month when set. + * @default false + */ + @property({ type: Boolean, attribute: 'show-today' }) showToday?: boolean; /** @internal The year you can select from. */ @state() years: number[] = []; @@ -58,18 +77,19 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { override connectedCallback(): void { super.connectedCallback(); - this.#setYears(this.year - 5, this.year + 6); + this.#setYears(this.year.getFullYear() - 5, this.year.getFullYear() + 6); } override render(): TemplateResult { return html`
    - ${this.years.at(0)}-${this.years.at(-1)} + ${this.years.at(0)} - ${this.years.at(-1)} @@ -78,30 +98,51 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { aria-label=${msg('Go forward 12 years', { id: 'sl.calendar.nextYears' })} fill="ghost" variant="primary" + ?disabled=${this.years && this.#isUnselectable((this.years.at(-1) || 0) + 1)} >
      - ${this.years.map( - year => html` + ${this.years.map(year => { + const parts = this.getYearParts(year).join(' '); + return html`
    1. - this.#onClick(year)} - .fill=${this.year === year ? 'solid' : 'ghost'} - .variant=${this.year === year ? 'primary' : 'default'} - ?autofocus=${this.year === year} - aria-pressed=${ifDefined(this.year === year ? 'true' : undefined)} - > - ${year} - + ${this.#isUnselectable(year) + ? html`${year}` + : html` + + `}
    2. - ` - )} + `; + })}
    `; } + getYearParts = (year: number): string[] => { + return [ + 'year', + year === new Date().getFullYear() ? 'today' : '', + this.year.getFullYear() === year ? 'selected' : '', + this.#isUnselectable(year) ? 'unselectable' : '' + ].filter(part => part !== ''); + }; + + #isUnselectable(year: number): boolean { + if (!year) { + return true; + } + return !!((this.min && year < this.min.getFullYear()) || (this.max && year > this.max.getFullYear())); + } + #onClick(year: number): void { this.selectEvent.emit(new Date(year, 0)); } @@ -111,7 +152,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { event.preventDefault(); event.stopPropagation(); - this.selectEvent.emit(new Date(this.year, 0)); + this.selectEvent.emit(this.year); } } From f816c7a1c1fd3387ff5b57b5f447ca53d78a649c Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Tue, 9 Sep 2025 16:10:09 +0200 Subject: [PATCH 010/126] Add unit tests for SelectDay, SelectMonth, and SelectYear components to validate functionality and ensure proper event handling. --- packages/components/calendar/src/calendar.ts | 12 +- .../src/month-view.stories.d.js copy.map | 7 - .../src/month-view.stories.js copy.map | 7 - .../calendar/src/select-day.spec.ts | 125 ++++++++++++++++ .../calendar/src/select-month.spec.ts | 134 ++++++++++++++++++ .../calendar/src/select-year.spec.ts | 119 ++++++++++++++++ 6 files changed, 389 insertions(+), 15 deletions(-) delete mode 100644 packages/components/calendar/src/month-view.stories.d.js copy.map delete mode 100644 packages/components/calendar/src/month-view.stories.js copy.map create mode 100644 packages/components/calendar/src/select-day.spec.ts create mode 100644 packages/components/calendar/src/select-month.spec.ts create mode 100644 packages/components/calendar/src/select-year.spec.ts diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 3c448beac5..1501426767 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -119,13 +119,23 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { @sl-select=${this.#onSelectMonth} @sl-toggle=${this.#onToggleMonthYear} .month=${this.month} + ?show-today=${this.showToday} locale=${ifDefined(this.locale)} max=${ifDefined(this.max?.toISOString())} min=${ifDefined(this.min?.toISOString())} > ` ], - ['year', () => html``] + [ + 'year', + () => html` + + ` + ] ])} `; } diff --git a/packages/components/calendar/src/month-view.stories.d.js copy.map b/packages/components/calendar/src/month-view.stories.d.js copy.map deleted file mode 100644 index 3c266225c5..0000000000 --- a/packages/components/calendar/src/month-view.stories.d.js copy.map +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 3, - "sources": ["month-view.stories.d.ts"], - "sourcesContent": ["import { type StoryObj } from '@storybook/web-components-vite';\nimport '../register.js';\nimport { type MonthView } from './month-view.js';\ntype Props = Pick & {\n styles?: string;\n};\ntype Story = StoryObj;\ndeclare const _default: {\n title: string;\n tags: string[];\n args: {\n hideDaysOtherMonths: false;\n month: Date;\n readonly: false;\n showToday: false;\n showWeekNumbers: false;\n };\n argTypes: {\n firstDayOfWeek: {\n control: \"number\";\n };\n locale: {\n control: \"inline-radio\";\n options: string[];\n };\n max: {\n control: \"date\";\n };\n min: {\n control: \"date\";\n };\n month: {\n control: \"date\";\n };\n renderer: {\n table: {\n disable: true;\n };\n };\n selected: {\n control: \"date\";\n };\n styles: {\n table: {\n disable: true;\n };\n };\n };\n render: ({ firstDayOfWeek, hideDaysOtherMonths, max, min, month, locale, readonly, renderer, selected, showToday, showWeekNumbers, styles }: Props) => import(\"lit-html\").TemplateResult<1>;\n};\nexport default _default;\nexport declare const Basic: Story;\nexport declare const FirstDayOfWeek: Story;\nexport declare const HideDaysOtherMonths: Story;\nexport declare const MinMax: Story;\nexport declare const Readonly: Story;\nexport declare const Renderer: Story;\nexport declare const Selected: Story;\nexport declare const Today: Story;\nexport declare const WeekNumbers: Story;\n"], - "mappings": ";AACA,OAAO;AAiDP,eAAe;", - "names": [] -} diff --git a/packages/components/calendar/src/month-view.stories.js copy.map b/packages/components/calendar/src/month-view.stories.js copy.map deleted file mode 100644 index 6d984c19ef..0000000000 --- a/packages/components/calendar/src/month-view.stories.js copy.map +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 3, - "sources": ["month-view.stories.ts"], - "sourcesContent": ["import { faFlag } from '@fortawesome/pro-regular-svg-icons';\nimport { Icon } from '@sl-design-system/icon';\nimport { type Meta, type StoryObj } from '@storybook/web-components-vite';\nimport { html, nothing } from 'lit';\nimport { ifDefined } from 'lit/directives/if-defined.js';\nimport '../register.js';\nimport { type MonthView } from './month-view.js';\nimport { type Day } from './utils.js';\n\ntype Props = Pick<\n MonthView,\n | 'firstDayOfWeek'\n | 'hideDaysOtherMonths'\n | 'locale'\n | 'max'\n | 'min'\n | 'month'\n | 'readonly'\n | 'renderer'\n | 'selected'\n | 'showToday'\n | 'showWeekNumbers'\n> & { styles?: string };\ntype Story = StoryObj;\n\nIcon.register(faFlag);\n\nexport default {\n title: 'Date & Time/Month view',\n tags: ['draft'],\n args: {\n hideDaysOtherMonths: false,\n month: new Date(),\n readonly: false,\n showToday: false,\n showWeekNumbers: false\n },\n argTypes: {\n firstDayOfWeek: {\n control: 'number'\n },\n locale: {\n control: 'inline-radio',\n options: ['de', 'en-GB', 'es', 'fi', 'fr', 'it', 'nl', 'nl-BE', 'no', 'pl', 'sv']\n },\n max: {\n control: 'date'\n },\n min: {\n control: 'date'\n },\n month: {\n control: 'date'\n },\n renderer: {\n table: { disable: true }\n },\n selected: {\n control: 'date'\n },\n styles: {\n table: { disable: true }\n }\n },\n render: ({\n firstDayOfWeek,\n hideDaysOtherMonths,\n max,\n min,\n month,\n locale,\n readonly,\n renderer,\n selected,\n showToday,\n showWeekNumbers,\n styles\n }) => html`\n ${styles\n ? html`\n \n `\n : nothing}\n \n `\n} satisfies Meta;\n\nexport const Basic: Story = {};\n\nexport const FirstDayOfWeek: Story = {\n args: {\n firstDayOfWeek: 0\n }\n};\n\nexport const HideDaysOtherMonths: Story = {\n args: {\n hideDaysOtherMonths: true\n }\n};\n\nexport const MinMax: Story = {\n args: {\n month: new Date(2025, 0, 1),\n max: new Date(2025, 0, 20),\n min: new Date(2025, 0, 10)\n }\n};\n\nexport const Readonly: Story = {\n args: {\n readonly: true\n }\n};\n\nexport const Renderer: Story = {\n args: {\n renderer: (day: Day, monthView: MonthView) => {\n const parts = monthView.getDayParts(day);\n\n if (day.currentMonth && [2, 4, 7, 10, 16, 22].includes(day.date.getDate())) {\n parts.push('highlight');\n }\n\n if (day.currentMonth && day.date.getDate() === 24) {\n parts.push('finish');\n\n return html``;\n } else if (day.currentMonth) {\n return html``;\n } else {\n return html`${day.date.getDate()}`;\n }\n },\n styles: `\n sl-month-view::part(finish) {\n background: var(--sl-color-success-plain);\n border-radius: 50%;\n color: var(--sl-color-text-inverted);\n }\n\n sl-month-view::part(finish):hover {\n background: var(--sl-color-success-bold);\n }\n\n sl-month-view::part(finish):active {\n background: var(--sl-color-success-heavy);\n }\n `\n }\n};\n\nexport const Selected: Story = {\n args: {\n month: new Date(2024, 11, 10),\n selected: new Date(2024, 11, 4)\n }\n};\n\nexport const Today: Story = {\n args: {\n showToday: true\n }\n};\n\nexport const WeekNumbers: Story = {\n args: {\n showWeekNumbers: true\n }\n};\n"], - "mappings": ";AAAA,SAAS,cAAc;AACvB,SAAS,YAAY;AAErB,SAAS,MAAM,eAAe;AAC9B,SAAS,iBAAiB;AAC1B,OAAO;AAoBP,KAAK,SAAS,MAAM;AAEpB,eAAe;AAAA,EACb,OAAO;AAAA,EACP,MAAM,CAAC,OAAO;AAAA,EACd,MAAM;AAAA,IACJ,qBAAqB;AAAA,IACrB,OAAO,oBAAI,KAAK;AAAA,IAChB,UAAU;AAAA,IACV,WAAW;AAAA,IACX,iBAAiB;AAAA,EACnB;AAAA,EACA,UAAU;AAAA,IACR,gBAAgB;AAAA,MACd,SAAS;AAAA,IACX;AAAA,IACA,QAAQ;AAAA,MACN,SAAS;AAAA,MACT,SAAS,CAAC,MAAM,SAAS,MAAM,MAAM,MAAM,MAAM,MAAM,SAAS,MAAM,MAAM,IAAI;AAAA,IAClF;AAAA,IACA,KAAK;AAAA,MACH,SAAS;AAAA,IACX;AAAA,IACA,KAAK;AAAA,MACH,SAAS;AAAA,IACX;AAAA,IACA,OAAO;AAAA,MACL,SAAS;AAAA,IACX;AAAA,IACA,UAAU;AAAA,MACR,OAAO,EAAE,SAAS,KAAK;AAAA,IACzB;AAAA,IACA,UAAU;AAAA,MACR,SAAS;AAAA,IACX;AAAA,IACA,QAAQ;AAAA,MACN,OAAO,EAAE,SAAS,KAAK;AAAA,IACzB;AAAA,EACF;AAAA,EACA,QAAQ,CAAC;AAAA,IACP;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,MAAM;AAAA,MACF,SACE;AAAA;AAAA,cAEM,MAAM;AAAA;AAAA,YAGZ,OAAO;AAAA;AAAA,gCAEiB,mBAAmB;AAAA,kBACjC,QAAQ;AAAA,oBACN,SAAS;AAAA,2BACF,eAAe;AAAA,0BAChB,UAAU,cAAc,CAAC;AAAA,eACpC,UAAU,MAAM,CAAC;AAAA,YACpB,UAAU,KAAK,YAAY,CAAC,CAAC;AAAA,YAC7B,UAAU,KAAK,YAAY,CAAC,CAAC;AAAA,cAC3B,UAAU,OAAO,YAAY,CAAC,CAAC;AAAA,iBAC5B,UAAU,UAAU,YAAY,CAAC,CAAC;AAAA,kBACjC,QAAQ;AAAA;AAAA;AAG1B;AAEO,aAAM,QAAe,CAAC;AAEtB,aAAM,iBAAwB;AAAA,EACnC,MAAM;AAAA,IACJ,gBAAgB;AAAA,EAClB;AACF;AAEO,aAAM,sBAA6B;AAAA,EACxC,MAAM;AAAA,IACJ,qBAAqB;AAAA,EACvB;AACF;AAEO,aAAM,SAAgB;AAAA,EAC3B,MAAM;AAAA,IACJ,OAAO,IAAI,KAAK,MAAM,GAAG,CAAC;AAAA,IAC1B,KAAK,IAAI,KAAK,MAAM,GAAG,EAAE;AAAA,IACzB,KAAK,IAAI,KAAK,MAAM,GAAG,EAAE;AAAA,EAC3B;AACF;AAEO,aAAM,WAAkB;AAAA,EAC7B,MAAM;AAAA,IACJ,UAAU;AAAA,EACZ;AACF;AAEO,aAAM,WAAkB;AAAA,EAC7B,MAAM;AAAA,IACJ,UAAU,CAAC,KAAU,cAAyB;AAC5C,YAAM,QAAQ,UAAU,YAAY,GAAG;AAEvC,UAAI,IAAI,gBAAgB,CAAC,GAAG,GAAG,GAAG,IAAI,IAAI,EAAE,EAAE,SAAS,IAAI,KAAK,QAAQ,CAAC,GAAG;AAC1E,cAAM,KAAK,WAAW;AAAA,MACxB;AAEA,UAAI,IAAI,gBAAgB,IAAI,KAAK,QAAQ,MAAM,IAAI;AACjD,cAAM,KAAK,QAAQ;AAEnB,eAAO,qBAAqB,MAAM,KAAK,GAAG,CAAC;AAAA,MAC7C,WAAW,IAAI,cAAc;AAC3B,eAAO,qBAAqB,MAAM,KAAK,GAAG,CAAC,IAAI,IAAI,KAAK,QAAQ,CAAC;AAAA,MACnE,OAAO;AACL,eAAO,mBAAmB,MAAM,KAAK,GAAG,CAAC,IAAI,IAAI,KAAK,QAAQ,CAAC;AAAA,MACjE;AAAA,IACF;AAAA,IACA,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeV;AACF;AAEO,aAAM,WAAkB;AAAA,EAC7B,MAAM;AAAA,IACJ,OAAO,IAAI,KAAK,MAAM,IAAI,EAAE;AAAA,IAC5B,UAAU,IAAI,KAAK,MAAM,IAAI,CAAC;AAAA,EAChC;AACF;AAEO,aAAM,QAAe;AAAA,EAC1B,MAAM;AAAA,IACJ,WAAW;AAAA,EACb;AACF;AAEO,aAAM,cAAqB;AAAA,EAChC,MAAM;AAAA,IACJ,iBAAiB;AAAA,EACnB;AACF;", - "names": [] -} diff --git a/packages/components/calendar/src/select-day.spec.ts b/packages/components/calendar/src/select-day.spec.ts new file mode 100644 index 0000000000..24001e8f78 --- /dev/null +++ b/packages/components/calendar/src/select-day.spec.ts @@ -0,0 +1,125 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit'; +import { SelectDay } from './select-day.js'; + +try { + customElements.define('sl-select-day', SelectDay); +} catch { + /* already defined */ +} + +describe('sl-select-day', () => { + let el: SelectDay; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + await el.updateComplete; + }); + + it('should set displayMonth, nextMonth and previousMonth based on month', () => { + expect(el.displayMonth?.getMonth()).to.equal(5); + expect(el.nextMonth?.getMonth()).to.equal(6); + expect(el.previousMonth?.getMonth()).to.equal(4); + }); + + it('should generate 7 weekday headers', () => { + expect(el.weekDays).to.have.lengthOf(7); + }); + + it('should update weekday ordering when firstDayOfWeek changes', async () => { + const firstBefore = el.weekDays[0].short; + el.firstDayOfWeek = 0; // Sunday + await el.updateComplete; + const firstAfter = el.weekDays[0].short; + expect(firstAfter).to.not.equal(firstBefore); + }); + + it('should show week number header when show-week-numbers set', async () => { + el.showWeekNumbers = true; + await el.updateComplete; + const weekHeader = el.renderRoot.querySelector('.days-of-week .week-number'); + expect(weekHeader).to.exist; + }); + }); + + describe('min/max boundaries', () => { + beforeEach(async () => { + const month = new Date(2025, 5, 15); // June 2025 + const min = new Date(2025, 5, 1); // same month start + const max = new Date(2025, 5, 30); // same month end + el = await fixture(html``); + await el.updateComplete; + }); + + it('should disable previous-month navigation when at min boundary', () => { + const prevBtn = el.renderRoot.querySelector('sl-button.previous-month'); + expect(prevBtn).to.exist.and.match(':disabled'); + }); + + it('should disable next-month navigation when at max boundary', () => { + const nextBtn = el.renderRoot.querySelector('sl-button.next-month'); + expect(nextBtn).to.exist.and.match(':disabled'); + }); + }); + + describe('toggle events', () => { + beforeEach(async () => { + el = await fixture(html``); + await el.updateComplete; + }); + + it('should emit sl-toggle "month" when clicking current month button', async () => { + const onToggle = new Promise(resolve => + el.addEventListener('sl-toggle', e => resolve(e as CustomEvent)) + ); + const monthBtn = el.renderRoot.querySelector('sl-button.current-month'); + (monthBtn as HTMLButtonElement | null)?.click?.(); + const ev = await onToggle; + expect(ev.detail).to.equal('month'); + }); + + it('should emit sl-toggle "year" when clicking current year button', async () => { + const onToggle = new Promise(resolve => + el.addEventListener('sl-toggle', e => resolve(e as CustomEvent)) + ); + const yearBtn = Array.from(el.renderRoot.querySelectorAll('sl-button.current-year')).find( + btn => !btn.classList.contains('previous-month') && !btn.classList.contains('next-month') + ); + (yearBtn as HTMLButtonElement | null)?.click?.(); + const ev = await onToggle; + expect(ev.detail).to.equal('year'); + }); + }); + + describe('selection & change propagation', () => { + beforeEach(async () => { + el = await fixture(html``); + await el.updateComplete; + }); + + it('should re-set month when receiving sl-change from inner month view', async () => { + const targetMonthView = el.renderRoot.querySelector('sl-month-view:nth-of-type(2)'); + const date = new Date(2025, 7, 10); // August 2025 + targetMonthView?.dispatchEvent( + new CustomEvent('sl-change', { detail: date, bubbles: true, composed: true, cancelable: true }) + ); + await el.updateComplete; + expect(el.month?.getMonth()).to.equal(7); + }); + + it('should emit sl-select when inner month view emits sl-select', async () => { + const onSelect = new Promise(resolve => + el.addEventListener('sl-select', e => resolve(e as CustomEvent)) + ); + const targetMonthView = el.renderRoot.querySelector('sl-month-view:nth-of-type(2)'); + const date = new Date(2025, 5, 20); + targetMonthView?.dispatchEvent( + new CustomEvent('sl-select', { detail: date, bubbles: true, composed: true, cancelable: true }) + ); + const ev = await onSelect; + expect(ev.detail).to.be.instanceOf(Date); + expect((ev.detail as Date).getDate()).to.equal(20); + }); + }); +}); diff --git a/packages/components/calendar/src/select-month.spec.ts b/packages/components/calendar/src/select-month.spec.ts new file mode 100644 index 0000000000..1aa3ab2ffd --- /dev/null +++ b/packages/components/calendar/src/select-month.spec.ts @@ -0,0 +1,134 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit'; +import { SelectMonth } from './select-month.js'; + +// Define element if not already defined (no dedicated register file for select-month) +try { + customElements.define('sl-select-month', SelectMonth); +} catch { + /* already defined */ +} + +describe('sl-select-month', () => { + let el: SelectMonth; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should generate 12 months', () => { + expect(el.months).to.have.lengthOf(12); + const buttonsOrSpans = el.renderRoot.querySelectorAll('ol li'); + expect(buttonsOrSpans).to.have.lengthOf(12); + }); + + it('should show a year toggle button (because navigation is possible)', () => { + const toggleBtn = el.renderRoot.querySelector('sl-button.current-year'); + expect(toggleBtn).to.exist; + }); + + it('should have enabled previous and next year buttons', () => { + const prev = el.renderRoot.querySelector('sl-button[aria-label^="Previous year"]'); + const next = el.renderRoot.querySelector('sl-button[aria-label^="Next year"]'); + expect(prev).to.exist.and.not.match(':disabled'); + expect(next).to.exist.and.not.match(':disabled'); + }); + }); + + describe('min/max boundaries', () => { + beforeEach(async () => { + const currentYear = new Date().getFullYear(); + // Allow only months April (3) through September (8) + const min = new Date(currentYear, 3, 1); + const max = new Date(currentYear, 8, 1); + el = await fixture( + html`` + ); + await el.updateComplete; + }); + + it('should mark months outside range as unselectable spans', () => { + const unselectableParts = Array.from(el.renderRoot.querySelectorAll('[part~="unselectable"]')); + // Months 0,1,2 and 9,10,11 -> 6 unselectables + expect(unselectableParts).to.have.lengthOf(6); + const allSpans = unselectableParts.every(n => n.tagName === 'SPAN'); + expect(allSpans).to.be.true; + }); + + it('should disable navigating to a previous year (since min is current year)', () => { + const prev = el.renderRoot.querySelector('sl-button[aria-label^="Previous year"]'); + expect(prev).to.exist.and.match(':disabled'); + }); + + it('should disable navigating to a next year (since max is current year)', () => { + const next = el.renderRoot.querySelector('sl-button[aria-label^="Next year"]'); + expect(next).to.exist.and.match(':disabled'); + }); + }); + + describe('selection', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should emit sl-select when a month button is clicked', async () => { + const onSelect = new Promise(resolve => + el.addEventListener('sl-select', e => resolve(e as CustomEvent)) + ); + const firstButton = el.renderRoot.querySelector('ol button'); + (firstButton as HTMLButtonElement | null)?.click?.(); + const ev = await onSelect; + expect(ev.detail).to.be.instanceOf(Date); + expect((ev.detail as Date).getMonth()).to.equal(parseInt(firstButton!.textContent!.trim().slice(0, 2)) - 1 || 0); // simplistic, month names may vary but at least ensure Date returned + }); + + it('should emit sl-select with current month when Escape is pressed', async () => { + const currentMonth = el.month.getMonth(); + const onSelect = new Promise(resolve => + el.addEventListener('sl-select', e => resolve(e as CustomEvent)) + ); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + const ev = await onSelect; + expect((ev.detail as Date).getMonth()).to.equal(currentMonth); + }); + }); + + describe('navigation between years', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should increment year when next is clicked', async () => { + const startYear = el.month.getFullYear(); + const next = el.renderRoot.querySelector('sl-button[aria-label^="Next year"]'); + (next as HTMLButtonElement | null)?.click?.(); + await el.updateComplete; + expect(el.month.getFullYear()).to.equal(startYear + 1); + }); + + it('should decrement year when previous is clicked', async () => { + const startYear = el.month.getFullYear(); + const prev = el.renderRoot.querySelector('sl-button[aria-label^="Previous year"]'); + (prev as HTMLButtonElement | null)?.click?.(); + await el.updateComplete; + expect(el.month.getFullYear()).to.equal(startYear - 1); + }); + }); + + describe('toggle year select', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should emit sl-toggle with detail "year" when clicking current year button', async () => { + const onToggle = new Promise(resolve => + el.addEventListener('sl-toggle', e => resolve(e as CustomEvent)) + ); + const toggleBtn = el.renderRoot.querySelector('sl-button.current-year'); + (toggleBtn as HTMLButtonElement | null)?.click?.(); + const ev = await onToggle; + expect(ev.detail).to.equal('year'); + }); + }); +}); diff --git a/packages/components/calendar/src/select-year.spec.ts b/packages/components/calendar/src/select-year.spec.ts new file mode 100644 index 0000000000..53446a4509 --- /dev/null +++ b/packages/components/calendar/src/select-year.spec.ts @@ -0,0 +1,119 @@ +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit'; +import '../register.js'; +import { SelectYear } from './select-year.js'; + +// Ensure the element is defined for direct usage if not already via calendar/register +try { + customElements.define('sl-select-year', SelectYear); +} catch { + /* already defined */ +} + +describe('sl-select-year', () => { + let el: SelectYear; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render 12 years by default (current year -5 .. current year +6)', () => { + const current = new Date().getFullYear(); + const years = Array.from(el.renderRoot.querySelectorAll('ol li')); // list items (12) + + expect(years).to.have.lengthOf(12); + expect(el.years[0]).to.equal(current - 5); + expect(el.years.at(-1)).to.equal(current + 6); + }); + + it('should highlight today year when show-today is set', async () => { + el.showToday = true; + await el.updateComplete; + + const today = new Date().getFullYear(); + const todayButton = el.renderRoot.querySelector('[part~="today"]'); + expect(todayButton).to.exist; + expect(todayButton?.textContent?.trim()).to.equal(String(today)); + }); + }); + + describe('navigation', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should go to previous 12 years when previous button clicked', async () => { + const initialFirst = el.years[0]; + const prevBtn = el.renderRoot.querySelector('sl-button:nth-of-type(1)'); + (prevBtn as HTMLButtonElement | null)?.click?.(); + await el.updateComplete; + expect(el.years[0]).to.equal(initialFirst - 12); + }); + + it('should go to next 12 years when next button clicked', async () => { + const initialLast = el.years.at(-1)!; + const nextBtn = el.renderRoot.querySelector('sl-button:nth-of-type(2)'); + (nextBtn as HTMLButtonElement | null)?.click?.(); + await el.updateComplete; + expect(el.years.at(-1)).to.equal(initialLast + 12); + }); + }); + + describe('min/max', () => { + beforeEach(async () => { + const year = new Date().getFullYear(); + el = await fixture( + html`` + ); + }); + + it('should render disabled (unselectable) years outside min/max', () => { + const unselectable = Array.from(el.renderRoot.querySelectorAll('[part~="unselectable"]')); + expect(unselectable.length).to.be.greaterThan(0); + // ensure they are spans not buttons + const allSpans = unselectable.every(node => node.tagName === 'SPAN'); + expect(allSpans).to.be.true; + }); + + it('should not allow navigating before min boundary', async () => { + // navigate backwards many times until disabled (safeguard 5 iterations) + for (let i = 0; i < 5; i++) { + const prev = el.renderRoot.querySelector('sl-button:nth-of-type(1)'); + if (!prev || prev.hasAttribute('disabled')) break; + (prev as HTMLButtonElement | null)?.click?.(); + await el.updateComplete; + } + const prevBtn = el.renderRoot.querySelector('sl-button:nth-of-type(1)'); + expect(prevBtn).to.have.attribute('disabled'); + }); + }); + + describe('selection', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should emit sl-select with selected year on click', async () => { + const onSelect = new Promise(resolve => + el.addEventListener('sl-select', e => resolve(e as CustomEvent)) + ); + // click middle year (should be a button) - choose the first button + const button = el.renderRoot.querySelector('ol button'); + (button as HTMLButtonElement | null)?.click?.(); + const ev = await onSelect; + expect(ev.detail).to.be.instanceOf(Date); + expect((ev.detail as Date).getFullYear()).to.equal(parseInt(button!.textContent!.trim())); + }); + + it('should emit sl-select for Escape key returning current year', async () => { + const currentYear = el.year.getFullYear(); + const onSelect = new Promise(resolve => + el.addEventListener('sl-select', e => resolve(e as CustomEvent)) + ); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + const ev = await onSelect; + expect((ev.detail as Date).getFullYear()).to.equal(currentYear); + }); + }); +}); From fa34625b036c05261fbb6c51b698bb6c8257cf27 Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Wed, 10 Sep 2025 10:31:58 +0200 Subject: [PATCH 011/126] Remove unused story files for SelectDay and SelectMonth components; add tests for month navigation in SelectDay component. --- .../calendar/src/month-view.stories.d copy.ts | 86 ------------------- .../calendar/src/select-day.spec.ts | 53 ++++++++++++ .../src/select-month.stories.d copy.ts | 39 --------- .../components/calendar/src/select-month.ts | 1 + 4 files changed, 54 insertions(+), 125 deletions(-) delete mode 100644 packages/components/calendar/src/month-view.stories.d copy.ts delete mode 100644 packages/components/calendar/src/select-month.stories.d copy.ts diff --git a/packages/components/calendar/src/month-view.stories.d copy.ts b/packages/components/calendar/src/month-view.stories.d copy.ts deleted file mode 100644 index 3217ef2f36..0000000000 --- a/packages/components/calendar/src/month-view.stories.d copy.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { type StoryObj } from '@storybook/web-components-vite'; -import '../register.js'; -import { type MonthView } from './month-view.js'; -type Props = Pick< - MonthView, - | 'firstDayOfWeek' - | 'hideDaysOtherMonths' - | 'locale' - | 'max' - | 'min' - | 'month' - | 'readonly' - | 'renderer' - | 'selected' - | 'showToday' - | 'showWeekNumbers' -> & { - styles?: string; -}; -type Story = StoryObj; -declare const _default: { - title: string; - tags: string[]; - args: { - hideDaysOtherMonths: false; - month: Date; - readonly: false; - showToday: false; - showWeekNumbers: false; - }; - argTypes: { - firstDayOfWeek: { - control: 'number'; - }; - locale: { - control: 'inline-radio'; - options: string[]; - }; - max: { - control: 'date'; - }; - min: { - control: 'date'; - }; - month: { - control: 'date'; - }; - renderer: { - table: { - disable: true; - }; - }; - selected: { - control: 'date'; - }; - styles: { - table: { - disable: true; - }; - }; - }; - render({ - firstDayOfWeek, - hideDaysOtherMonths, - max, - min, - month, - locale, - readonly, - renderer, - selected, - showToday, - showWeekNumbers, - styles - }: Props): import('lit-html').TemplateResult<1>; -}; -export default _default; -export declare const Basic: Story; -export declare const FirstDayOfWeek: Story; -export declare const HideDaysOtherMonths: Story; -export declare const MinMax: Story; -export declare const Readonly: Story; -export declare const Renderer: Story; -export declare const Selected: Story; -export declare const Today: Story; -export declare const WeekNumbers: Story; diff --git a/packages/components/calendar/src/select-day.spec.ts b/packages/components/calendar/src/select-day.spec.ts index 24001e8f78..0d71280a10 100644 --- a/packages/components/calendar/src/select-day.spec.ts +++ b/packages/components/calendar/src/select-day.spec.ts @@ -1,4 +1,5 @@ import { expect, fixture } from '@open-wc/testing'; +import { Button } from '@sl-design-system/button'; import { html } from 'lit'; import { SelectDay } from './select-day.js'; @@ -122,4 +123,56 @@ describe('sl-select-day', () => { expect((ev.detail as Date).getDate()).to.equal(20); }); }); + + // New tests exercising the next/previous navigation (invoking #onNext / #onPrevious indirectly) + describe('month navigation', () => { + it('should go to next month when next button clicked', async () => { + el = await fixture(html``); // June 2025 + await el.updateComplete; + const startMonth = el.month!.getMonth(); // 5 + const nextBtn: Button | null = el.renderRoot.querySelector('sl-button.next-month'); + expect(nextBtn, 'next-month button should exist').to.exist; + nextBtn?.click(); + await el.updateComplete; + expect(el.month?.getMonth()).to.equal((startMonth + 1) % 12); + if (startMonth === 11) { + expect(el.month?.getFullYear()).to.equal(2026); + } + }); + + it('should go to previous month when previous button clicked', async () => { + el = await fixture(html``); // June 2025 + await el.updateComplete; + const startMonth = el.month!.getMonth(); // 5 + const prevBtn: Button | null = el.renderRoot.querySelector('sl-button.previous-month'); + expect(prevBtn, 'previous-month button should exist').to.exist; + prevBtn?.click(); + await el.updateComplete; + const expected = (startMonth + 11) % 12; + expect(el.month?.getMonth()).to.equal(expected); + if (startMonth === 0) { + expect(el.month?.getFullYear()).to.equal(2024); + } + }); + + it('should handle year decrement when navigating from January to previous month', async () => { + el = await fixture(html``); // Jan 2025 + await el.updateComplete; + const prevBtn: Button | null = el.renderRoot.querySelector('sl-button.previous-month'); + prevBtn?.click(); + await el.updateComplete; + expect(el.month?.getMonth()).to.equal(11); // Dec + expect(el.month?.getFullYear()).to.equal(2024); + }); + + it('should handle year increment when navigating from December to next month', async () => { + el = await fixture(html``); // Dec 2025 + await el.updateComplete; + const nextBtn: Button | null = el.renderRoot.querySelector('sl-button.next-month'); + nextBtn?.click(); + await el.updateComplete; + expect(el.month?.getMonth()).to.equal(0); // Jan + expect(el.month?.getFullYear()).to.equal(2026); + }); + }); }); diff --git a/packages/components/calendar/src/select-month.stories.d copy.ts b/packages/components/calendar/src/select-month.stories.d copy.ts deleted file mode 100644 index ee58932251..0000000000 --- a/packages/components/calendar/src/select-month.stories.d copy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type StoryObj } from '@storybook/web-components-vite'; -import '../register.js'; -import { SelectMonth } from './select-month.js'; -type Props = Pick & { - styles?: string; -}; -type Story = StoryObj; -declare const _default: { - title: string; - tags: string[]; - args: { - month: Date; - max: Date; - min: Date; - showToday: true; - }; - argTypes: { - month: { - control: 'date'; - }; - max: { - control: 'date'; - }; - min: { - control: 'date'; - }; - showToday: { - control: 'boolean'; - }; - styles: { - table: { - disable: true; - }; - }; - }; - render({ month, max, min, styles, showToday }: Props): import('lit-html').TemplateResult<1>; -}; -export default _default; -export declare const Basic: Story; diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 8e7ba41aa6..be6e843af7 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -195,6 +195,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #onNext(): void { + console.log('next'); this.month = new Date(this.month.getFullYear() + 1, this.month.getMonth(), this.month.getDate()); } From 8abdbf0e4b9c44b71be618b940da61b20bc614df Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Tue, 16 Sep 2025 13:33:04 +0200 Subject: [PATCH 012/126] feat: add inert property to calendar components and improve mode handling --- packages/components/calendar/src/calendar.ts | 5 ++++- packages/components/calendar/src/month-view.ts | 5 ++++- packages/components/calendar/src/select-day.ts | 4 ++++ packages/components/calendar/src/select-month.ts | 9 ++++++++- packages/components/calendar/src/select-year.ts | 2 +- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 1501426767..a10ce22a35 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -30,6 +30,8 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { }; } + #previousMode: 'day' | 'month' | 'year' = 'day'; + /** @internal */ static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true }; @@ -167,7 +169,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { event.stopPropagation(); this.month = new Date(event.detail.getFullYear(), this.month!.getMonth(), this.month!.getDate()); - this.mode = 'day'; + this.mode = this.#previousMode ?? 'day'; requestAnimationFrame(() => { this.renderRoot.querySelector('sl-select-day')?.focus(); @@ -178,6 +180,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { event.preventDefault(); event.stopPropagation(); + this.#previousMode = this.mode; this.mode = event.detail; requestAnimationFrame(() => { diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index 8484e31b99..23fc6cf5fe 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -108,6 +108,9 @@ export class MonthView extends LocaleMixin(LitElement) { /** The list of dates that should have an indicator. */ @property({ converter: dateConverter }) indicator?: Date[]; + // eslint-disable-next-line lit/no-native-attributes + @property({ type: Boolean }) override inert = false; + /** * Highlights today's date when set. * @default false @@ -144,7 +147,7 @@ export class MonthView extends LocaleMixin(LitElement) { this.calendar = createCalendar(this.month ?? new Date(), { firstDayOfWeek, max, min, showToday }); } - if (changes.has('month')) { + if (changes.has('month') || changes.has('inert')) { this.#rovingTabindexController.clearElementCache(); } } diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 539e730fce..ad915cd0fa 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -104,6 +104,9 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal The translated days of the week. */ @state() weekDays: Array<{ long: string; short: string }> = []; + // eslint-disable-next-line lit/no-native-attributes + @property({ type: Boolean }) override inert = false; + override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); @@ -265,6 +268,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { .selected=${this.selected} .negative=${this.negative} .indicator=${this.indicator} + ?inert=${this.inert} locale=${ifDefined(this.locale)} max=${ifDefined(this.max?.toISOString())} min=${ifDefined(this.min?.toISOString())} diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index be6e843af7..0c843514f4 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -46,7 +46,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { #rovingTabindexController = new RovingTabindexController(this, { direction: 'grid', directionLength: 3, - elements: (): HTMLElement[] => Array.from(this.renderRoot.querySelectorAll('ol sl-button')), + elements: (): HTMLElement[] => Array.from(this.renderRoot.querySelectorAll('ol button')), focusInIndex: elements => { const index = elements.findIndex(el => el.hasAttribute('aria-pressed')); @@ -169,6 +169,13 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** Returns an array of part names for a day. */ getMonthParts = (month: Month): string[] => { + console.log( + 'getMonthParts', + this.month.getMonth(), + month.value, + this.month.getFullYear(), + new Date().getFullYear() + ); return [ 'month', month.value === new Date().getMonth() && this.month.getFullYear() === new Date().getFullYear() ? 'today' : '', diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index 5098c38589..4dfc1eb5d3 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -38,7 +38,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { #rovingTabindexController = new RovingTabindexController(this, { direction: 'grid', directionLength: 3, - elements: (): HTMLElement[] => Array.from(this.renderRoot.querySelectorAll('ol sl-button')), + elements: (): HTMLElement[] => Array.from(this.renderRoot.querySelectorAll('ol button')), focusInIndex: elements => { const index = elements.findIndex(el => el.hasAttribute('aria-pressed')); From c217ad2906d5979db6f9e76ed36c3a4c0af14130 Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Tue, 16 Sep 2025 16:08:16 +0200 Subject: [PATCH 013/126] feat: add selected date property to calendar components and update selection handling --- .../calendar/src/calendar.stories.ts | 15 +++++++++++++++ packages/components/calendar/src/calendar.ts | 3 +++ .../components/calendar/src/select-month.ts | 18 +++++++++--------- .../components/calendar/src/select-year.ts | 5 ++++- packages/components/calendar/src/utils.ts | 1 + 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index 6a55564a73..c030a7f751 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -1,6 +1,8 @@ +import '@sl-design-system/format-date/register.js'; import { type Meta, type StoryObj } from '@storybook/web-components-vite'; import { TemplateResult, html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { useArgs } from 'storybook/internal/preview-api'; import '../register.js'; import { type Calendar } from './calendar.js'; @@ -69,6 +71,7 @@ export default { showToday, showWeekNumbers }) => { + const [_, updateArgs] = useArgs(); const parseDate = (value: string | Date | undefined): Date | undefined => { if (!value) { return undefined; @@ -77,8 +80,16 @@ export default { return value instanceof Date ? value : new Date(value); }; + const selectedDate: Date | undefined = parseDate(selected); + + const onSelectDate = (event: CustomEvent) => { + console.log('Date selected:', event.detail.getFullYear(), event.detail.getMonth()); + updateArgs({ selected: new Date(event.detail).getTime() }); //needs to be set to the 'time' otherwise Storybook chokes on the date format 🤷 + }; + return html` date.toISOString()).join(','))} indicator=${ifDefined(indicator?.map(date => date.toISOString()).join(','))} > +

    + Selected date: + +

    `; } } satisfies Meta; diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index a10ce22a35..41337e2665 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -120,6 +120,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { html` @@ -147,6 +149,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { event.stopPropagation(); if (!this.selected || !isSameDate(this.selected, event.detail)) { + console.log('select date', event.detail, this.selected); this.selected = new Date(event.detail); this.changeEvent.emit(this.selected); } diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 0c843514f4..65bff130e9 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -58,6 +58,9 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The month/year to display. */ @property({ converter: dateConverter }) month = new Date(); + /** The currently selected date. (In order to style current month) */ + @property({ converter: dateConverter }) selected?: Date; + /** * The maximum date selectable in the month. * @default undefined @@ -98,6 +101,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { return { short: formatShort.format(date), long: formatLong.format(date), + date, value: i, unselectable: !( (!this.min || date >= new Date(this.min.getFullYear(), this.min.getMonth(), 1)) && @@ -169,18 +173,15 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** Returns an array of part names for a day. */ getMonthParts = (month: Month): string[] => { - console.log( - 'getMonthParts', - this.month.getMonth(), - month.value, - this.month.getFullYear(), - new Date().getFullYear() - ); return [ 'month', month.value === new Date().getMonth() && this.month.getFullYear() === new Date().getFullYear() ? 'today' : '', month.unselectable ? 'unselectable' : '', - this.month.getMonth() === month.value && this.month.getFullYear() === new Date().getFullYear() ? 'selected' : '' + this.selected && + this.selected.getMonth() === month.value && + this.selected.getFullYear() === month.date.getFullYear() + ? 'selected' + : '' ].filter(part => part !== ''); }; @@ -202,7 +203,6 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #onNext(): void { - console.log('next'); this.month = new Date(this.month.getFullYear() + 1, this.month.getMonth(), this.month.getDate()); } diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index 4dfc1eb5d3..ddfa5a6e4c 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -53,6 +53,9 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { /** The current year. */ @property({ converter: dateConverter }) year = new Date(); + /** The currently selected date. (In order to style current month) */ + @property({ converter: dateConverter }) selected?: Date; + /** * The maximum date selectable in the month. * @default undefined @@ -131,7 +134,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { return [ 'year', year === new Date().getFullYear() ? 'today' : '', - this.year.getFullYear() === year ? 'selected' : '', + this.selected && this.selected.getFullYear() === year ? 'selected' : '', this.#isUnselectable(year) ? 'unselectable' : '' ].filter(part => part !== ''); }; diff --git a/packages/components/calendar/src/utils.ts b/packages/components/calendar/src/utils.ts index ee4404696a..f6b09bf414 100644 --- a/packages/components/calendar/src/utils.ts +++ b/packages/components/calendar/src/utils.ts @@ -30,6 +30,7 @@ export interface Month { short: string; long: string; value: number; + date: Date; unselectable?: boolean; } From 7b6fe98e6ba218608a450d529c05af8a223cefd9 Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Tue, 16 Sep 2025 17:00:32 +0200 Subject: [PATCH 014/126] disable some tests to get the build working --- packages/components/calendar/src/select-day.spec.ts | 9 +++++++-- packages/components/calendar/src/select-day.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/components/calendar/src/select-day.spec.ts b/packages/components/calendar/src/select-day.spec.ts index 0d71280a10..11c05a8214 100644 --- a/packages/components/calendar/src/select-day.spec.ts +++ b/packages/components/calendar/src/select-day.spec.ts @@ -125,16 +125,21 @@ describe('sl-select-day', () => { }); // New tests exercising the next/previous navigation (invoking #onNext / #onPrevious indirectly) - describe('month navigation', () => { + // eslint-disable-next-line mocha/no-pending-tests + describe.skip('month navigation', () => { it('should go to next month when next button clicked', async () => { el = await fixture(html``); // June 2025 + console.log('displayMonth before click', el.displayMonth); await el.updateComplete; const startMonth = el.month!.getMonth(); // 5 const nextBtn: Button | null = el.renderRoot.querySelector('sl-button.next-month'); expect(nextBtn, 'next-month button should exist').to.exist; nextBtn?.click(); + await new Promise(resolve => setTimeout(resolve, 1000)); await el.updateComplete; - expect(el.month?.getMonth()).to.equal((startMonth + 1) % 12); + // debugger; + console.log('displayMonth after click', el.displayMonth); + expect(el.displayMonth?.getMonth()).to.equal((startMonth + 1) % 12); if (startMonth === 11) { expect(el.month?.getFullYear()).to.equal(2026); } diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index ad915cd0fa..ca54ee5125 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -311,6 +311,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #onNext(): void { + console.log('onNext'); this.#scrollToMonth(1, true); } @@ -326,6 +327,11 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #onScrollSnapChanging(event: Event): void { + console.log( + 'onScrollSnapChanging', + this.displayMonth, + normalizeDateTime((event.snapTargetInline as MonthView).month!) + ); if (!this.#initialized) return; this.displayMonth = normalizeDateTime((event.snapTargetInline as MonthView).month!); @@ -350,6 +356,8 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { const width = parseInt(getComputedStyle(this).width), left = width * month + width; + console.log('scrollToMonth', month, left, this.scroller); + this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); } } From 791543509fd0b28fd29851c2b776d428c337a9a4 Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Thu, 18 Sep 2025 15:58:04 +0200 Subject: [PATCH 015/126] feat: refactor SelectDay component to improve scrolling behavior and add scroll wrapper --- .../components/calendar/src/select-day.scss | 1 - .../components/calendar/src/select-day.ts | 149 ++++++++---------- 2 files changed, 70 insertions(+), 80 deletions(-) diff --git a/packages/components/calendar/src/select-day.scss b/packages/components/calendar/src/select-day.scss index 09a8e50a4c..b27f9636be 100644 --- a/packages/components/calendar/src/select-day.scss +++ b/packages/components/calendar/src/select-day.scss @@ -79,7 +79,6 @@ sl-button { sl-month-view { flex-shrink: 0; - inline-size: 100%; padding-inline: var(--sl-size-050); scroll-snap-align: start; scroll-snap-stop: always; diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index ca54ee5125..0a892e9105 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -42,7 +42,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { static override styles: CSSResultGroup = styles; /** Ignore snap events before initialized. */ - #initialized = false; + // #initialized = false; /** @internal The month/year that will be displayed in the header. */ @state() displayMonth?: Date; @@ -79,6 +79,8 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal The scroller element. */ @query('.scroller') scroller?: HTMLElement; + /** @internal The scroller element. */ + @query('.scroll-wrapper') scrollWrapper?: HTMLElement; /** The selected date. */ @property({ converter: dateConverter }) selected?: Date; @@ -107,10 +109,27 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { // eslint-disable-next-line lit/no-native-attributes @property({ type: Boolean }) override inert = false; + observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting && entry.intersectionRatio === 1) { + this.month = normalizeDateTime((entry.target as MonthView).month!); + this.#scrollToMonth(0); + } + }); + }, + { root: this.scrollWrapper, threshold: [0, 0.25, 0.5, 0.75, 1] } + ); + override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); - requestAnimationFrame(() => this.#scrollToMonth(0)); + requestAnimationFrame(() => { + this.#scrollToMonth(0); + const monthViews = this.renderRoot.querySelectorAll('sl-month-view'); + console.log('monthViews', monthViews); + monthViews.forEach(mv => this.observer.observe(mv)); + }); } override willUpdate(changes: PropertyValues): void { @@ -236,58 +255,55 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { : nothing} ${this.weekDays.map(day => html`${day.short}`)}
    -
    - - - +
    +
    + + + +
    `; } @@ -311,32 +327,9 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #onNext(): void { - console.log('onNext'); this.#scrollToMonth(1, true); } - #onScrollEnd(): void { - this.#initialized = true; - } - - #onScrollSnapChange(event: Event): void { - if (!this.#initialized) return; - - this.month = normalizeDateTime((event.snapTargetInline as MonthView).month!); - this.#scrollToMonth(0); - } - - #onScrollSnapChanging(event: Event): void { - console.log( - 'onScrollSnapChanging', - this.displayMonth, - normalizeDateTime((event.snapTargetInline as MonthView).month!) - ); - if (!this.#initialized) return; - - this.displayMonth = normalizeDateTime((event.snapTargetInline as MonthView).month!); - } - #onSelect(event: SlSelectEvent): void { event.preventDefault(); event.stopPropagation(); @@ -356,8 +349,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { const width = parseInt(getComputedStyle(this).width), left = width * month + width; - console.log('scrollToMonth', month, left, this.scroller); - this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); } } From eee7d1ff1f82f2ddfb2263a36e434fa8de82ca84 Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Fri, 19 Sep 2025 11:06:06 +0200 Subject: [PATCH 016/126] feat: add month display to MonthView and improve SelectDay component structure --- .../components/calendar/src/month-view.ts | 3 + .../calendar/src/select-day.spec.ts | 43 +++++--- .../components/calendar/src/select-day.ts | 97 +++++++++---------- 3 files changed, 77 insertions(+), 66 deletions(-) diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index 23fc6cf5fe..043fb777d8 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -157,6 +157,9 @@ export class MonthView extends LocaleMixin(LitElement) { ${this.renderHeader()} + + + ${this.calendar?.weeks.map( week => html` diff --git a/packages/components/calendar/src/select-day.spec.ts b/packages/components/calendar/src/select-day.spec.ts index 11c05a8214..65ec0f4bf7 100644 --- a/packages/components/calendar/src/select-day.spec.ts +++ b/packages/components/calendar/src/select-day.spec.ts @@ -1,10 +1,12 @@ import { expect, fixture } from '@open-wc/testing'; import { Button } from '@sl-design-system/button'; import { html } from 'lit'; +import { MonthView } from '../index.js'; import { SelectDay } from './select-day.js'; try { customElements.define('sl-select-day', SelectDay); + customElements.define('sl-month-view', MonthView); } catch { /* already defined */ } @@ -125,46 +127,51 @@ describe('sl-select-day', () => { }); // New tests exercising the next/previous navigation (invoking #onNext / #onPrevious indirectly) - // eslint-disable-next-line mocha/no-pending-tests - describe.skip('month navigation', () => { + + describe('month navigation', () => { it('should go to next month when next button clicked', async () => { - el = await fixture(html``); // June 2025 - console.log('displayMonth before click', el.displayMonth); + const startMonth = 5; // June + el = await fixture(html``); // June 2025 + await new Promise(resolve => setTimeout(resolve, 500)); // wait for the scroll animation to finish + await el.updateComplete; - const startMonth = el.month!.getMonth(); // 5 + const nextBtn: Button | null = el.renderRoot.querySelector('sl-button.next-month'); expect(nextBtn, 'next-month button should exist').to.exist; nextBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise(resolve => setTimeout(resolve, 500)); // wait for the scroll animation to finish + await el.updateComplete; - // debugger; - console.log('displayMonth after click', el.displayMonth); + expect(el.displayMonth?.getMonth()).to.equal((startMonth + 1) % 12); - if (startMonth === 11) { - expect(el.month?.getFullYear()).to.equal(2026); - } }); it('should go to previous month when previous button clicked', async () => { - el = await fixture(html``); // June 2025 + const startMonth = 5; // June + + el = await fixture(html``); // June 2025 + await new Promise(resolve => setTimeout(resolve, 500)); // wait for the scroll animation to finish + await el.updateComplete; - const startMonth = el.month!.getMonth(); // 5 + const prevBtn: Button | null = el.renderRoot.querySelector('sl-button.previous-month'); expect(prevBtn, 'previous-month button should exist').to.exist; prevBtn?.click(); + await new Promise(resolve => setTimeout(resolve, 500)); // wait for the scroll animation to finish await el.updateComplete; + const expected = (startMonth + 11) % 12; expect(el.month?.getMonth()).to.equal(expected); - if (startMonth === 0) { - expect(el.month?.getFullYear()).to.equal(2024); - } }); it('should handle year decrement when navigating from January to previous month', async () => { el = await fixture(html``); // Jan 2025 + await new Promise(resolve => setTimeout(resolve, 500)); // wait for the scroll animation to finish + await el.updateComplete; const prevBtn: Button | null = el.renderRoot.querySelector('sl-button.previous-month'); prevBtn?.click(); + await new Promise(resolve => setTimeout(resolve, 500)); // wait for the scroll animation to finish await el.updateComplete; expect(el.month?.getMonth()).to.equal(11); // Dec expect(el.month?.getFullYear()).to.equal(2024); @@ -172,9 +179,13 @@ describe('sl-select-day', () => { it('should handle year increment when navigating from December to next month', async () => { el = await fixture(html``); // Dec 2025 + await new Promise(resolve => setTimeout(resolve, 500)); // wait for the scroll animation to finish + await el.updateComplete; const nextBtn: Button | null = el.renderRoot.querySelector('sl-button.next-month'); nextBtn?.click(); + await new Promise(resolve => setTimeout(resolve, 500)); // wait for the scroll animation to finish + await el.updateComplete; expect(el.month?.getMonth()).to.equal(0); // Jan expect(el.month?.getFullYear()).to.equal(2026); diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 0a892e9105..b64d3c958a 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -127,7 +127,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { requestAnimationFrame(() => { this.#scrollToMonth(0); const monthViews = this.renderRoot.querySelectorAll('sl-month-view'); - console.log('monthViews', monthViews); monthViews.forEach(mv => this.observer.observe(mv)); }); } @@ -255,55 +254,53 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { : nothing} ${this.weekDays.map(day => html`${day.short}`)} -
    -
    - - - -
    +
    + + +
    `; } From 96127ddaa5079a8bac02e0afc74d26a9706ef5fd Mon Sep 17 00:00:00 2001 From: Diana Broeders Date: Mon, 22 Sep 2025 09:45:49 +0200 Subject: [PATCH 017/126] feat: add logging for month updates in Calendar and SelectDay components --- packages/components/calendar/src/calendar.ts | 2 ++ packages/components/calendar/src/select-day.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 41337e2665..90bff6f0e8 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -87,6 +87,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { // If only the `selected` property is set, make sure the `month` property is set // to the same date, so the selected day is visible in the calendar. this.month ??= new Date(this.selected); + console.log('willUpdate selected, setting month to', this.month, this.selected); } else { // Otherwise default to the current month. this.month ??= new Date(); @@ -95,6 +96,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { override render(): TemplateResult { return html` + ${this.month ? html`month:${this.month.getMonth() + 1}` : 'undefined month'} { if (entry.isIntersecting && entry.intersectionRatio === 1) { this.month = normalizeDateTime((entry.target as MonthView).month!); + console.log(this.month); this.#scrollToMonth(0); } }); @@ -149,6 +147,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } if (changes.has('month') && this.month) { + console.log('willUpdate', { month: this.month }); this.displayMonth = this.month; this.nextMonth = new Date(this.month.getFullYear(), this.month.getMonth() + 1); this.previousMonth = new Date(this.month.getFullYear(), this.month.getMonth() - 1); @@ -309,6 +308,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { event.preventDefault(); event.stopPropagation(); + console.log('onChange, setting month to', event.detail); this.month = new Date(event.detail.getFullYear(), event.detail.getMonth()); // Wait for the month views to rerender before focusing the day From 6214136d830004ab65388e956ad9ea079417cb5a Mon Sep 17 00:00:00 2001 From: anna-lach Date: Wed, 24 Sep 2025 13:22:51 +0200 Subject: [PATCH 018/126] fixes the problem with setting month --- packages/components/calendar/src/calendar.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 90bff6f0e8..98781b769a 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -86,15 +86,26 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { if (changes.has('selected') && this.selected) { // If only the `selected` property is set, make sure the `month` property is set // to the same date, so the selected day is visible in the calendar. - this.month ??= new Date(this.selected); - console.log('willUpdate selected, setting month to', this.month, this.selected); + // this.month ??= this.selected; //new Date(this.selected); // only assigns this.selected to this.month if this.month is null or undefined + this.month = this.selected; + // TODO: jumping here when selected has changed? + console.log( + 'willUpdate selected, setting month to', + this.month, + this.selected, + this.month.getMonth(), + this.selected.getMonth() + ); } else { // Otherwise default to the current month. this.month ??= new Date(); + console.log('willUpdate month - should be current', this.month, this.selected); } } override render(): TemplateResult { + console.log('in render', this.month, this.selected); + return html` ${this.month ? html`month:${this.month.getMonth() + 1}` : 'undefined month'} Date: Wed, 24 Sep 2025 16:12:40 +0200 Subject: [PATCH 019/126] fixed arrow keys navigation in the month view, partially got it working as well in the select year view --- .../components/calendar/src/month-view.ts | 50 ++++++++++++++++++- .../components/calendar/src/select-year.ts | 47 ++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index 043fb777d8..c1b5417480 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -43,7 +43,14 @@ export class MonthView extends LocaleMixin(LitElement) { return elements.findIndex(el => !el.disabled); }, - elements: (): HTMLButtonElement[] => Array.from(this.renderRoot.querySelectorAll('button')), + elements: (): HTMLButtonElement[] => { + // console.log( + // 'elements', + // Array.from(this.renderRoot.querySelectorAll('button')), + // this.renderRoot.querySelectorAll('button') + // ); + return Array.from(this.renderRoot.querySelectorAll('button')); + }, isFocusableElement: el => !el.disabled }); @@ -253,6 +260,47 @@ export class MonthView extends LocaleMixin(LitElement) { event.stopPropagation(); this.changeEvent.emit(new Date(day.date.getFullYear(), day.date.getMonth() + 1, 1)); + } else if (event.key === 'ArrowUp' && day.currentMonth /*&& day.date.getDate() === 1*/) { + // event.preventDefault(); + // event.stopPropagation(); + // + // this.changeEvent.emit(new Date(day.date.getFullYear(), day.date.getMonth(), 0)); + + const crossesMonthBoundary = day.date.getDate() - 7 < 1; + // Move to the same weekday in previous month + if (crossesMonthBoundary) { + event.preventDefault(); + event.stopPropagation(); + + const targetDate = new Date(day.date.getFullYear(), day.date.getMonth(), day.date.getDate() - 7); + this.changeEvent.emit(targetDate); + } + } else if (event.key === 'ArrowDown' && day.currentMonth /*&& day.lastDayOfMonth*/) { + console.log('down on last day of month', day, day.currentMonth, day.lastDayOfMonth); + // event.preventDefault(); + // event.stopPropagation(); + // + // this.changeEvent.emit(new Date(day.date.getFullYear(), day.date.getMonth() + 1, 1)); + + // const lastDateOfMonth = new Date(day.date.getFullYear(), day.date.getMonth() + 1, 0).getDate(); + // const isInLastWeek = day.date.getDate() + 7 > lastDateOfMonth; + // + // if (isInLastWeek) { + // event.preventDefault(); + // event.stopPropagation(); + // this.changeEvent.emit(new Date(day.date.getFullYear(), day.date.getMonth() + 1, 1)); + // } + + const lastDateOfMonth = new Date(day.date.getFullYear(), day.date.getMonth() + 1, 0).getDate(); + const crossesMonthBoundary = day.date.getDate() + 7 > lastDateOfMonth; + // Move to the same weekday in next month + if (crossesMonthBoundary) { + event.preventDefault(); + event.stopPropagation(); + + const targetDate = new Date(day.date.getFullYear(), day.date.getMonth(), day.date.getDate() + 7); + this.changeEvent.emit(targetDate); + } } else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index ddfa5a6e4c..0d60d499f1 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -151,7 +151,52 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } #onKeydown(event: KeyboardEvent): void { - if (event.key === 'Escape') { + if (event.key === 'ArrowLeft' /*&& year.currentMonth && day.date.getDate() === 1*/) { + // this.#onPrevious(); + + const buttons = Array.from(this.renderRoot.querySelectorAll('ol button')); + const activeEl = this.shadowRoot?.activeElement as HTMLButtonElement | null; + const index = activeEl ? buttons.indexOf(activeEl) : -1; + if (index === 0) { + event.preventDefault(); + event.stopPropagation(); + this.#onPrevious(); + } + } else if (event.key === 'ArrowRight' /*&& day.currentMonth && day.lastDayOfMonth*/) { + const buttons = Array.from(this.renderRoot.querySelectorAll('ol button')); + const activeEl = this.shadowRoot?.activeElement as HTMLButtonElement | null; + const index = activeEl ? buttons.indexOf(activeEl) : -1; + if (index === buttons.length - 1) { + event.preventDefault(); + event.stopPropagation(); + this.#onNext(); + } + } else if (event.key === 'ArrowUp' /*&& day.date.getDate() === 1*/) { + // event.preventDefault(); + // event.stopPropagation(); + // + // this.changeEvent.emit(new Date(day.date.getFullYear(), day.date.getMonth(), 0)); + + const buttons = Array.from(this.renderRoot.querySelectorAll('ol button')); + const activeEl = this.shadowRoot?.activeElement as HTMLButtonElement | null; + const index = activeEl ? buttons.indexOf(activeEl) : -1; + if (index === 0) { + event.preventDefault(); + event.stopPropagation(); + this.#onPrevious(); + } + } else if (event.key === 'ArrowDown' /* && day.currentMonth*/ /*&& day.lastDayOfMonth*/) { + console.log('down on last day of month'); + + const buttons = Array.from(this.renderRoot.querySelectorAll('ol button')); + const activeEl = this.shadowRoot?.activeElement as HTMLButtonElement | null; + const index = activeEl ? buttons.indexOf(activeEl) : -1; + if (index === buttons.length - 1) { + event.preventDefault(); + event.stopPropagation(); + this.#onNext(); + } + } else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); From 173fe8c71599ba68eb7523baa66cf3388fec88cf Mon Sep 17 00:00:00 2001 From: anna-lach Date: Thu, 25 Sep 2025 10:56:45 +0200 Subject: [PATCH 020/126] select year keyboard navigation changes --- .../components/calendar/src/select-year.ts | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index 0d60d499f1..eedfa83cc5 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -5,7 +5,7 @@ import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, EventsController, RovingTabindexController, event } from '@sl-design-system/shared'; import { dateConverter } from '@sl-design-system/shared/converters.js'; import { type SlSelectEvent } from '@sl-design-system/shared/events.js'; -import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; import { property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import styles from './select-year.scss.js'; @@ -34,7 +34,8 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { // eslint-disable-next-line no-unused-private-class-members #events = new EventsController(this, { keydown: this.#onKeydown }); - // eslint-disable-next-line no-unused-private-class-members + // #focusLastOnRender = false; + #rovingTabindexController = new RovingTabindexController(this, { direction: 'grid', directionLength: 3, @@ -43,6 +44,17 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { const index = elements.findIndex(el => el.hasAttribute('aria-pressed')); return index === -1 ? 0 : index; + + // const pressedIndex = elements.findIndex(el => el.hasAttribute('aria-pressed')); + // if (pressedIndex !== -1) { + // return pressedIndex; + // } + // // console.log('focusing last element', this.#focusLastOnRender); + // if (this.#focusLastOnRender) { + // // console.log('focusing last element', this.#focusLastOnRender); + // return elements.length - 1; + // } + // return 0; }, listenerScope: (): HTMLElement => this.renderRoot.querySelector('ol')! }); @@ -83,6 +95,15 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { this.#setYears(this.year.getFullYear() - 5, this.year.getFullYear() + 6); } + override willUpdate(changes: PropertyValues): void { + console.log('SelectYear willUpdate changes', changes); + + if (changes.has('years') || changes.has('inert')) { + this.#rovingTabindexController.clearElementCache(); + // this.#rovingTabindexController.focusToElement(this.selected); + } + } + override render(): TemplateResult { return html`
    @@ -158,9 +179,26 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { const activeEl = this.shadowRoot?.activeElement as HTMLButtonElement | null; const index = activeEl ? buttons.indexOf(activeEl) : -1; if (index === 0) { + console.log('left on first year of range'); event.preventDefault(); event.stopPropagation(); - this.#onPrevious(); + // this.#onPrevious(); + + const start = this.years[0] - 12; + const end = this.years[0] - 1; + this.#setYears(start, end); + // void this.updateComplete.then(() => { + // const buttons = this.renderRoot.querySelectorAll('ol button'); + // (buttons[buttons.length - 1] as HTMLButtonElement | undefined)?.focus(); + // }); + void this.updateComplete.then(() => { + // this.#rovingTabindexController.clearElementCache(); + // this.#focusLastOnRender = true; + // (buttons[buttons.length - 1] as HTMLButtonElement | undefined)?.focus(); + // this.#rovingTabindexController.clearElementCache(); + this.#rovingTabindexController.focusToElement(buttons[buttons.length - 1] as HTMLButtonElement); + }); + // this.#rovingTabindexController.clearElementCache(); } } else if (event.key === 'ArrowRight' /*&& day.currentMonth && day.lastDayOfMonth*/) { const buttons = Array.from(this.renderRoot.querySelectorAll('ol button')); @@ -169,7 +207,15 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { if (index === buttons.length - 1) { event.preventDefault(); event.stopPropagation(); + this.#onNext(); + void this.updateComplete.then(() => { + // this.#rovingTabindexController.clearElementCache(); + const first = this.renderRoot.querySelector('ol button') as HTMLButtonElement; + if (first) { + this.#rovingTabindexController.focusToElement(first); + } + }); // TODO: should work as well when we have limited years with min and max... } } else if (event.key === 'ArrowUp' /*&& day.date.getDate() === 1*/) { // event.preventDefault(); @@ -206,6 +252,12 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { #onPrevious(): void { this.#setYears(this.years[0] - 12, this.years[0] - 1); + + // this.#focusLastOnRender = true; + // this.#setYears(this.years[0] - 12, this.years[0] - 1); + // void this.updateComplete.then(() => { + // this.#focusLastOnRender = false; + // }); } #onNext(): void { From f731d360f86c75a3a1fbd3dbe89e579f9d67d930 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Thu, 25 Sep 2025 16:15:52 +0200 Subject: [PATCH 021/126] make it working with min and max in the select year view, also with arrow keys, using buttons disabled instead of span for disabled years --- packages/components/calendar/src/calendar.ts | 2 + .../components/calendar/src/select-year.scss | 5 +- .../components/calendar/src/select-year.ts | 112 ++++++++++++++++-- 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 98781b769a..2ca78d2616 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -149,6 +149,8 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { @sl-select=${this.#onSelectYear} .selected=${this.selected} ?show-today=${this.showToday} + max=${ifDefined(this.max?.toISOString())} + min=${ifDefined(this.min?.toISOString())} .year=${this.month} > ` diff --git a/packages/components/calendar/src/select-year.scss b/packages/components/calendar/src/select-year.scss index 4d2745d147..f49b0c0371 100644 --- a/packages/components/calendar/src/select-year.scss +++ b/packages/components/calendar/src/select-year.scss @@ -57,11 +57,11 @@ button { transition-property: background, border-radius, color; } - &:hover { + &:hover:not([part~='unselectable']) { --_bg-opacity: var(--sl-opacity-interactive-plain-hover); } - &:active { + &:active:not([part~='unselectable']) { --_bg-opacity: var(--sl-opacity-interactive-plain-active); } @@ -95,6 +95,7 @@ button { [part~='unselectable'] { color: var(--sl-color-foreground-disabled); + cursor: default; } [part~='selected'] { diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index eedfa83cc5..e44b73d22d 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -39,7 +39,14 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { #rovingTabindexController = new RovingTabindexController(this, { direction: 'grid', directionLength: 3, - elements: (): HTMLElement[] => Array.from(this.renderRoot.querySelectorAll('ol button')), + // elements: (): HTMLElement[] => Array.from(this.renderRoot.querySelectorAll('ol button')), + // elements: (): HTMLElement[] => + // Array.from(this.renderRoot.querySelectorAll('ol button')).filter(btn => !btn.disabled), + elements: (): HTMLElement[] => { + const list = this.renderRoot.querySelector('ol'); + if (!list) return []; + return Array.from(list.querySelectorAll('button')).filter(btn => !btn.disabled); + }, focusInIndex: elements => { const index = elements.findIndex(el => el.hasAttribute('aria-pressed')); @@ -98,7 +105,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { override willUpdate(changes: PropertyValues): void { console.log('SelectYear willUpdate changes', changes); - if (changes.has('years') || changes.has('inert')) { + if (changes.has('max') || changes.has('min') || changes.has('years') || changes.has('inert')) { this.#rovingTabindexController.clearElementCache(); // this.#rovingTabindexController.focusToElement(this.selected); } @@ -133,7 +140,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { return html`
  • ${this.#isUnselectable(year) - ? html`${year}` + ? html`` : html` ` : html`
  • ${1 + (this.month?.getMonth() ?? 0)}
    ${this.renderHeader()} @@ -231,6 +236,8 @@ export class MonthView extends LocaleMixin(LitElement) { renderDay(day: Day): TemplateResult { let template: TemplateResult | undefined; + console.log('day in renderDay', day, 'day.disabled?', day.disabled); + if (this.renderer) { template = this.renderer(day, this); } else if (this.hideDaysOtherMonths && (day.nextMonth || day.previousMonth)) { @@ -239,6 +246,8 @@ export class MonthView extends LocaleMixin(LitElement) { const parts = this.getDayParts(day).join(' '), ariaLabel = `${day.date.getDate()}, ${format(day.date, this.locale, { weekday: 'long' })} ${format(day.date, this.locale, { month: 'long', year: 'numeric' })}`; + // TODO: maybe disabled -> unselectable here as well? + template = this.readonly || day.unselectable ? html`${day.date.getDate()}` @@ -257,7 +266,7 @@ export class MonthView extends LocaleMixin(LitElement) { return html` `; - } + } // TODO: buttons instead of spans for unselectable days, still problems with disabled? /** Returns an array of part names for a day. */ getDayParts = (day: Day): string[] => { @@ -284,6 +293,7 @@ export class MonthView extends LocaleMixin(LitElement) { day.previousMonth ? 'previous-month' : '', day.today ? 'today' : '', day.unselectable ? 'unselectable' : '', + this.disabled && isDateInList(day.date, this.disabled) ? 'unselectable' : '', this.negative && isDateInList(day.date, this.negative) ? 'negative' : '', // this.indicator && isDateInList(day.date, this.indicator) ? 'indicator' : '', // this.indicator && diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 99c2206711..08145ffaf9 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -41,6 +41,9 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal */ static override styles: CSSResultGroup = styles; + /** The list of dates that should be set as disabled. */ + @property({ converter: dateConverter }) disabled?: Date[]; + /** @internal The month/year that will be displayed in the header. */ @state() displayMonth?: Date; @@ -96,18 +99,18 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { toAttribute: (value?: Indicator[]) => value ? JSON.stringify( - value.map(i => ({ - ...i, - date: dateConverter.toAttribute?.(i.date) - })) - ) + value.map(i => ({ + ...i, + date: dateConverter.toAttribute?.(i.date) + })) + ) : undefined, fromAttribute: (value: string | null, _type?: unknown) => value ? (JSON.parse(value) as Array<{ date: string; color?: IndicatorColor }>).map(i => ({ - ...i, - date: dateConverter.fromAttribute?.(i.date) - })) + ...i, + date: dateConverter.fromAttribute?.(i.date) + })) : undefined } }) @@ -291,11 +294,12 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { ?readonly=${this.readonly} ?show-today=${this.showToday} ?show-week-numbers=${this.showWeekNumbers} + .disabled=${this.disabled} .firstDayOfWeek=${this.firstDayOfWeek} + .indicator=${this.indicator} .month=${this.previousMonth} - .selected=${this.selected} .negative=${this.negative} - .indicator=${this.indicator} + .selected=${this.selected} aria-hidden="true" inert max=${ifDefined(this.max?.toISOString())} @@ -308,11 +312,12 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { ?readonly=${this.readonly} ?show-today=${this.showToday} ?show-week-numbers=${this.showWeekNumbers} + .disabled=${this.disabled} .firstDayOfWeek=${this.firstDayOfWeek} + .indicator=${this.indicator} .month=${this.month} - .selected=${this.selected} .negative=${this.negative} - .indicator=${this.indicator} + .selected=${this.selected} ?inert=${this.inert} locale=${ifDefined(this.locale)} max=${ifDefined(this.max?.toISOString())} @@ -322,11 +327,12 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { ?readonly=${this.readonly} ?show-today=${this.showToday} ?show-week-numbers=${this.showWeekNumbers} + .disabled=${this.disabled} .firstDayOfWeek=${this.firstDayOfWeek} + .indicator=${this.indicator} .month=${this.nextMonth} - .selected=${this.selected} .negative=${this.negative} - .indicator=${this.indicator} + .selected=${this.selected} aria-hidden="true" inert locale=${ifDefined(this.locale)} diff --git a/packages/components/calendar/src/utils.ts b/packages/components/calendar/src/utils.ts index f6b09bf414..2d894a1175 100644 --- a/packages/components/calendar/src/utils.ts +++ b/packages/components/calendar/src/utils.ts @@ -135,6 +135,12 @@ export function isDateInList(date: Date, list?: Date[] | string): boolean { if (typeof list === 'string') { list = list.split(',').map(item => new Date(item)); } + console.log( + 'is day in list', + date, + list, + list.some(item => isSameDate(item, date)) + ); return list.some(item => isSameDate(item, date)); } From b9f35bb0631a339d3fab0eb197046cb0655872fb Mon Sep 17 00:00:00 2001 From: anna-lach Date: Fri, 3 Oct 2025 14:33:39 +0200 Subject: [PATCH 031/126] some styling improvements, disabled days are now being rendered properly, fixing keyboard navigation to get it working with disabled days as well --- .../components/calendar/src/month-view.scss | 13 ++- .../components/calendar/src/month-view.ts | 85 +++++++++++++++---- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/packages/components/calendar/src/month-view.scss b/packages/components/calendar/src/month-view.scss index 96a97c190f..b7d37eac6b 100644 --- a/packages/components/calendar/src/month-view.scss +++ b/packages/components/calendar/src/month-view.scss @@ -29,11 +29,11 @@ button { transition-property: background, border-radius, color; } - &:hover { + &:hover:not([part~='unselectable']) { --_bg-opacity: var(--sl-opacity-interactive-plain-hover); } - &:active { + &:active:not([part~='unselectable']) { --_bg-opacity: var(--sl-opacity-interactive-plain-active); } @@ -44,7 +44,9 @@ button { [part~='day'] { --_bg-color: transparent; - --_bg-mix-color: var(--sl-color-background-info-interactive-plain); + --_bg-mix-color: var( + --sl-color-background-neutral-interactive-bold + ); // var(--sl-color-background-info-interactive-plain); --_bg-opacity: var(--sl-opacity-interactive-plain-idle); align-items: center; @@ -101,6 +103,7 @@ button { [part~='unselectable'] { color: var(--sl-color-foreground-disabled); + cursor: default; &[part~='selected'] { --_bg-color: var(--sl-color-background-disabled); @@ -110,6 +113,10 @@ button { } } +:host([show-today]) [part~='unselectable'][part~='today'] { + border-color: var(--sl-color-border-disabled); +} + [part~='indicator']:not([part~='unselectable'])::after { background: var(--sl-color-background-accent-blue-bold); block-size: var(--sl-size-075); diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index 1c3f2c662f..6eb142de5a 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -53,7 +53,9 @@ export class MonthView extends LocaleMixin(LitElement) { // Array.from(this.renderRoot.querySelectorAll('button')), // this.renderRoot.querySelectorAll('button') // ); - return Array.from(this.renderRoot.querySelectorAll('button')); + // return Array.from(this.renderRoot.querySelectorAll('button')); + // return Array.from(this.renderRoot.querySelectorAll('button')).filter(btn => !btn.disabled); + return Array.from(this.renderRoot.querySelectorAll('button')); // keep disabled buttons to preserve grid alignment }, isFocusableElement: el => !el.disabled }); @@ -236,7 +238,15 @@ export class MonthView extends LocaleMixin(LitElement) { renderDay(day: Day): TemplateResult { let template: TemplateResult | undefined; - console.log('day in renderDay', day, 'day.disabled?', day.disabled); + // console.log('day in renderDay', day, 'day.disabled?', day.disabled); + + // if (this.disabled && isDateInList(day.date, this.disabled)) { + // // this.disabled && isDateInList(day.date, this.disabled) ? 'unselectable' : ''; + // day.disabled = true; + // day.unselectable = true; + // } + + // TODO: fix roving tab index up and down when days are disabled if (this.renderer) { template = this.renderer(day, this); @@ -248,9 +258,22 @@ export class MonthView extends LocaleMixin(LitElement) { // TODO: maybe disabled -> unselectable here as well? + // TODO: why the bunselectable is not disabled button in the DOM? + + console.log( + 'day before template', + day, + day.date.getDate(), + 'readonly?', + this.readonly, + 'day.unselectable?', + day.unselectable, + parts + ); + template = - this.readonly || day.unselectable - ? html`${day.date.getDate()}` + this.readonly || day.unselectable || day.disabled || isDateInList(day.date, this.disabled) + ? html`` : html` `; + + // console.log('template in renderDay', template); } return html` @@ -342,27 +367,38 @@ export class MonthView extends LocaleMixin(LitElement) { this.changeEvent.emit(new Date(day.date.getFullYear(), day.date.getMonth() + 1, 1)); } else if (event.key === 'ArrowUp' && day.currentMonth) { - const crossesMonthBoundary = day.date.getDate() - 7 < 1; + // Whether it's possible to move to the same weekday in previous weeks (skipping disabled) + const possibleDay = this.#getEnabledSameWeekday(day.date, -1); - // Move to the same weekday in previous month - if (crossesMonthBoundary) { + if (!possibleDay) { + return; + } + + const crossesMonth = + possibleDay.getMonth() !== day.date.getMonth() || possibleDay.getFullYear() !== day.date.getFullYear(); + + if (crossesMonth) { event.preventDefault(); event.stopPropagation(); - const targetDate = new Date(day.date.getFullYear(), day.date.getMonth(), day.date.getDate() - 7); - this.changeEvent.emit(targetDate); + this.changeEvent.emit(possibleDay); } } else if (event.key === 'ArrowDown' && day.currentMonth) { - const lastDateOfMonth = new Date(day.date.getFullYear(), day.date.getMonth() + 1, 0).getDate(), - crossesMonthBoundary = day.date.getDate() + 7 > lastDateOfMonth; + // Whether it's possible to move to the same weekday in following weeks (skipping disabled) + const possibleDay = this.#getEnabledSameWeekday(day.date, 1); + + if (!possibleDay) { + return; + } - // Move to the same weekday in next month - if (crossesMonthBoundary) { + const crossesMonth = + possibleDay.getMonth() !== day.date.getMonth() || possibleDay.getFullYear() !== day.date.getFullYear(); + + if (crossesMonth) { event.preventDefault(); event.stopPropagation(); - const targetDate = new Date(day.date.getFullYear(), day.date.getMonth(), day.date.getDate() + 7); - this.changeEvent.emit(targetDate); + this.changeEvent.emit(possibleDay); } } else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); @@ -371,4 +407,23 @@ export class MonthView extends LocaleMixin(LitElement) { this.selectEvent.emit(day.date); } } + + /** Nearest enabled same-weekday date (weekly steps: -1 or 1) */ + #getEnabledSameWeekday(start: Date, direction: 1 | -1): Date | undefined { + const findEnabledSameWeekday = (current: Date): Date | undefined => { + const possibleDay = new Date(current.getFullYear(), current.getMonth(), current.getDate() + 7 * direction); + + if ((this.min && possibleDay < this.min) || (this.max && possibleDay > this.max)) { + return undefined; + } + + if (!(this.disabled && isDateInList(possibleDay, this.disabled))) { + return possibleDay; + } + + return findEnabledSameWeekday(possibleDay); + }; + + return findEnabledSameWeekday(start); + } } From 99c3ab35462748a96a4dc365e4192b84cef15b36 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Fri, 3 Oct 2025 15:53:20 +0200 Subject: [PATCH 032/126] indicators stories fixes --- .../calendar/src/calendar.stories.ts | 11 +++++- .../calendar/src/month-view.stories.ts | 28 ++++++++++++-- .../components/calendar/src/month-view.ts | 38 +++++-------------- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index 96e20b1473..883e6859d1 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -5,7 +5,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { useArgs } from 'storybook/internal/preview-api'; import '../register.js'; import { type Calendar } from './calendar.js'; -import { type Indicator, type IndicatorColor } from './month-view.js'; +import { type IndicatorColor } from './month-view.js'; type Props = Pick< Calendar, @@ -233,7 +233,14 @@ export const All: Story = { negative=${ifDefined(settings.negative?.map(date => date.toISOString()).join(','))} indicator=${ifDefined( Array.isArray(settings.indicator) - ? settings.indicator.map((item: Indicator) => `${item.date.toISOString()}:${item.color}`).join(',') + ? JSON.stringify( + settings.indicator + .filter(item => item?.date) + .map(item => ({ + date: item.date.toISOString(), + ...(item.color ? { color: item.color } : {}) + })) + ) : undefined )} > diff --git a/packages/components/calendar/src/month-view.stories.ts b/packages/components/calendar/src/month-view.stories.ts index 7b5f54b5e1..567cc5afb0 100644 --- a/packages/components/calendar/src/month-view.stories.ts +++ b/packages/components/calendar/src/month-view.stories.ts @@ -94,7 +94,18 @@ export default { ?show-today=${showToday} ?show-week-numbers=${showWeekNumbers} first-day-of-week=${ifDefined(firstDayOfWeek)} - indicator=${ifDefined(indicator?.map(ind => ind.date.toISOString()).join(','))} + indicator=${ifDefined( + Array.isArray(indicator) + ? JSON.stringify( + indicator + .filter(item => item?.date) + .map(item => ({ + date: item.date.toISOString(), + ...(item.color ? { color: item.color } : {}) + })) + ) + : undefined + )} locale=${ifDefined(locale)} max=${ifDefined(max?.toISOString())} min=${ifDefined(min?.toISOString())} @@ -107,6 +118,8 @@ export default { export const Basic: Story = {}; +// TODO: selecting when clicking on it should work in the mont-view as well? + export const FirstDayOfWeek: Story = { args: { firstDayOfWeek: 0 @@ -185,8 +198,17 @@ export const Today: Story = { export const Indicator: Story = { args: { - // indicator: [new Date(), new Date('2025-08-05')], - indicator: [{ date: new Date() }, { date: new Date('2025-08-05') }], + // indicator: [new Date(), new Date('2025-08-05')], + indicator: [ + { date: new Date('2025-08-05') }, + { date: new Date('2025-08-06'), color: 'blue' }, + { date: new Date('2025-08-07'), color: 'red' }, + { date: new Date('2025-08-09'), color: 'yellow' }, + { date: new Date('2025-08-10'), color: 'green' }, + { date: new Date('2025-08-20'), color: 'grey' }, + { date: new Date('2025-08-22'), color: 'green' }, + { date: new Date('2025-08-27'), color: 'yellow' } + ], showToday: true, month: new Date(1755640800000) } diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index 6eb142de5a..c57c253371 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -238,14 +238,6 @@ export class MonthView extends LocaleMixin(LitElement) { renderDay(day: Day): TemplateResult { let template: TemplateResult | undefined; - // console.log('day in renderDay', day, 'day.disabled?', day.disabled); - - // if (this.disabled && isDateInList(day.date, this.disabled)) { - // // this.disabled && isDateInList(day.date, this.disabled) ? 'unselectable' : ''; - // day.disabled = true; - // day.unselectable = true; - // } - // TODO: fix roving tab index up and down when days are disabled if (this.renderer) { @@ -284,8 +276,6 @@ export class MonthView extends LocaleMixin(LitElement) { ${day.date.getDate()} `; - - // console.log('template in renderDay', template); } return html` @@ -301,16 +291,16 @@ export class MonthView extends LocaleMixin(LitElement) { this.indicator ? this.indicator[0].date : 'no indicator', this.indicator?.length ); - console.log( - 'indicator part applied?', - this.indicator && - isDateInList( - day.date, - this.indicator.map(i => i.date) - ) - ? 'indicator' - : '' - ); + // console.log( + // 'indicator part applied?', + // this.indicator && + // isDateInList( + // day.date, + // this.indicator.map(i => i.date) + // ) + // ? 'indicator' + // : '' + // ); return [ 'day', @@ -320,14 +310,6 @@ export class MonthView extends LocaleMixin(LitElement) { day.unselectable ? 'unselectable' : '', this.disabled && isDateInList(day.date, this.disabled) ? 'unselectable' : '', this.negative && isDateInList(day.date, this.negative) ? 'negative' : '', - // this.indicator && isDateInList(day.date, this.indicator) ? 'indicator' : '', - // this.indicator && - // isDateInList( - // day.date, - // this.indicator.map(i => i.date) - // ) - // ? 'indicator' - // : '', this.indicator && isDateInList( day.date, From 57a5714c67f9180bb015ac360f264fbadcc8c10b Mon Sep 17 00:00:00 2001 From: anna-lach Date: Fri, 3 Oct 2025 16:03:37 +0200 Subject: [PATCH 033/126] small changes to select year story --- .../calendar/src/select-year.stories.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/components/calendar/src/select-year.stories.ts b/packages/components/calendar/src/select-year.stories.ts index 5e7d81035a..c7f48a6dae 100644 --- a/packages/components/calendar/src/select-year.stories.ts +++ b/packages/components/calendar/src/select-year.stories.ts @@ -1,5 +1,5 @@ import { type Meta, type StoryObj } from '@storybook/web-components-vite'; -import { html, nothing } from 'lit'; +import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; import { useArgs } from 'storybook/internal/preview-api'; import '../register.js'; @@ -52,20 +52,26 @@ export default { }; return html` - ${styles - ? html` - - ` - : nothing} - + +
    + +
    `; } } satisfies Meta; From acd2d881c3965aacdd0bd92f86e2bb5c4be08062 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Mon, 6 Oct 2025 15:46:28 +0200 Subject: [PATCH 034/126] stories changes, make it possible to select month and year and day in single view (not in the calendar only), disabled fixes, some unit tests - first part --- .../calendar/src/calendar.stories.ts | 13 +++-- packages/components/calendar/src/calendar.ts | 4 +- .../components/calendar/src/month-view.scss | 9 ++- .../calendar/src/month-view.stories.ts | 18 +++++- .../components/calendar/src/month-view.ts | 4 +- .../calendar/src/select-day.spec.ts | 56 +++++++++++++++++++ .../components/calendar/src/select-day.ts | 12 +--- .../components/calendar/src/select-month.ts | 5 +- .../calendar/src/select-year.stories.ts | 2 +- .../components/calendar/src/select-year.ts | 1 + 10 files changed, 101 insertions(+), 23 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index 883e6859d1..d76ed41921 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -187,9 +187,11 @@ export const WithIndicator: Story = { export const DisabledDays: Story = { args: { - disabled: [new Date(), new Date('2025-08-05'), new Date('2025-10-05'), new Date('2025-10-10')], - showToday: true, - month: new Date('2025-09-01') //new Date(1755640800000) + disabled: [new Date('2025-10-06'), new Date('2025-10-07'), new Date('2025-10-10')], + // showToday: true, + max: new Date(2025, 10, 20), + min: new Date(2025, 9, 4), + month: new Date(2025, 9, 20) //new Date(1755640800000) } }; @@ -262,7 +264,10 @@ export const All: Story = { indicator: [ { date: getOffsetDate(0), color: 'red' as IndicatorColor }, { date: getOffsetDate(1), color: 'blue' as IndicatorColor }, - { date: getOffsetDate(6), color: 'green' as IndicatorColor } + { date: getOffsetDate(2), color: 'yellow' as IndicatorColor }, + { date: getOffsetDate(3), color: 'grey' as IndicatorColor }, + { date: getOffsetDate(5), color: 'green' as IndicatorColor }, + { date: getOffsetDate(8), color: 'green' as IndicatorColor } ], // make sure one is outside the min/max range selected: getOffsetDate(1), showToday: true, diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 798367ec13..2ac42392bd 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -152,7 +152,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { console.log('indicator in the calendar render', this.indicator); - console.log('disabled dates', this.disabled); + console.log('disabled dates', this.disabled, 'min and max', this.min, this.max, 'month', this.month); return html` ${this.month ? html`month:${this.month.getMonth() + 1}` : 'undefined month'} @@ -296,3 +296,5 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { } } // TODO: there is an issue when I go to months view and then go to the years view and then select a year and go back to selecting month from months view - I cannot use arrow keays properly there... why? + +// TODO: what aria for current day (today) and what for selected day? diff --git a/packages/components/calendar/src/month-view.scss b/packages/components/calendar/src/month-view.scss index b7d37eac6b..257b68830e 100644 --- a/packages/components/calendar/src/month-view.scss +++ b/packages/components/calendar/src/month-view.scss @@ -29,11 +29,11 @@ button { transition-property: background, border-radius, color; } - &:hover:not([part~='unselectable']) { + &:hover:not([part~='unselectable'], [disabled]) { --_bg-opacity: var(--sl-opacity-interactive-plain-hover); } - &:active:not([part~='unselectable']) { + &:active:not([part~='unselectable'], [disabled]) { --_bg-opacity: var(--sl-opacity-interactive-plain-active); } @@ -47,6 +47,7 @@ button { --_bg-mix-color: var( --sl-color-background-neutral-interactive-bold ); // var(--sl-color-background-info-interactive-plain); + --_bg-opacity: var(--sl-opacity-interactive-plain-idle); align-items: center; @@ -101,6 +102,10 @@ button { border-color: var(--sl-color-border-negative-plain); } +[disabled] { + cursor: default; +} + [part~='unselectable'] { color: var(--sl-color-foreground-disabled); cursor: default; diff --git a/packages/components/calendar/src/month-view.stories.ts b/packages/components/calendar/src/month-view.stories.ts index 567cc5afb0..0422256b9b 100644 --- a/packages/components/calendar/src/month-view.stories.ts +++ b/packages/components/calendar/src/month-view.stories.ts @@ -9,6 +9,7 @@ import { type Day } from './utils.js'; type Props = Pick< MonthView, + | 'disabled' | 'firstDayOfWeek' | 'hideDaysOtherMonths' | 'indicator' @@ -37,6 +38,9 @@ export default { showWeekNumbers: false }, argTypes: { + disabled: { + control: 'date' + }, firstDayOfWeek: { control: 'number' }, @@ -67,6 +71,7 @@ export default { } }, render: ({ + disabled, firstDayOfWeek, hideDaysOtherMonths, indicator, @@ -93,6 +98,7 @@ export default { ?readonly=${readonly} ?show-today=${showToday} ?show-week-numbers=${showWeekNumbers} + .disabled=${ifDefined(disabled?.map(date => date.toISOString()).join(','))} first-day-of-week=${ifDefined(firstDayOfWeek)} indicator=${ifDefined( Array.isArray(indicator) @@ -120,6 +126,8 @@ export const Basic: Story = {}; // TODO: selecting when clicking on it should work in the mont-view as well? +// TODO: disabled days story is missing + export const FirstDayOfWeek: Story = { args: { firstDayOfWeek: 0 @@ -198,7 +206,6 @@ export const Today: Story = { export const Indicator: Story = { args: { - // indicator: [new Date(), new Date('2025-08-05')], indicator: [ { date: new Date('2025-08-05') }, { date: new Date('2025-08-06'), color: 'blue' }, @@ -214,6 +221,15 @@ export const Indicator: Story = { } }; +export const DisabledDays: Story = { + args: { + disabled: [new Date('2025-10-06'), new Date('2025-10-07'), new Date('2025-10-17')], + max: new Date(2025, 9, 25), + min: new Date(2025, 9, 4), + month: new Date(2025, 9, 1) + } +}; + export const WeekNumbers: Story = { args: { showWeekNumbers: true diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index c57c253371..c974901cc0 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -202,7 +202,7 @@ export class MonthView extends LocaleMixin(LitElement) { } override render(): TemplateResult { - console.log('disabled dates in month view', this.disabled); + // console.log('disabled dates in month view', this.disabled); return html`
    this.#onClick(event, day)} data-date=${day.date.toISOString()}>${template}
    @@ -332,8 +332,10 @@ export class MonthView extends LocaleMixin(LitElement) { } #onClick(event: Event, day: Day): void { + console.log('click event in month view...', event, day); if (event.target instanceof HTMLButtonElement && !event.target.disabled) { this.selectEvent.emit(day.date); + this.selected = day.date; } } diff --git a/packages/components/calendar/src/select-day.spec.ts b/packages/components/calendar/src/select-day.spec.ts index 005fa57f7a..fd68cfd5e0 100644 --- a/packages/components/calendar/src/select-day.spec.ts +++ b/packages/components/calendar/src/select-day.spec.ts @@ -192,4 +192,60 @@ describe('sl-select-day', () => { expect(el.month?.getFullYear()).to.equal(2026); }); }); + + describe('header rendering & readonly behavior', () => { + it('should render static month and year (no toggle buttons) when navigation is fully disabled by min/max', async () => { + el = await fixture(html` + + `); + await el.updateComplete; + + const monthButton = el.renderRoot.querySelector('sl-button.current-month'); + const monthSpan = el.renderRoot.querySelector('span.current-month'); + const yearButton = el.renderRoot.querySelector('sl-button.current-year'); + const yearSpan = el.renderRoot.querySelector('span.current-year'); + + expect(monthButton, 'month should not be a button').to.not.exist; + expect(monthSpan, 'month should render as span').to.exist; + expect(yearButton, 'year should not be a button').to.not.exist; + expect(yearSpan, 'year should render as span').to.exist; + }); + + it('should disable all interactive header controls when readonly', async () => { + el = await fixture(html``); + await el.updateComplete; + + el.readonly = true; + await el.updateComplete; + + const currentMonthBtn = el.renderRoot.querySelector('sl-button.current-month'), + currentYearBtn = el.renderRoot.querySelector('sl-button.current-year'), + prevBtn = el.renderRoot.querySelector('sl-button.previous-month'), + nextBtn = el.renderRoot.querySelector('sl-button.next-month'); + + expect(currentMonthBtn).to.exist; + expect(currentMonthBtn).to.match(':disabled'); + expect(currentYearBtn).to.exist; + expect(currentYearBtn).to.match(':disabled'); + expect(prevBtn).to.exist; + expect(prevBtn).to.match(':disabled'); + expect(nextBtn).to.exist; + expect(nextBtn).to.match(':disabled'); + }); + + it('should still show toggle buttons (not spans) when navigation is possible', async () => { + el = await fixture(html``); + await el.updateComplete; + + const monthButton = el.renderRoot.querySelector('sl-button.current-month'); + const yearButton = el.renderRoot.querySelector('sl-button.current-year'); + + expect(monthButton).to.exist; + expect(yearButton).to.exist; + }); + }); }); diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 08145ffaf9..b222e6a9ff 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -90,9 +90,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { @property({ converter: dateConverter }) negative?: Date[]; /** The list of dates that should have an indicator. */ - // @property({ converter: dateConverter }) indicator?: Date[]; - // @property({ converter: dateConverter }) indicator?: Indicator[]; - @property({ attribute: 'indicator', converter: { @@ -114,7 +111,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { : undefined } }) - indicator?: Indicator[]; + indicator?: Indicator[]; // TODO: maybe sth like dateConverter is needed here? /** @internal Emits when the user selects a day. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; @@ -139,7 +136,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { entries.forEach(entry => { if (entry.isIntersecting && entry.intersectionRatio === 1) { this.month = normalizeDateTime((entry.target as MonthView).month!); - console.log(this.month); this.#scrollToMonth(0); } }); @@ -175,7 +171,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } if (changes.has('month') && this.month) { - console.log('willUpdate', { month: this.month }); this.displayMonth = this.month; this.nextMonth = new Date(this.month.getFullYear(), this.month.getMonth() + 1); this.previousMonth = new Date(this.month.getFullYear(), this.month.getMonth() - 1); @@ -183,8 +178,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } override render(): TemplateResult { - console.log('in render readonly', this.readonly, this.month, this.selected); - const canSelectNextYear = this.displayMonth ? !this.max || (this.max && this.displayMonth.getFullYear() + 1 <= this.max.getFullYear()) : false, @@ -218,7 +211,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { ` : html` - Date: Tue, 7 Oct 2025 15:23:55 +0200 Subject: [PATCH 035/126] select month and select year unit tests --- .../calendar/src/select-month.spec.ts | 114 ++++++++++++++++++ .../calendar/src/select-year.spec.ts | 103 ++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/packages/components/calendar/src/select-month.spec.ts b/packages/components/calendar/src/select-month.spec.ts index 757f9065b4..b4c5918fba 100644 --- a/packages/components/calendar/src/select-month.spec.ts +++ b/packages/components/calendar/src/select-month.spec.ts @@ -1,4 +1,5 @@ import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { userEvent } from '@vitest/browser/context'; import { html } from 'lit'; import { beforeEach, describe, expect, it } from 'vitest'; import { SelectMonth } from './select-month.js'; @@ -66,6 +67,23 @@ describe('sl-select-month', () => { const next = el.renderRoot.querySelector('sl-button[aria-label^="Next year"]'); expect(next).to.exist.and.match(':disabled'); }); + + it('should have static year (span) when navigation is fully disabled by min and max', async () => { + el = await fixture(html` + + `); + await el.updateComplete; + + const yearButton = el.renderRoot.querySelector('sl-button.current-year'); + const yearSpan = el.renderRoot.querySelector('span.current-year'); + + expect(yearButton).to.not.exist; + expect(yearSpan).to.exist; + }); }); describe('selection', () => { @@ -84,6 +102,22 @@ describe('sl-select-month', () => { expect((ev.detail as Date).getMonth()).to.equal(parseInt(firstButton!.textContent!.trim().slice(0, 2)) - 1 || 0); // simplistic, month names may vary but at least ensure Date returned }); + it('clicking a month button should update selected', async () => { + const clickable = Array.from(el.renderRoot.querySelectorAll('ol button')).find( + b => !(b as HTMLButtonElement).disabled + )!; + const onSelect = new Promise(resolve => + el.addEventListener('sl-select', (e: Event) => resolve(e as CustomEvent)) + ); + + (clickable as HTMLButtonElement).click(); + const ev = await onSelect; + + await el.updateComplete; + expect(el.selected).to.be.instanceOf(Date); + expect((el.selected as Date).getMonth()).to.equal((ev.detail as Date).getMonth()); + }); + it('should emit sl-select with current month when Escape is pressed', async () => { const currentMonth = el.month.getMonth(); const onSelect = new Promise(resolve => @@ -132,4 +166,84 @@ describe('sl-select-month', () => { expect(ev.detail).to.equal('year'); }); }); + + describe('parts', () => { + it('should add "today" and "selected" parts to the month button when month and selected equal the current month', async () => { + const now = new Date(); + el = await fixture( + html`` + ); + await el.updateComplete; + + el.selected = new Date(now.getFullYear(), now.getMonth(), 1); + await el.updateComplete; + + const button = Array.from(el.renderRoot.querySelectorAll('ol button')).find(button => + (button.getAttribute('part') || '').includes('selected') + ); + + expect(button).to.exist; + expect(button?.matches('[part~="today"]')).to.be.true; + expect(button?.matches('[part~="selected"]')).to.be.true; + }); + }); + + describe('keyboard navigation', () => { + let month: Date; + + beforeEach(async () => { + month = new Date(2025, 6, 1); + + el = await fixture(html``); + + await el.updateComplete; + }); + + it('ArrowLeft on first button should decrement year', async () => { + const buttons = el.renderRoot.querySelectorAll('ol button'); + const first = buttons[0] as HTMLButtonElement; + + first.focus(); + await userEvent.keyboard('{ArrowLeft}'); + + await el.updateComplete; + + expect(el.month.getFullYear()).to.equal(month.getFullYear() - 1); + }); + + it('ArrowRight on last button should increment year', async () => { + const buttons = el.renderRoot.querySelectorAll('ol button'); + const last = buttons[buttons.length - 1] as HTMLButtonElement; + + last.focus(); + await userEvent.keyboard('{ArrowRight}'); + + await el.updateComplete; + expect(el.month.getFullYear()).to.equal(month.getFullYear() + 1); + }); + + it('keydown ArrowUp on a top row button should decrement year', async () => { + const buttons = el.renderRoot.querySelectorAll('ol button'); + // pick index 1 (top row, middle) + const target = buttons[1] as HTMLButtonElement; + + target.focus(); + await userEvent.keyboard('{ArrowUp}'); + + await el.updateComplete; + expect(el.month.getFullYear()).to.equal(month.getFullYear() - 1); + }); + + it('keydown ArrowDown on a last row button should increment year', async () => { + const buttons = el.renderRoot.querySelectorAll('ol button'); + // For 12 months and 3 columns, lastRowStart is index 9, choose index 10 + const target = buttons[10] as HTMLButtonElement; + + target.focus(); + await userEvent.keyboard('{ArrowDown}'); + + await el.updateComplete; + expect(el.month.getFullYear()).to.equal(month.getFullYear() + 1); + }); + }); }); diff --git a/packages/components/calendar/src/select-year.spec.ts b/packages/components/calendar/src/select-year.spec.ts index 9ec1792df2..81a8560883 100644 --- a/packages/components/calendar/src/select-year.spec.ts +++ b/packages/components/calendar/src/select-year.spec.ts @@ -1,4 +1,5 @@ import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { userEvent } from '@vitest/browser/context'; import { html } from 'lit'; import { beforeEach, describe, expect, it } from 'vitest'; import '../register.js'; @@ -89,6 +90,48 @@ describe('sl-select-year', () => { const prevBtn = el.renderRoot.querySelector('sl-button:nth-of-type(1)'); expect(prevBtn).to.have.attribute('disabled'); }); + + it('should disable prev/next buttons when navigation is fully restricted by min/max', async () => { + const baseYear = 2025; + el = await fixture(html` + + `); + await el.updateComplete; + + const prevBtn = el.renderRoot.querySelector('sl-button:nth-of-type(1)'); + const nextBtn = el.renderRoot.querySelector('sl-button:nth-of-type(2)'); + + expect(prevBtn).to.have.attribute('disabled'); + expect(nextBtn).to.have.attribute('disabled'); + }); + + it('should set "today" and "selected" parts when applicable and "unselectable" when outside min/max', async () => { + el = await fixture(html``); + await el.updateComplete; + + el.selected = new Date(new Date().getFullYear(), 0, 1); + await el.updateComplete; + + const year = el.renderRoot.querySelector('[part~="today"]'); + + expect(year).to.exist; + expect(year?.matches('[part~="today"]')).to.be.true; + expect(year?.matches('[part~="selected"]')).to.be.true; + + // make current year unselectable by moving min/max beyond it + el.min = new Date(new Date().getFullYear() + 1, 0, 1); + el.max = new Date(new Date().getFullYear() + 2, 11, 31); + await el.updateComplete; + + const yearUnselectable = el.renderRoot.querySelector('[part~="today"]'); + + expect(yearUnselectable).to.exist; + expect(yearUnselectable?.matches('[part~="unselectable"]')).to.be.true; + }); }); describe('selection', () => { @@ -118,4 +161,64 @@ describe('sl-select-year', () => { expect((ev.detail as Date).getFullYear()).to.equal(currentYear); }); }); + + describe('keyboard navigation', () => { + beforeEach(async () => { + el = await fixture(html``); + + await el.updateComplete; + }); + + it('keydown ArrowLeft on first button should decrement year range by 12', async () => { + const initialFirst = el.years[0]; + const buttons = el.renderRoot.querySelectorAll('ol button'); + const first = buttons[0] as HTMLButtonElement; + + first.focus(); + + await userEvent.keyboard('{ArrowLeft}'); + await el.updateComplete; + + expect(el.years[0]).to.equal(initialFirst - 12); + }); + + it('keydown ArrowRight on last button should increment year range by 12', async () => { + const initialLast = el.years.at(-1)!; + const buttons = el.renderRoot.querySelectorAll('ol button'); + const last = buttons[buttons.length - 1] as HTMLButtonElement; + + last.focus(); + + await userEvent.keyboard('{ArrowRight}'); + await el.updateComplete; + + expect(el.years.at(-1)).to.equal(initialLast + 12); + }); + + it('keydown ArrowUp on a top-row button should decrement year range by 12', async () => { + const initialFirst = el.years[0]; + const buttons = el.renderRoot.querySelectorAll('ol button'); + const target = buttons[1] as HTMLButtonElement; // top row (index 1) + + target.focus(); + + await userEvent.keyboard('{ArrowUp}'); + await el.updateComplete; + + expect(el.years[0]).to.equal(initialFirst - 12); + }); + + it('keydown ArrowDown on a last-row button should increment year range by 12', async () => { + const initialFirst = el.years[0]; + const buttons = el.renderRoot.querySelectorAll('ol button'); + const target = buttons[10] as HTMLButtonElement; // pick an index in the last row + + target.focus(); + + await userEvent.keyboard('{ArrowDown}'); + await el.updateComplete; + + expect(el.years[0]).to.equal(initialFirst + 12); + }); + }); }); From fd8520998373b4f948703b9cff81851390d1014e Mon Sep 17 00:00:00 2001 From: anna-lach Date: Tue, 7 Oct 2025 16:25:50 +0200 Subject: [PATCH 036/126] month view first version of tests and small change in month view --- .../calendar/src/month-view.spec.ts | 281 ++++++++++++++++++ .../components/calendar/src/month-view.ts | 2 +- 2 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 packages/components/calendar/src/month-view.spec.ts diff --git a/packages/components/calendar/src/month-view.spec.ts b/packages/components/calendar/src/month-view.spec.ts new file mode 100644 index 0000000000..84569629c2 --- /dev/null +++ b/packages/components/calendar/src/month-view.spec.ts @@ -0,0 +1,281 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { beforeEach, describe, expect, it } from 'vitest'; +import '../register.js'; +import { MonthView } from './month-view.js'; + +// Ensure the element is defined for direct usage if not already via calendar/register +try { + customElements.define('sl-month-view', MonthView); +} catch { + /* already defined */ +} + +describe('sl-month-view', () => { + let el: MonthView; + + describe('basic rendering & header', () => { + beforeEach(async () => { + el = await fixture(html``); + await el.updateComplete; + }); + + it('renders a header with localized weekday short names', () => { + const headers = Array.from(el.renderRoot.querySelectorAll('thead th[part~="week-day"]')); + expect(headers.length).to.equal(7); + // short names should be short strings like 'Mon' or single-letter depending on locale + expect(headers[0].textContent?.trim().length).to.be.greaterThan(0); + }); + + it('renders week numbers column when showWeekNumbers is true', async () => { + el.showWeekNumbers = true; + await el.updateComplete; + + const firstTh = el.renderRoot.querySelector('thead th[part~="week-number"]'); + expect(firstTh).to.exist; + expect(firstTh?.textContent?.trim().length).to.be.greaterThan(0); + }); + }); + + describe('renderer & hide other months', () => { + it('uses custom renderer when provided', async () => { + const month = new Date(); + el = await fixture(html``); + el.renderer = day => html`
    ${day.date.getDate()}
    `; + await el.updateComplete; + + // There should be at least one custom renderer div in the DOM + const custom = el.renderRoot.querySelector('.custom'); + expect(custom).to.exist; + }); + + it('hides days from other months when hideDaysOtherMonths is true', async () => { + // pick a month where the calendar will include prev/next month days (most months) + const month = new Date(2025, 9, 1); // October 2025 + el = await fixture(html``); + await el.updateComplete; + + // find any td that does not contain a button (these are hidden days) + const tds = Array.from(el.renderRoot.querySelectorAll('tbody td')); + const emptyTds = tds.filter(td => td.querySelector('button') === null && td.textContent?.trim() === ''); + // Expect at least one empty td when hiding other-month days + expect(emptyTds.length).to.be.greaterThan(0); + }); + }); + + describe('parts generation & indicators/negative/disabled/selected', () => { + beforeEach(async () => { + const now = new Date(); + // use current month so we can exercise today/selected semantics + el = await fixture( + html`` + ); + await el.updateComplete; + }); + + // it('applies today and selected parts when appropriate', async () => { + // const today = new Date(); + // el.showToday = true; + // el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + // await el.updateComplete; + // + // // find a button with both today and selected in part attribute + // const btn = Array.from(el.renderRoot.querySelectorAll('button')).find(b => (b.getAttribute('part') || '').includes('selected')); + // expect(btn).to.exist; + // const part = btn!.getAttribute('part') || ''; + // expect(part).to.include('today'); + // expect(part).to.include('selected'); + // }); + + it('applies today and selected parts when appropriate', async () => { + const today = new Date(); + el.month = new Date(el.month!.getFullYear(), el.month!.getMonth(), 1); + el.showToday = true; + el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + await el.updateComplete; + + // el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + + // // Recreate the calendar so `day.today` gets computed (willUpdate recreates only on month/min/max) + // el.month = new Date(el.month!.getFullYear(), el.month!.getMonth(), 1); + // await el.updateComplete; + + console.log( + 'Today is:', + today.toDateString(), + Array.from(el.renderRoot.querySelectorAll('button')).filter(b => b.getAttribute('part') || '') + ); + + // find a button with both today and selected in part attribute + const btn = Array.from(el.renderRoot.querySelectorAll('button')).find(b => + (b.getAttribute('part') || '').includes('selected') + ); + expect(btn).to.exist; + const part = btn!.getAttribute('part') || ''; + + console.log('parts:', part); + expect(part).to.include('today'); // TODO: use matches... + expect(part).to.include('selected'); + }); + + it('applies "unselectable" for disabled and outside min/max days and "negative" and indicator classes', async () => { + const year = new Date().getFullYear(); + const month = 6; // July + el = await fixture(html``); + + // pick a date in that month to mark as disabled/negative/indicator + const target = new Date(year, month, 10); + el.disabled = [target]; + el.negative = [new Date(year, month, 11)]; + el.indicator = [{ date: new Date(year, month, 12), color: 'blue' }]; + await el.updateComplete; + + // disabled -> unselectable + const disabledBtn = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => b.textContent?.trim() === '10' + ); + expect(disabledBtn).to.exist; + expect(disabledBtn?.hasAttribute('disabled')).to.be.true; + expect((disabledBtn?.getAttribute('part') || '').split(' ')).to.include('unselectable'); + + // negative + const negBtn = Array.from(el.renderRoot.querySelectorAll('button')).find(b => b.textContent?.trim() === '11'); + expect(negBtn).to.exist; + expect((negBtn?.getAttribute('part') || '').split(' ')).to.include('negative'); + + // indicator with color + const indBtn = Array.from(el.renderRoot.querySelectorAll('button')).find(b => b.textContent?.trim() === '12'); + expect(indBtn).to.exist; + const parts = (indBtn?.getAttribute('part') || '').split(' '); + expect(parts).to.include('indicator'); // TODO: use matches ... + expect(parts).to.include('indicator-blue'); + }); + }); + + describe('click & keyboard interactions', () => { + beforeEach(async () => { + // choose a month with predictable days + el = await fixture(html``); + await el.updateComplete; + }); + + it('clicking an enabled day emits sl-select and updates selected', async () => { + const onSelect = new Promise(resolve => + el.addEventListener('sl-select', (e: Event) => resolve(e as CustomEvent)) + ); + + // find a clickable day (not disabled) + const button = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => !b.hasAttribute('disabled') + ) as HTMLButtonElement; + expect(button).to.exist; + + button.click(); + const ev = await onSelect; + expect(ev.detail).to.be.instanceOf(Date); + // selected property should equal the emitted date + await el.updateComplete; + expect(el.selected).to.be.instanceOf(Date); + expect((el.selected as Date).getDate()).to.equal((ev.detail as Date).getDate()); + }); + + it('Enter or Space on a day emits sl-select', async () => { + const onSelect = new Promise(resolve => + el.addEventListener('sl-select', (e: Event) => resolve(e as CustomEvent)) + ); + // pick a button + const button = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => !b.hasAttribute('disabled') + ) as HTMLButtonElement; + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + const ev = await onSelect; + expect(ev.detail).to.be.instanceOf(Date); + + // Space key + const onSelect2 = new Promise(resolve => + el.addEventListener('sl-select', (e: Event) => resolve(e as CustomEvent)) + ); + button.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + const ev2 = await onSelect2; + expect(ev2.detail).to.be.instanceOf(Date); + }); + + it('ArrowLeft on the 1st of the month emits change with previous month last day', async () => { + // find the button with date '1' that belongs to the current month + const btnOne = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => b.textContent?.trim() === '1' + ) as HTMLButtonElement; + expect(btnOne).to.exist; + + const onChange = new Promise(resolve => + el.addEventListener('sl-change', (e: Event) => resolve(e as CustomEvent)) + ); + btnOne.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + const ev = await onChange; + + // emitted date should be the last day of previous month + const emitted = ev.detail as Date; + const prevLast = new Date(emitted.getFullYear(), emitted.getMonth() + 1, 0).getDate(); + expect(emitted.getDate()).to.equal(prevLast); + }); + + it('ArrowRight on the last day of the month emits change with next month first day', async () => { + // find last day of month number + const month = el.month!; + const lastDayNum = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); + const lastBtn = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => b.textContent?.trim() === String(lastDayNum) + ) as HTMLButtonElement; + expect(lastBtn).to.exist; + + const onChange = new Promise(resolve => + el.addEventListener('sl-change', (e: Event) => resolve(e as CustomEvent)) + ); + lastBtn.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + const ev = await onChange; + + const emitted = ev.detail as Date; + expect(emitted.getDate()).to.equal(1); + // month should be next month + expect(emitted.getMonth()).to.equal(month.getMonth() + 1); + }); + + it('ArrowUp on a day near start of month emits change to previous month same weekday when it crosses month', async () => { + // pick day 7 which when subtracting 7 will cross to previous month + const btnSeven = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => b.textContent?.trim() === '7' + ) as HTMLButtonElement; + expect(btnSeven).to.exist; + + const onChange = new Promise(resolve => + el.addEventListener('sl-change', (e: Event) => resolve(e as CustomEvent)) + ); + btnSeven.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + const ev = await onChange; + + const emitted = ev.detail as Date; + // emitted month should not equal current month + expect(emitted.getMonth()).to.not.equal(el.month?.getMonth()); + }); + + it('ArrowDown on a day near end of month emits change to next month same weekday when it crosses month', async () => { + const month = el.month!; + const lastDayNum = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); + // pick a day in last week (lastDay - 3) so adding 7 crosses month + const targetNum = lastDayNum - 3; + const targetBtn = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => b.textContent?.trim() === String(targetNum) + ) as HTMLButtonElement; + expect(targetBtn).to.exist; + + const onChange = new Promise(resolve => + el.addEventListener('sl-change', (e: Event) => resolve(e as CustomEvent)) + ); + targetBtn.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + const ev = await onChange; + + const emitted = ev.detail as Date; + expect(emitted.getMonth()).to.not.equal(month.getMonth()); + }); + }); +}); diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index c974901cc0..a9544e6b02 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -190,7 +190,7 @@ export class MonthView extends LocaleMixin(LitElement) { ); } - if (changes.has('max') || changes.has('min') || changes.has('month')) { + if (changes.has('max') || changes.has('min') || changes.has('month') || changes.has('showToday')) { const { firstDayOfWeek, max, min, showToday } = this; this.calendar = createCalendar(this.month ?? new Date(), { firstDayOfWeek, max, min, showToday }); From 4aca9bc987e41778190849d2886f5b7fc9e2e19c Mon Sep 17 00:00:00 2001 From: anna-lach Date: Wed, 8 Oct 2025 09:59:46 +0200 Subject: [PATCH 037/126] month view unit tests changes --- .../calendar/src/month-view.spec.ts | 121 ++++++++++++------ 1 file changed, 80 insertions(+), 41 deletions(-) diff --git a/packages/components/calendar/src/month-view.spec.ts b/packages/components/calendar/src/month-view.spec.ts index 84569629c2..9e1d205ca8 100644 --- a/packages/components/calendar/src/month-view.spec.ts +++ b/packages/components/calendar/src/month-view.spec.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import '../register.js'; import { MonthView } from './month-view.js'; -// Ensure the element is defined for direct usage if not already via calendar/register try { customElements.define('sl-month-view', MonthView); } catch { @@ -14,17 +13,69 @@ try { describe('sl-month-view', () => { let el: MonthView; - describe('basic rendering & header', () => { + describe('defaults', () => { + const month = new Date(); + + beforeEach(async () => { + el = await fixture(html``); + + await el.updateComplete; + }); + + it('renders a header with weekday names', () => { + const weekdays = Array.from(el.renderRoot.querySelectorAll('thead th[part~="week-day"]')); + + expect(weekdays.length).to.equal(7); + expect(weekdays.map(th => th.textContent?.trim())).to.deep.equal([ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun' + ]); + }); + + it('applies day part to days buttons', async () => { + // const today = new Date(); + el.month = new Date(el.month!.getFullYear(), el.month!.getMonth(), 1); + // el.showToday = true; + // el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + await el.updateComplete; + + const buttons = Array.from(el.renderRoot.querySelectorAll('button')); + + console.log('buttons.....', buttons, 'el....', el); + + expect(buttons).to.exist; + expect(buttons.length).to.be.greaterThan(0); + expect(buttons[0]?.matches('[part~="day"]')).to.be.true; + // expect(button?.matches('[part~="selected"]')).to.be.true; + }); + }); + + describe('header', () => { beforeEach(async () => { el = await fixture(html``); + await el.updateComplete; }); it('renders a header with localized weekday short names', () => { - const headers = Array.from(el.renderRoot.querySelectorAll('thead th[part~="week-day"]')); - expect(headers.length).to.equal(7); - // short names should be short strings like 'Mon' or single-letter depending on locale - expect(headers[0].textContent?.trim().length).to.be.greaterThan(0); + // TODO: maybe different locale check? + const weekdays = Array.from(el.renderRoot.querySelectorAll('thead th[part~="week-day"]')); + + expect(weekdays.length).to.equal(7); + expect(weekdays.map(th => th.textContent?.trim())).to.deep.equal([ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun' + ]); }); it('renders week numbers column when showWeekNumbers is true', async () => { @@ -32,8 +83,9 @@ describe('sl-month-view', () => { await el.updateComplete; const firstTh = el.renderRoot.querySelector('thead th[part~="week-number"]'); + expect(firstTh).to.exist; - expect(firstTh?.textContent?.trim().length).to.be.greaterThan(0); + expect(firstTh).to.have.trimmed.text('wk.'); }); }); @@ -63,7 +115,7 @@ describe('sl-month-view', () => { }); }); - describe('parts generation & indicators/negative/disabled/selected', () => { + describe('parts', () => { beforeEach(async () => { const now = new Date(); // use current month so we can exercise today/selected semantics @@ -73,19 +125,21 @@ describe('sl-month-view', () => { await el.updateComplete; }); - // it('applies today and selected parts when appropriate', async () => { - // const today = new Date(); - // el.showToday = true; - // el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - // await el.updateComplete; - // - // // find a button with both today and selected in part attribute - // const btn = Array.from(el.renderRoot.querySelectorAll('button')).find(b => (b.getAttribute('part') || '').includes('selected')); - // expect(btn).to.exist; - // const part = btn!.getAttribute('part') || ''; - // expect(part).to.include('today'); - // expect(part).to.include('selected'); - // }); + it('applies today part when appropriate', async () => { + // const today = new Date(); + el.month = new Date(el.month!.getFullYear(), el.month!.getMonth(), 1); + el.showToday = true; + // el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + await el.updateComplete; + + const button = Array.from(el.renderRoot.querySelectorAll('button')).find(btn => + (btn.getAttribute('part') || '').includes('today') + ); + expect(button).to.exist; + + expect(button?.matches('[part~="today"]')).to.be.true; + // expect(button?.matches('[part~="selected"]')).to.be.true; + }); it('applies today and selected parts when appropriate', async () => { const today = new Date(); @@ -94,28 +148,13 @@ describe('sl-month-view', () => { el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); await el.updateComplete; - // el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - - // // Recreate the calendar so `day.today` gets computed (willUpdate recreates only on month/min/max) - // el.month = new Date(el.month!.getFullYear(), el.month!.getMonth(), 1); - // await el.updateComplete; - - console.log( - 'Today is:', - today.toDateString(), - Array.from(el.renderRoot.querySelectorAll('button')).filter(b => b.getAttribute('part') || '') - ); - - // find a button with both today and selected in part attribute - const btn = Array.from(el.renderRoot.querySelectorAll('button')).find(b => - (b.getAttribute('part') || '').includes('selected') + const button = Array.from(el.renderRoot.querySelectorAll('button')).find(btn => + (btn.getAttribute('part') || '').includes('selected') ); - expect(btn).to.exist; - const part = btn!.getAttribute('part') || ''; + expect(button).to.exist; - console.log('parts:', part); - expect(part).to.include('today'); // TODO: use matches... - expect(part).to.include('selected'); + expect(button?.matches('[part~="today"]')).to.be.true; + expect(button?.matches('[part~="selected"]')).to.be.true; }); it('applies "unselectable" for disabled and outside min/max days and "negative" and indicator classes', async () => { From fbc2c5da763c0aec168b4987cb1e3d5310260aeb Mon Sep 17 00:00:00 2001 From: anna-lach Date: Wed, 8 Oct 2025 12:41:17 +0200 Subject: [PATCH 038/126] examples improvements --- .../calendar/src/calendar.stories.ts | 21 ++++++++++--------- packages/components/calendar/src/calendar.ts | 1 - .../components/calendar/src/month-view.ts | 5 ----- .../components/calendar/src/select-day.ts | 12 ++++++++++- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index d76ed41921..e36bffbf4a 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -31,7 +31,7 @@ export default { readonly: false, showToday: false, showWeekNumbers: false, - month: new Date(2024, 8, 15) + month: new Date(2025, 8, 15) }, argTypes: { disabled: { @@ -87,7 +87,7 @@ export default { return value instanceof Date ? value : new Date(value); }; - const selectedDate: Date | undefined = parseDate(selected); + // const selectedDate: Date | undefined = parseDate(selected); const onSelectDate = (event: CustomEvent) => { console.log('Date selected:', event.detail.getFullYear(), event.detail.getMonth()); @@ -121,11 +121,6 @@ export default { : undefined )} > -

    - Selected date: - -

    -

    active element: ${window.document.activeElement ? window.document.activeElement.tagName : 'none'}

    `; } } satisfies Meta; @@ -248,7 +243,7 @@ export const All: Story = { > `; }; - const monthEndDate = new Date(2025, 8, 29); + const monthEndDate = new Date(); //new Date(2025, 8, 29); const monthEnd = { negative: [getOffsetDate(2, monthEndDate)], // indicator: [getOffsetDate(3, monthEndDate)], @@ -293,6 +288,12 @@ export const All: Story = { selected: getOffsetDate(0) }; return html` +

    Month End (${monthEndDate.toLocaleDateString()})

    Selected, negative and date with indicator are all in the next month, but have the same styling as they would @@ -302,9 +303,9 @@ export const All: Story = {

    Today

    Shows current month with 'today' highlighted, in combination with selected, indicator and negative

    Indicator

    - ${renderMonth(indicator)} ${renderMonth(indicatorToday)} +
    ${renderMonth(indicator)} ${renderMonth(indicatorToday)}

    Negative

    - ${renderMonth(negative)} ${renderMonth(negativeToday)} +
    ${renderMonth(negative)} ${renderMonth(negativeToday)}
    `; } }; diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 2ac42392bd..b0b038a899 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -155,7 +155,6 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { console.log('disabled dates', this.disabled, 'min and max', this.min, this.max, 'month', this.month); return html` - ${this.month ? html`month:${this.month.getMonth() + 1}` : 'undefined month'} ${this.renderHeader()}
    - - - ${this.calendar?.weeks.map( week => html` diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index b222e6a9ff..0d5c16c68d 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -191,6 +191,9 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { ? !this.min || (this.min && this.previousMonth?.getTime() >= new Date(this.min.getFullYear(), this.min.getMonth()).getTime()) : false; + + console.log('canSelectPreviousMonth in select day', canSelectPreviousMonth, this.previousMonth, this.min); + return html`
    ${canSelectPreviousMonth || canSelectNextMonth @@ -374,9 +377,16 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #scrollToMonth(month: -1 | 0 | 1, smooth = false): void { + console.log( + 'scroll to month', + month, + smooth, + 'left:', + parseInt(getComputedStyle(this).width) * month + parseInt(getComputedStyle(this).width) + ); const width = parseInt(getComputedStyle(this).width), left = width * month + width; - this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); + this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' }); } } From f7e6805e162df207f6fd796338c1b0f9d57eee10 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Wed, 8 Oct 2025 16:20:53 +0200 Subject: [PATCH 039/126] mont view tests, fixing selecting day with space or enter key --- .../calendar/src/month-view.spec.ts | 270 ++++++++++++------ .../components/calendar/src/month-view.ts | 1 + 2 files changed, 184 insertions(+), 87 deletions(-) diff --git a/packages/components/calendar/src/month-view.spec.ts b/packages/components/calendar/src/month-view.spec.ts index 9e1d205ca8..07a04c304e 100644 --- a/packages/components/calendar/src/month-view.spec.ts +++ b/packages/components/calendar/src/month-view.spec.ts @@ -1,5 +1,7 @@ import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { userEvent } from '@vitest/browser/context'; import { html } from 'lit'; +import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; import '../register.js'; import { MonthView } from './month-view.js'; @@ -38,20 +40,14 @@ describe('sl-month-view', () => { }); it('applies day part to days buttons', async () => { - // const today = new Date(); el.month = new Date(el.month!.getFullYear(), el.month!.getMonth(), 1); - // el.showToday = true; - // el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); await el.updateComplete; const buttons = Array.from(el.renderRoot.querySelectorAll('button')); - console.log('buttons.....', buttons, 'el....', el); - expect(buttons).to.exist; expect(buttons.length).to.be.greaterThan(0); - expect(buttons[0]?.matches('[part~="day"]')).to.be.true; - // expect(button?.matches('[part~="selected"]')).to.be.true; + buttons.forEach(button => expect(button.matches('[part~="day"]')).to.be.true); }); }); @@ -62,19 +58,21 @@ describe('sl-month-view', () => { await el.updateComplete; }); - it('renders a header with localized weekday short names', () => { - // TODO: maybe different locale check? + it('renders a header with localized weekday short names', async () => { + el.locale = 'it-IT'; + await el.updateComplete; + const weekdays = Array.from(el.renderRoot.querySelectorAll('thead th[part~="week-day"]')); expect(weekdays.length).to.equal(7); expect(weekdays.map(th => th.textContent?.trim())).to.deep.equal([ - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', - 'Sun' + 'lun', + 'mar', + 'mer', + 'gio', + 'ven', + 'sab', + 'dom' ]); }); @@ -87,30 +85,48 @@ describe('sl-month-view', () => { expect(firstTh).to.exist; expect(firstTh).to.have.trimmed.text('wk.'); }); + + it('renders week numbers column with localized header when showWeekNumbers is true', async () => { + el.showWeekNumbers = true; + el.locale = 'it-IT'; + await el.updateComplete; + + const firstTh = el.renderRoot.querySelector('thead th[part~="week-number"]'); + + expect(firstTh).to.exist; + expect(firstTh).to.have.trimmed.text('sett.'); + }); }); - describe('renderer & hide other months', () => { - it('uses custom renderer when provided', async () => { - const month = new Date(); - el = await fixture(html``); + describe('custom renderer', () => { + beforeEach(async () => { + el = await fixture(html``); + + await el.updateComplete; + }); + + it('should use custom renderer when provided', async () => { el.renderer = day => html`
    ${day.date.getDate()}
    `; await el.updateComplete; - // There should be at least one custom renderer div in the DOM const custom = el.renderRoot.querySelector('.custom'); + expect(custom).to.exist; }); + }); - it('hides days from other months when hideDaysOtherMonths is true', async () => { - // pick a month where the calendar will include prev/next month days (most months) - const month = new Date(2025, 9, 1); // October 2025 + describe('other months hidden', () => { + beforeEach(async () => { + const month = new Date(2025, 9, 1); el = await fixture(html``); + await el.updateComplete; + }); - // find any td that does not contain a button (these are hidden days) + it('should hide days from other months when hideDaysOtherMonths is true', () => { const tds = Array.from(el.renderRoot.querySelectorAll('tbody td')); const emptyTds = tds.filter(td => td.querySelector('button') === null && td.textContent?.trim() === ''); - // Expect at least one empty td when hiding other-month days + expect(emptyTds.length).to.be.greaterThan(0); }); }); @@ -118,127 +134,207 @@ describe('sl-month-view', () => { describe('parts', () => { beforeEach(async () => { const now = new Date(); - // use current month so we can exercise today/selected semantics + el = await fixture( html`` ); await el.updateComplete; }); - it('applies today part when appropriate', async () => { - // const today = new Date(); + it('should apply today part when showToday is set', async () => { el.month = new Date(el.month!.getFullYear(), el.month!.getMonth(), 1); el.showToday = true; - // el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); await el.updateComplete; const button = Array.from(el.renderRoot.querySelectorAll('button')).find(btn => (btn.getAttribute('part') || '').includes('today') ); - expect(button).to.exist; + expect(button).to.exist; expect(button?.matches('[part~="today"]')).to.be.true; - // expect(button?.matches('[part~="selected"]')).to.be.true; }); - it('applies today and selected parts when appropriate', async () => { - const today = new Date(); + it('should apply today and selected parts when appropriate', async () => { el.month = new Date(el.month!.getFullYear(), el.month!.getMonth(), 1); el.showToday = true; - el.selected = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + el.selected = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate()); await el.updateComplete; const button = Array.from(el.renderRoot.querySelectorAll('button')).find(btn => (btn.getAttribute('part') || '').includes('selected') ); - expect(button).to.exist; + expect(button).to.exist; expect(button?.matches('[part~="today"]')).to.be.true; expect(button?.matches('[part~="selected"]')).to.be.true; }); - it('applies "unselectable" for disabled and outside min/max days and "negative" and indicator classes', async () => { - const year = new Date().getFullYear(); - const month = 6; // July - el = await fixture(html``); + it('should apply "unselectable" for disabled days', async () => { + el.disabled = [new Date(new Date().getFullYear(), new Date().getMonth(), 10)]; + await el.updateComplete; + + const disabledDay = Array.from(el.renderRoot.querySelectorAll('button')).find( + day => day.textContent?.trim() === '10' + ); + + expect(disabledDay).to.exist; + expect(disabledDay?.hasAttribute('disabled')).to.be.true; + expect(disabledDay?.matches('[part~="unselectable"]')).to.be.true; + }); + + it('should apply "unselectable" for outside min/max days', async () => { + el.min = new Date(new Date().getFullYear(), new Date().getMonth(), 15); + el.max = new Date(new Date().getFullYear(), new Date().getMonth(), 20); + await el.updateComplete; + + const disabledBtnPrevious = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => b.textContent?.trim() === '14' + ), + disabledBtnNext = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => b.textContent?.trim() === '21' + ); + + expect(disabledBtnPrevious).to.exist; + expect(disabledBtnNext).to.exist; + expect(disabledBtnPrevious?.hasAttribute('disabled')).to.be.true; + expect(disabledBtnNext?.hasAttribute('disabled')).to.be.true; + expect(disabledBtnPrevious?.matches('[part~="unselectable"]')).to.be.true; + expect(disabledBtnNext?.matches('[part~="unselectable"]')).to.be.true; + }); + + it('should apply "negative" part', async () => { + el.negative = [new Date(new Date().getFullYear(), new Date().getMonth(), 11)]; + await el.updateComplete; + + const negativeBtn = Array.from(el.renderRoot.querySelectorAll('button')).find( + button => button.textContent?.trim() === '11' + ); + + expect(negativeBtn).to.exist; + expect(negativeBtn?.matches('[part~="negative"]')).to.be.true; + }); - // pick a date in that month to mark as disabled/negative/indicator - const target = new Date(year, month, 10); - el.disabled = [target]; - el.negative = [new Date(year, month, 11)]; - el.indicator = [{ date: new Date(year, month, 12), color: 'blue' }]; + it('should apply indicator part', async () => { + el.indicator = [{ date: new Date(new Date().getFullYear(), new Date().getMonth(), 12) }]; await el.updateComplete; - // disabled -> unselectable - const disabledBtn = Array.from(el.renderRoot.querySelectorAll('button')).find( - b => b.textContent?.trim() === '10' + const indBtn = Array.from(el.renderRoot.querySelectorAll('button')).find( + button => button.textContent?.trim() === '12' ); - expect(disabledBtn).to.exist; - expect(disabledBtn?.hasAttribute('disabled')).to.be.true; - expect((disabledBtn?.getAttribute('part') || '').split(' ')).to.include('unselectable'); - // negative - const negBtn = Array.from(el.renderRoot.querySelectorAll('button')).find(b => b.textContent?.trim() === '11'); - expect(negBtn).to.exist; - expect((negBtn?.getAttribute('part') || '').split(' ')).to.include('negative'); + expect(indBtn).to.exist; + expect(indBtn?.matches('[part~="indicator"]')).to.be.true; + }); + + it('should apply red indicator when set', async () => { + el.indicator = [{ date: new Date(new Date().getFullYear(), new Date().getMonth(), 12), color: 'red' }]; + await el.updateComplete; + + const indBtn = Array.from(el.renderRoot.querySelectorAll('button')).find( + button => button.textContent?.trim() === '12' + ); - // indicator with color - const indBtn = Array.from(el.renderRoot.querySelectorAll('button')).find(b => b.textContent?.trim() === '12'); expect(indBtn).to.exist; - const parts = (indBtn?.getAttribute('part') || '').split(' '); - expect(parts).to.include('indicator'); // TODO: use matches ... - expect(parts).to.include('indicator-blue'); + expect(indBtn?.matches('[part~="indicator"]')).to.be.true; + expect(indBtn?.matches('[part~="indicator-red"]')).to.be.true; }); }); - describe('click & keyboard interactions', () => { + describe('click event', () => { beforeEach(async () => { - // choose a month with predictable days - el = await fixture(html``); + el = await fixture(html` + + `); await el.updateComplete; }); - it('clicking an enabled day emits sl-select and updates selected', async () => { - const onSelect = new Promise(resolve => - el.addEventListener('sl-select', (e: Event) => resolve(e as CustomEvent)) - ); + it('should emit sl-select and update selected when clicking on an enabled day', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', onSelect); - // find a clickable day (not disabled) const button = Array.from(el.renderRoot.querySelectorAll('button')).find( b => !b.hasAttribute('disabled') ) as HTMLButtonElement; expect(button).to.exist; button.click(); - const ev = await onSelect; - expect(ev.detail).to.be.instanceOf(Date); - // selected property should equal the emitted date await el.updateComplete; + + expect(onSelect).to.have.been.calledOnce; + + const event = onSelect.lastCall.firstArg as CustomEvent; expect(el.selected).to.be.instanceOf(Date); - expect((el.selected as Date).getDate()).to.equal((ev.detail as Date).getDate()); + expect((el.selected as Date).getDate()).to.equal(event.detail.getDate()); }); - it('Enter or Space on a day emits sl-select', async () => { - const onSelect = new Promise(resolve => - el.addEventListener('sl-select', (e: Event) => resolve(e as CustomEvent)) - ); - // pick a button + it('should not emit sl-select and update selected day when clicking on a disabled day', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', onSelect); + + const button = Array.from(el.renderRoot.querySelectorAll('button')).find(b => + b.hasAttribute('disabled') + ) as HTMLButtonElement; + + expect(button).to.exist; + + button.click(); + await el.updateComplete; + + expect(onSelect).not.to.have.been.calledOnce; + }); + }); + + describe('keyboard navigation', () => { + beforeEach(async () => { + el = await fixture(html``); + await el.updateComplete; + }); + + it('Enter on a day emits sl-select', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', onSelect); + const button = Array.from(el.renderRoot.querySelectorAll('button')).find( b => !b.hasAttribute('disabled') ) as HTMLButtonElement; - button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - const ev = await onSelect; - expect(ev.detail).to.be.instanceOf(Date); - // Space key - const onSelect2 = new Promise(resolve => - el.addEventListener('sl-select', (e: Event) => resolve(e as CustomEvent)) - ); - button.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); - const ev2 = await onSelect2; - expect(ev2.detail).to.be.instanceOf(Date); + button.focus(); + await userEvent.keyboard('{Enter}'); + await el.updateComplete; + + expect(onSelect).to.have.been.calledOnce; + + const event = onSelect.lastCall.firstArg as CustomEvent; + + expect(el.selected).to.be.instanceOf(Date); + expect(event.detail.getDate()).to.equal(29); + }); + + it('Space on a day emits sl-select', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', onSelect); + + const button = Array.from(el.renderRoot.querySelectorAll('button')).find( + b => !b.hasAttribute('disabled') + ) as HTMLButtonElement; + + button.focus(); + await userEvent.keyboard('{Space}'); + await el.updateComplete; + + expect(onSelect).to.have.been.calledOnce; + + const event = onSelect.lastCall.firstArg as CustomEvent; + + expect(el.selected).to.be.instanceOf(Date); + expect(event.detail.getDate()).to.equal(29); }); + // TODO: change those tests below and improve descriptions everywhere, should begin with 'should...' it('ArrowLeft on the 1st of the month emits change with previous month last day', async () => { // find the button with date '1' that belongs to the current month const btnOne = Array.from(el.renderRoot.querySelectorAll('button')).find( diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index fd0eff1db1..f1737f8cc9 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -384,6 +384,7 @@ export class MonthView extends LocaleMixin(LitElement) { event.stopPropagation(); this.selectEvent.emit(day.date); + this.selected = day.date; } } From da6ebf6d4d5b41a4266af385e9de288c6eefe5b1 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Thu, 9 Oct 2025 12:29:37 +0200 Subject: [PATCH 040/126] fixes styling --- .../components/calendar/src/month-view.scss | 2 +- .../components/calendar/src/select-day.scss | 35 ++-- .../components/calendar/src/select-day.ts | 160 +++++++++--------- .../components/calendar/src/select-month.scss | 10 +- .../components/calendar/src/select-month.ts | 38 +++-- .../components/calendar/src/select-year.scss | 10 +- .../components/calendar/src/select-year.ts | 38 +++-- 7 files changed, 161 insertions(+), 132 deletions(-) diff --git a/packages/components/calendar/src/month-view.scss b/packages/components/calendar/src/month-view.scss index 257b68830e..1e41b1a56d 100644 --- a/packages/components/calendar/src/month-view.scss +++ b/packages/components/calendar/src/month-view.scss @@ -154,7 +154,7 @@ button { [part~='week-day'], [part~='week-number'] { - color: var(--sl-color-foreground-subtlest); + color: var(--sl-color-foreground-disabled); font-weight: normal; } diff --git a/packages/components/calendar/src/select-day.scss b/packages/components/calendar/src/select-day.scss index b27f9636be..bb16f0a578 100644 --- a/packages/components/calendar/src/select-day.scss +++ b/packages/components/calendar/src/select-day.scss @@ -5,7 +5,7 @@ :host([show-week-numbers]) { .days-of-week { - grid-template-columns: repeat(8, var(--sl-size-450)); + grid-template-columns: var(--sl-size-600) repeat(7, var(--sl-size-450)); // repeat(8, var(--sl-size-450)); } .scroller { @@ -18,12 +18,13 @@ display: flex; justify-content: space-between; padding-block: var(--sl-size-075); - padding-inline: var(--sl-size-150) var(--sl-size-075); + + // padding-inline: var(--sl-size-150) var(--sl-size-050); } .current-month, .current-year { - font-size: 1.2em; + // font-size: 1.2em; font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); sl-icon { @@ -35,20 +36,26 @@ margin-inline-start: var(--sl-size-200); } -.previous-month { - margin-inline-start: auto; +.arrows { + display: inline-flex; + gap: var(--sl-size-100); } -.next-month { - margin-inline-start: var(--sl-size-100) auto; -} +// .previous-month { +// margin-inline-start: auto; +// } + +// .next-month { +// margin-inline-start: var(--sl-size-100) auto; +// } .days-of-week { - color: var(--sl-color-foreground-subtlest); + color: var(--sl-color-foreground-disabled); display: grid; font-weight: normal; grid-template-columns: repeat(7, var(--sl-size-450)); - padding-inline: var(--sl-size-075); + + // padding-inline: var(--sl-size-100); } .day-of-week, @@ -57,8 +64,9 @@ } .week-number { - border-inline-end: var(--sl-size-borderWidth-subtle) solid var(--sl-color-neutral-muted); - inline-size: calc(var(--sl-size-450) - var(--sl-size-borderWidth-subtle)); + border-inline-end: var(--sl-size-borderWidth-subtle) solid var(--sl-color-border-neutral-plain); + + // inline-size: calc(var(--sl-size-450) - var(--sl-size-borderWidth-subtle)); } .scroller { @@ -68,7 +76,8 @@ max-inline-size: calc(7 * var(--sl-size-450) + var(--sl-size-100)); outline: none; overflow: scroll hidden; - padding-block: var(--sl-size-050); + + // padding-block: var(--sl-size-050); scroll-snap-type: x mandatory; scrollbar-width: none; } diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 0d5c16c68d..3ae9002101 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -196,84 +196,88 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { return html`
    - ${canSelectPreviousMonth || canSelectNextMonth - ? html` - - - - - ` - : html` - - `} - ${canSelectPreviousYear || canSelectNextYear - ? html` - - - - - ` - : html` - - `} - - - - - - +
    + ${canSelectPreviousMonth || canSelectNextMonth + ? html` + + + + + ` + : html` + + `} + ${canSelectPreviousYear || canSelectNextYear + ? html` + + + + + ` + : html` + + `} +
    +
    + + + + + + +
    ${this.showWeekNumbers diff --git a/packages/components/calendar/src/select-month.scss b/packages/components/calendar/src/select-month.scss index 610d746be6..9a18fb6f05 100644 --- a/packages/components/calendar/src/select-month.scss +++ b/packages/components/calendar/src/select-month.scss @@ -3,20 +3,26 @@ display: inline-flex; flex-direction: column; gap: var(--sl-size-100); - padding: var(--sl-size-075); + padding: var(--sl-size-025); } [part='header'] { align-items: center; display: flex; + padding-inline: var(--sl-size-150) var(--sl-size-050); } .current-year { - font-size: 1.2em; + // font-size: 1.2em; font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); margin-inline-end: auto; } +.arrows { + display: inline-flex; + gap: var(--sl-size-100); +} + sl-button { user-select: none; } diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 733d053a8d..642b8263a8 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -149,24 +149,26 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { ` : html`${currentYear}`} - - - - - - +
    + + + + + + +
      ${this.months.map(month => { diff --git a/packages/components/calendar/src/select-year.scss b/packages/components/calendar/src/select-year.scss index f49b0c0371..dc025ba905 100644 --- a/packages/components/calendar/src/select-year.scss +++ b/packages/components/calendar/src/select-year.scss @@ -3,20 +3,26 @@ display: inline-flex; flex-direction: column; gap: var(--sl-size-100); - padding: var(--sl-size-075); + padding: var(--sl-size-025); } [part='header'] { align-items: center; display: flex; + padding-inline: var(--sl-size-150) var(--sl-size-050); } .current-range { - font-size: 1.2em; + // font-size: 1.2em; font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); margin-inline-end: auto; } +.arrows { + display: inline-flex; + gap: var(--sl-size-100); +} + sl-button { user-select: none; } diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index bad18b2cf9..7f409b1d2b 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -115,24 +115,26 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { return html`
      ${this.years.at(0)} - ${this.years.at(-1)} - - - - - - +
      + + + + + + +
        ${this.years.map(year => { From 9af50ab4ac80aff02bc6d7c2a7f47a73ceb6dcca Mon Sep 17 00:00:00 2001 From: anna-lach Date: Thu, 9 Oct 2025 15:53:25 +0200 Subject: [PATCH 041/126] some styling fixes, trying to solve the issue with selecting next/prev month when the week numbers are visible and not break basic behaviour --- .../components/calendar/src/select-day.scss | 6 +- .../components/calendar/src/select-day.ts | 86 +++++++++++++++---- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/packages/components/calendar/src/select-day.scss b/packages/components/calendar/src/select-day.scss index bb16f0a578..c8fbca000e 100644 --- a/packages/components/calendar/src/select-day.scss +++ b/packages/components/calendar/src/select-day.scss @@ -27,9 +27,9 @@ // font-size: 1.2em; font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); - sl-icon { - color: var(--sl-color-foreground-subtlest); - } + // sl-icon { + // color: var(--sl-color-foreground-subtlest); + // } } .current-year { diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 3ae9002101..240ab3554a 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -80,8 +80,8 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal The scroller element. */ @query('.scroller') scroller?: HTMLElement; - /** @internal The scroller element. */ - @query('.scroll-wrapper') scrollWrapper?: HTMLElement; + // /** @internal The scroller element. */ + // @query('.scroll-wrapper') scrollWrapper?: HTMLElement; /** The selected date. */ @property({ converter: dateConverter }) selected?: Date; @@ -131,25 +131,70 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { // eslint-disable-next-line lit/no-native-attributes @property({ type: Boolean }) override inert = false; - observer = new IntersectionObserver( - entries => { - entries.forEach(entry => { - if (entry.isIntersecting && entry.intersectionRatio === 1) { - this.month = normalizeDateTime((entry.target as MonthView).month!); - this.#scrollToMonth(0); - } - }); - }, - { root: this.scrollWrapper, threshold: [0, 0.25, 0.5, 0.75, 1] } - ); + observer?: IntersectionObserver; + + // observer = new IntersectionObserver( + // entries => { + // entries.forEach(entry => { + // console.log( + // 'entry in intersection observer', + // entry, + // 'entry.isIntersecting && entry.intersectionRatio', + // entry.isIntersecting, + // entry.intersectionRatio, + // 'month...', + // normalizeDateTime((entry.target as MonthView).month!), + // 'root for intersection observer', + // this.scroller + // ); + // if (entry.isIntersecting && entry.intersectionRatio >= 0.75 /*=== 1*/) { + // this.month = normalizeDateTime((entry.target as MonthView).month!); + // console.log('month in intersection observer', this.month); + // this.#scrollToMonth(0); + // } + // }); + // }, + // { root: this.scroller, threshold: [0, 0.25, 0.5, 0.75, 1] } // TODO: check maybe rootMargin 20px or sth? + // ); + + override disconnectedCallback(): void { + this.observer?.disconnect(); + + super.disconnectedCallback(); + } override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); + // Create the observer after the scroller exists so `root` is the scroller element + this.observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + console.log( + 'entry in intersection observer', + entry, + 'entry.isIntersecting && entry.intersectionRatio', + entry.isIntersecting, + entry.intersectionRatio, + 'month...', + normalizeDateTime((entry.target as MonthView).month!), + 'root for intersection observer', + this.scroller + ); + if (entry.isIntersecting && entry.intersectionRatio >= 0.7 /*=== 1*/) { + this.month = normalizeDateTime((entry.target as MonthView).month!); + console.log('month in intersection observer', this.month); + this.#scrollToMonth(0); + } + }); + }, + { root: this.scroller, threshold: [0, 0.25, 0.5, 0.75, 1] } // TODO: check maybe rootMargin 20px or sth? + ); + requestAnimationFrame(() => { this.#scrollToMonth(0); const monthViews = this.renderRoot.querySelectorAll('sl-month-view'); - monthViews.forEach(mv => this.observer.observe(mv)); + monthViews.forEach(mv => this.observer?.observe(mv)); }); } @@ -362,6 +407,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #onNext(): void { + // TODO: why the header is not updated when clicking next and week days are visible? this.#scrollToMonth(1, true); } @@ -388,9 +434,15 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { 'left:', parseInt(getComputedStyle(this).width) * month + parseInt(getComputedStyle(this).width) ); - const width = parseInt(getComputedStyle(this).width), - left = width * month + width; + // // Prefer scroller width (viewport of observed root). Fall back to host computed width + // const width = this.scroller?.clientWidth ?? parseInt(getComputedStyle(this).width), //parseInt(getComputedStyle(this).width), + // left = width * month + width; + + // Prefer scroller width (viewport of observed root). Fall back to host computed width. + const hostWidth = parseInt(getComputedStyle(this).width) || 0; + const width = this.scroller?.clientWidth ?? hostWidth; + const left = width * month + width; - this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' }); + this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); } } From 58a31863b6da60b0d0033110d8485028db8bab8a Mon Sep 17 00:00:00 2001 From: anna-lach Date: Fri, 10 Oct 2025 14:56:45 +0200 Subject: [PATCH 042/126] styling improvements, solving the issue with selecting next/prev --- .../components/calendar/src/select-day.scss | 10 +- .../components/calendar/src/select-day.ts | 94 +++++++++---------- 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/packages/components/calendar/src/select-day.scss b/packages/components/calendar/src/select-day.scss index c8fbca000e..c485b53ef4 100644 --- a/packages/components/calendar/src/select-day.scss +++ b/packages/components/calendar/src/select-day.scss @@ -6,10 +6,13 @@ :host([show-week-numbers]) { .days-of-week { grid-template-columns: var(--sl-size-600) repeat(7, var(--sl-size-450)); // repeat(8, var(--sl-size-450)); + padding: var(--sl-size-050) var(--sl-size-075) 0 0; } .scroller { - max-inline-size: calc(8 * var(--sl-size-450) + var(--sl-size-100)); + max-inline-size: calc( + var(--sl-size-600) + var(--sl-size-borderWidth-subtle) + 7 * var(--sl-size-450) + var(--sl-size-100) + ); // calc(8 * var(--sl-size-450) + var(--sl-size-100)); } } @@ -18,13 +21,11 @@ display: flex; justify-content: space-between; padding-block: var(--sl-size-075); - - // padding-inline: var(--sl-size-150) var(--sl-size-050); + padding-inline: var(--sl-size-150) var(--sl-size-050); } .current-month, .current-year { - // font-size: 1.2em; font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); // sl-icon { @@ -54,6 +55,7 @@ display: grid; font-weight: normal; grid-template-columns: repeat(7, var(--sl-size-450)); + padding: var(--sl-size-050) var(--sl-size-075); // padding-inline: var(--sl-size-100); } diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 240ab3554a..d31df475f7 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -68,6 +68,9 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The month that is shown. */ @property({ converter: dateConverter }) month?: Date; + /** @internal The month-view element. */ + @query('sl-month-view') monthView?: HTMLElement; + /** @internal The next month in the calendar. */ @state() nextMonth?: Date; @@ -133,30 +136,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { observer?: IntersectionObserver; - // observer = new IntersectionObserver( - // entries => { - // entries.forEach(entry => { - // console.log( - // 'entry in intersection observer', - // entry, - // 'entry.isIntersecting && entry.intersectionRatio', - // entry.isIntersecting, - // entry.intersectionRatio, - // 'month...', - // normalizeDateTime((entry.target as MonthView).month!), - // 'root for intersection observer', - // this.scroller - // ); - // if (entry.isIntersecting && entry.intersectionRatio >= 0.75 /*=== 1*/) { - // this.month = normalizeDateTime((entry.target as MonthView).month!); - // console.log('month in intersection observer', this.month); - // this.#scrollToMonth(0); - // } - // }); - // }, - // { root: this.scroller, threshold: [0, 0.25, 0.5, 0.75, 1] } // TODO: check maybe rootMargin 20px or sth? - // ); - override disconnectedCallback(): void { this.observer?.disconnect(); @@ -166,35 +145,48 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); - // Create the observer after the scroller exists so `root` is the scroller element - this.observer = new IntersectionObserver( - entries => { - entries.forEach(entry => { - console.log( - 'entry in intersection observer', - entry, - 'entry.isIntersecting && entry.intersectionRatio', - entry.isIntersecting, - entry.intersectionRatio, - 'month...', - normalizeDateTime((entry.target as MonthView).month!), - 'root for intersection observer', - this.scroller - ); - if (entry.isIntersecting && entry.intersectionRatio >= 0.7 /*=== 1*/) { - this.month = normalizeDateTime((entry.target as MonthView).month!); - console.log('month in intersection observer', this.month); - this.#scrollToMonth(0); - } - }); - }, - { root: this.scroller, threshold: [0, 0.25, 0.5, 0.75, 1] } // TODO: check maybe rootMargin 20px or sth? - ); - requestAnimationFrame(() => { + let totalHorizontal = 0; + + if (this.monthView) { + const cs = getComputedStyle(this.monthView); + const left = parseFloat(cs.paddingLeft) || 0; + const right = parseFloat(cs.paddingRight) || 0; + totalHorizontal = Math.round(left + right); + } + + // Create the observer after the scroller exists so `root` is the scroller element + this.observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + // console.log( + // 'entry in intersection observer', + // entry, + // 'entry.isIntersecting && entry.intersectionRatio', + // entry.isIntersecting, + // 'ratio:', + // entry.intersectionRatio, + // 'month...', + // normalizeDateTime((entry.target as MonthView).month!), + // 'root for intersection observer', + // this.scroller, + // 'monthView....?', + // this.monthView + // ); + if (entry.isIntersecting && entry.intersectionRatio === 1) { + this.month = normalizeDateTime((entry.target as MonthView).month!); + console.log('month in intersection observer', this.month); + this.#scrollToMonth(0); + } + }); + }, + { root: this.scroller, rootMargin: `${totalHorizontal}px`, threshold: [0, 0.25, 0.5, 0.75, /*0.99,*/ 1] } // TODO: check maybe rootMargin 20px or sth? + ); + this.#scrollToMonth(0); const monthViews = this.renderRoot.querySelectorAll('sl-month-view'); monthViews.forEach(mv => this.observer?.observe(mv)); + console.log('monthViews observed', monthViews); }); } @@ -440,9 +432,11 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { // Prefer scroller width (viewport of observed root). Fall back to host computed width. const hostWidth = parseInt(getComputedStyle(this).width) || 0; - const width = this.scroller?.clientWidth ?? hostWidth; + const width = /*this.scroller?.clientWidth ??*/ hostWidth; const left = width * month + width; + console.log('scroll to month - left', left, 'width used', width, 'hostWidth', hostWidth); + this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); } } From a4db8f0e039c980620919ed31805ad3e2883db36 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Fri, 10 Oct 2025 15:57:07 +0200 Subject: [PATCH 043/126] trying to find the solution for keyboard navigation when going back from month view to select day (header navigation) --- packages/components/calendar/src/calendar.ts | 3 ++ .../components/calendar/src/month-view.ts | 36 +++++++++++++++---- .../components/calendar/src/select-day.ts | 2 +- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index b0b038a899..5294d695f0 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -154,6 +154,9 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { console.log('disabled dates', this.disabled, 'min and max', this.min, this.max, 'month', this.month); + console.log('find all focusable elements in calendar...', this.renderRoot.querySelectorAll('[tabindex="0"]')); + // TODO: in each month view there is one focusable (tabindex 0) element, probably only in the currently visible one there should be a tabindex 0? + return html` { let index = elements.findIndex(el => el.part.contains('selected') && !el.disabled); + console.log('looking for selected index...', index); if (index > -1) { + console.log('in focusInIndex: focusing selected?', index); return index; } index = elements.findIndex(el => el.part.contains('today') && !el.disabled); + console.log('looking for today index...', index); if (index > -1) { + console.log('in focusInIndex: focusing today?', index); return index; } + console.log( + 'in focusInIndex: no selected or today, focusing first enabled element', + elements.findIndex(el => !el.disabled) + ); + return elements.findIndex(el => !el.disabled); }, elements: (): HTMLButtonElement[] => { - // console.log( - // 'elements', - // Array.from(this.renderRoot.querySelectorAll('button')), - // this.renderRoot.querySelectorAll('button') - // ); + console.log( + 'elements in rovingtabindex controller in month view', + Array.from(this.renderRoot.querySelectorAll('button')), + this.renderRoot.querySelectorAll('button'), + 'inert?', + this.inert + ); + + if (this.inert) { + return []; + } + // return Array.from(this.renderRoot.querySelectorAll('button')); // return Array.from(this.renderRoot.querySelectorAll('button')).filter(btn => !btn.disabled); return Array.from(this.renderRoot.querySelectorAll('button')); // keep disabled buttons to preserve grid alignment + + // const buttons = Array.from(this.renderRoot.querySelectorAll('button')) as HTMLButtonElement[]; + // // If `inert` is set we keep disabled buttons to preserve grid alignment, + // // otherwise return only focusable (not disabled) buttons. + // return this.inert ? buttons : buttons.filter(btn => !btn.disabled); }, isFocusableElement: el => !el.disabled }); @@ -196,9 +217,12 @@ export class MonthView extends LocaleMixin(LitElement) { this.calendar = createCalendar(this.month ?? new Date(), { firstDayOfWeek, max, min, showToday }); } - if (changes.has('month') || changes.has('inert')) { + if (changes.has('max') || changes.has('min') || changes.has('month') || changes.has('inert')) { this.#rovingTabindexController.clearElementCache(); + // this.#rovingTabindexController.focus(); } + + console.log('find all focusable elements in month view...', this.renderRoot.querySelectorAll('[tabindex="0"]')); } override render(): TemplateResult { diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index d31df475f7..9d04557e3c 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -188,7 +188,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { monthViews.forEach(mv => this.observer?.observe(mv)); console.log('monthViews observed', monthViews); }); - } + } // TODO: maybe rovingtabindex for days should be added here as well? not only in the month view? override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); From b77a2d45875d2baea71c16cf9041d54147b7a209 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Mon, 13 Oct 2025 15:55:01 +0200 Subject: [PATCH 044/126] fixing the issue with lost focus in month view, trying to find the solution for keyboard navigation when trying to focus month in select-month --- .../components/calendar/src/month-view.ts | 152 +++++++++++++++--- .../components/calendar/src/select-day.ts | 4 +- .../components/calendar/src/select-month.ts | 18 ++- 3 files changed, 148 insertions(+), 26 deletions(-) diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index fd05b9adb8..63563b8e73 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -5,7 +5,7 @@ import { dateConverter } from '@sl-design-system/shared/converters.js'; import { type SlChangeEvent, type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { LocaleMixin } from '@sl-design-system/shared/mixins.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { property, query, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import styles from './month-view.scss.js'; import { type Calendar, type Day, createCalendar, getWeekdayNames, isDateInList, isSameDate } from './utils.js'; @@ -30,29 +30,50 @@ export class MonthView extends LocaleMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; + /* #rovingTabindexController = new RovingTabindexController(this, { direction: 'grid', // NOTE: If we add the ability to toggle the weekend days, we'll need to update this value. directionLength: 7, - focusInIndex: elements => { - let index = elements.findIndex(el => el.part.contains('selected') && !el.disabled); - console.log('looking for selected index...', index); - if (index > -1) { - console.log('in focusInIndex: focusing selected?', index); - return index; - } - - index = elements.findIndex(el => el.part.contains('today') && !el.disabled); - console.log('looking for today index...', index); - if (index > -1) { - console.log('in focusInIndex: focusing today?', index); - return index; - } - + focusInIndex: (elements: HTMLButtonElement[]) => { + // let index = elements.findIndex(el => el.part.contains('selected') && !el.disabled); + // console.log('looking for selected index...', index); + // if (index > -1) { + // console.log('in focusInIndex: focusing selected?', index); + // return index; + // } + // + // index = elements.findIndex(el => el.part.contains('today') && !el.disabled); + // console.log('looking for today index...', index); + // if (index > -1) { + // console.log('in focusInIndex: focusing today?', index); + // return index; + // } + // + // console.log( + // 'in focusInIndex: no selected or today, focusing first enabled element', + // elements.findIndex(el => !el.disabled) + // ); + // + // // TODO: maybe an issue here? when trying to use tab shift? problem with tabindex 0 + // + // return elements.findIndex(el => !el.disabled); console.log( - 'in focusInIndex: no selected or today, focusing first enabled element', + 'elements in focusInIndex', + elements, elements.findIndex(el => !el.disabled) ); + // return elements.findIndex(el => !el.disabled); + + // If there are no focusable elements (e.g. inert or focus left the grid), don't force any element to tabindex=0 + if (!elements || elements.length === 0) return -1; + + // Prefer selected -> today -> first enabled + const selectedIndex = elements.findIndex(el => el.getAttribute('aria-current') === 'date' && !el.disabled); + if (selectedIndex > -1) return selectedIndex; + + const todayIndex = elements.findIndex(el => (el.getAttribute('part') ?? '').includes('today') && !el.disabled); + if (todayIndex > -1) return todayIndex; return elements.findIndex(el => !el.disabled); }, @@ -78,7 +99,42 @@ export class MonthView extends LocaleMixin(LitElement) { // // otherwise return only focusable (not disabled) buttons. // return this.inert ? buttons : buttons.filter(btn => !btn.disabled); }, + isFocusableElement: el => !el.disabled, + listenerScope: (): HTMLElement => this + // listenerScope: (): HTMLElement => this.days! + // listenerScope: (): HTMLElement => this.renderRoot.querySelector('button')! + // listenerScope: (): HTMLElement => { + // // if (this.inert) { + // // const el = this.renderRoot.querySelector('button') as HTMLElement | null; + // // console.log('listenerScope in month view...', el, this.days, this.renderRoot.querySelector('button')); + // // + // // return this; + // // } + // console.log('listenerScope in month view...', 'not inert...', this.days, this.renderRoot.querySelector('button')); + // + // return this.days ?? this; //this.renderRoot.querySelector('button') as HTMLElement; + // } + }); +*/ + + #rovingTabindexController = new RovingTabindexController(this, { + direction: 'grid', + directionLength: 7, + focusInIndex: (elements: HTMLButtonElement[]) => { + if (!elements || elements.length === 0) return -1; + const selectedIndex = elements.findIndex(el => el.getAttribute('aria-current') === 'date' && !el.disabled); + if (selectedIndex > -1) return selectedIndex; + const todayIndex = elements.findIndex(el => (el.getAttribute('part') ?? '').includes('today') && !el.disabled); + if (todayIndex > -1) return todayIndex; + return elements.findIndex(el => !el.disabled); + }, + elements: (): HTMLButtonElement[] => { + if (this.inert) return []; + return Array.from(this.renderRoot.querySelectorAll('button')); + }, isFocusableElement: el => !el.disabled + // Listen on the host so focus moves between shadow and light DOM are detected + // listenerScope: (): HTMLElement => this }); /** @internal The calendar object. */ @@ -87,6 +143,9 @@ export class MonthView extends LocaleMixin(LitElement) { /** @internal Emits when the user uses the keyboard to navigate to the next/previous month. */ @event({ name: 'sl-change' }) changeEvent!: EventEmitter>; + /** @internal Days elements. */ + @query('.days') days?: HTMLElement; + /** The list of dates that should be disabled. */ @property({ converter: dateConverter }) disabled?: Date[]; @@ -196,6 +255,19 @@ export class MonthView extends LocaleMixin(LitElement) { /** @internal The translated days of the week. */ @state() weekDays: Array<{ long: string; short: string }> = []; + // override connectedCallback(): void { + // super.connectedCallback(); + // // capture so we see focusout/keydown before the roving controller acts + // this.addEventListener('focusout', this.#onFocusOut, true); + // this.addEventListener('keydown', this.#onHostKeydown, true); + // } + // + // override disconnectedCallback(): void { + // this.removeEventListener('focusout', this.#onFocusOut, true); + // this.removeEventListener('keydown', this.#onHostKeydown, true); + // super.disconnectedCallback(); + // } + override willUpdate(changes: PropertyValues): void { if (changes.has('firstDayOfWeek') || changes.has('locale')) { const { locale, firstDayOfWeek } = this, @@ -232,7 +304,7 @@ export class MonthView extends LocaleMixin(LitElement) {
    ${this.calendar?.weeks.map( week => html` - + ${this.showWeekNumbers ? html`` : nothing} ${week.days.map(day => this.renderDay(day))} @@ -359,6 +431,15 @@ export class MonthView extends LocaleMixin(LitElement) { } #onKeydown(event: KeyboardEvent, day: Day): void { + // if (event.key === 'Tab') { + // // Allow default tab behaviour so focus can leave the month view naturally. + // // Clear roving controller cache so it does not reset tabindex when focus leaves. + // this.#rovingTabindexController.clearElementCache(); + // setTimeout(() => this.#rovingTabindexController.clearElementCache(), 0); + // return; + // } + + console.log('keydown event in month view...', event, day); if (event.key === 'ArrowLeft' && day.currentMonth && day.date.getDate() === 1) { event.preventDefault(); event.stopPropagation(); @@ -409,9 +490,42 @@ export class MonthView extends LocaleMixin(LitElement) { this.selectEvent.emit(day.date); this.selected = day.date; - } + } /*else if (event.key === 'Tab') { + // Allow default tab behaviour so focus can leave the month view naturally. + // Do not stop propagation or prevent default. + // Clear roving controller cache so it does not reset tabindex when focus leaves. + this.#rovingTabindexController.clearElementCache(); + setTimeout(() => this.#rovingTabindexController.clearElementCache(), 0); + + return; + }*/ } + // /** Clear roving controller when focus actually leaves the component. */ + // #onFocusOut = (e: FocusEvent): void => { + // const related = e.relatedTarget as Node | null; + // + // console.log('focusout event in month view...', e, related, this.contains(related)); + // + // // TODO: why focus target is null? when using shift tab... + // + // // If focus moved to `null` (window) or to a node outside this host/shadow root, clear cache. + // if (!related || !(this.contains(related) || (this.renderRoot && (this.renderRoot as Node).contains(related)))) { + // this.#rovingTabindexController.clearElementCache(); + // // this.#rovingTabindexController.hostDisconnected(); + // // this.#rovingTabindexController.focus(); + // } + // }; + // + // /** Clear cache when Tab is used so the controller doesn't force a focusable back into the grid. */ + // #onHostKeydown = (e: KeyboardEvent): void => { + // if (e.key === 'Tab') { + // this.#rovingTabindexController.clearElementCache(); + // // also clear on next tick to handle async focus moves + // setTimeout(() => this.#rovingTabindexController.clearElementCache(), 0); + // } + // }; + /** Nearest enabled same-weekday date (weekly steps: -1 or 1) */ #getEnabledSameWeekday(start: Date, direction: 1 | -1): Date | undefined { const findEnabledSameWeekday = (current: Date): Date | undefined => { diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 9d04557e3c..1f8f076ed8 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -326,7 +326,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { : nothing} ${this.weekDays.map(day => html`${day.short}`)} -
    +
    { this.renderRoot.querySelector('sl-month-view:nth-child(2)')?.focusDay(event.detail); - }); + }); // TODO: maybe focus next month shoudl be added explicitly here as well? } #onPrevious(): void { diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 642b8263a8..d3d77a9d7a 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -57,9 +57,15 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { return Array.from(list.querySelectorAll('button')).filter(btn => !btn.disabled); }, focusInIndex: elements => { - const index = elements.findIndex(el => el.hasAttribute('aria-pressed')); - - return index === -1 ? 0 : index; + // const index = elements.findIndex(el => el.hasAttribute('aria-pressed')); + // + // return index === -1 ? 0 : index; + + if (!elements || elements.length === 0) return -1; + const buttons = elements as HTMLButtonElement[]; + const selectedIndex = buttons.findIndex(el => el.hasAttribute('aria-pressed') && !el.disabled); + if (selectedIndex > -1) return selectedIndex; + return buttons.findIndex(el => !el.disabled); }, listenerScope: (): HTMLElement => this.renderRoot.querySelector('ol')! }); @@ -130,6 +136,8 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { if (changes.has('month') || changes.has('min') || changes.has('max') || changes.has('inert')) { // console.log('SelectMonth willUpdate --- changes in month, min, max, inert', changes); this.#rovingTabindexController.clearElementCache(); + // this.#rovingTabindexController.focus(); + // requestAnimationFrame(() => this.#rovingTabindexController.focus()); } } @@ -174,7 +182,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { ${this.months.map(month => { const parts = this.getMonthParts(month).join(' '); return html` -
  • +
  • ${month.unselectable ? html`` : html` @@ -192,7 +200,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { })} `; - } + } // TODO: sth wrong with focusing month and year, works when using shirt tab but not just tab? /** Returns an array of part names for a day. */ getMonthParts = (month: Month): string[] => { From ba2e24abba506d0dd02ae98f51f6a5ecea3ca439 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Tue, 14 Oct 2025 16:06:39 +0200 Subject: [PATCH 045/126] trying to find the solution for keyboard navigation when trying to focus year in select-year, still an issue in select-month --- .../components/calendar/src/select-year.ts | 429 +++++++++++------- .../shared/src/controllers/focus-group.ts | 4 + 2 files changed, 264 insertions(+), 169 deletions(-) diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index 7f409b1d2b..2932019035 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -2,12 +2,11 @@ import { localized, msg } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { Button } from '@sl-design-system/button'; import { Icon } from '@sl-design-system/icon'; -import { type EventEmitter, EventsController, RovingTabindexController, event } from '@sl-design-system/shared'; +import { type EventEmitter, RovingTabindexController, event } from '@sl-design-system/shared'; import { dateConverter } from '@sl-design-system/shared/converters.js'; import { type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; import { property, state } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; import styles from './select-year.scss.js'; declare global { @@ -31,41 +30,10 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; - // eslint-disable-next-line no-unused-private-class-members - #events = new EventsController(this, { keydown: this.#onKeydown }); + // #events = new EventsController(this, { keydown: this.#onKeydown }); // #focusLastOnRender = false; - #rovingTabindexController = new RovingTabindexController(this, { - direction: 'grid', - directionLength: 3, - // elements: (): HTMLElement[] => Array.from(this.renderRoot.querySelectorAll('ol button')), - // elements: (): HTMLElement[] => - // Array.from(this.renderRoot.querySelectorAll('ol button')).filter(btn => !btn.disabled), - elements: (): HTMLElement[] => { - const list = this.renderRoot.querySelector('ol'); - if (!list) return []; - return Array.from(list.querySelectorAll('button')).filter(btn => !btn.disabled); - }, - focusInIndex: elements => { - const index = elements.findIndex(el => el.hasAttribute('aria-pressed')); - - return index === -1 ? 0 : index; - - // const pressedIndex = elements.findIndex(el => el.hasAttribute('aria-pressed')); - // if (pressedIndex !== -1) { - // return pressedIndex; - // } - // // console.log('focusing last element', this.#focusLastOnRender); - // if (this.#focusLastOnRender) { - // // console.log('focusing last element', this.#focusLastOnRender); - // return elements.length - 1; - // } - // return 0; - }, - listenerScope: (): HTMLElement => this.renderRoot.querySelector('ol')! - }); - /** @internal Emits when the user selects a year. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; @@ -96,18 +64,64 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { /** @internal The year you can select from. */ @state() years: number[] = []; + #cols = 3; + + #lastTabstopYear?: number; + + #rovingTabindexController?: RovingTabindexController; + override connectedCallback(): void { super.connectedCallback(); this.#setYears(this.year.getFullYear() - 5, this.year.getFullYear() + 6); + this.addEventListener('focusout', this.#onFocusOut); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + + this.removeEventListener('focusout', this.#onFocusOut); + } + + override firstUpdated(): void { + // this.#initRoving(); + this.#rovingTabindexController = new RovingTabindexController(this, { + direction: 'grid', + directionLength: this.#cols, + elements: () => this.#getYearButtons(), + focusInIndex: els => { + if (!els.length) return -1; + const sel = els.findIndex(el => el.getAttribute('aria-selected') === 'true'); + if (sel > -1) return sel; + const zero = els.findIndex(el => el.tabIndex === 0); + return zero > -1 ? zero : 0; + } + }); + + this.#rovingTabindexController.hostConnected(); + this.#enforceSingleTabstop(); } override willUpdate(changes: PropertyValues): void { console.log('SelectYear willUpdate changes', changes); - if (changes.has('max') || changes.has('min') || changes.has('years') || changes.has('inert')) { - this.#rovingTabindexController.clearElementCache(); - // this.#rovingTabindexController.focusToElement(this.selected); + if (changes.has('selected')) { + this.#lastTabstopYear = this.selected?.getFullYear(); + } + } + + override updated(changes: PropertyValues): void { + if (changes.has('years') || changes.has('selected') || changes.has('min') || changes.has('max')) { + this.#refreshRoving(); + } + } + + override focus(options?: FocusOptions): void { + const current = this.renderRoot.querySelector('ol button[tabindex="0"]'); + if (current) current.focus(options); + else { + this.#enforceSingleTabstop(); + this.renderRoot.querySelector('ol button[tabindex="0"]')?.focus(options); } } @@ -121,7 +135,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { aria-label=${msg('Go back 12 years', { id: 'sl.calendar.previousYears' })} fill="ghost" variant="secondary" - ?disabled=${this.years && this.#isUnselectable((this.years.at(0) || 0) - 1)} + ?disabled=${this.#isUnselectable((this.years.at(0) || 0) - 1)} > @@ -130,29 +144,28 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { aria-label=${msg('Go forward 12 years', { id: 'sl.calendar.nextYears' })} fill="ghost" variant="secondary" - ?disabled=${this.years && this.#isUnselectable((this.years.at(-1) || 0) + 1)} + ?disabled=${this.#isUnselectable((this.years.at(-1) || 0) + 1)} >
  • -
      +
        ${this.years.map(year => { - const parts = this.getYearParts(year).join(' '); + const disabled = this.#isUnselectable(year); + const selected = !!(this.selected && this.selected.getFullYear() === year); return html` -
      1. - ${this.#isUnselectable(year) - ? html`` - : html` - - `} +
      2. +
      3. `; })} @@ -160,158 +173,236 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { `; } - getYearParts = (year: number): string[] => { + getYearParts(year: number): string[] { return [ 'year', year === new Date().getFullYear() ? 'today' : '', this.selected && this.selected.getFullYear() === year ? 'selected' : '', this.#isUnselectable(year) ? 'unselectable' : '' - ].filter(part => part !== ''); - }; + ].filter(Boolean); + } - #isUnselectable(year: number): boolean { - // console.log( - // 'isUnselectable', - // year, - // this.min, - // this.max, - // !!((this.min && year < this.min.getFullYear()) || (this.max && year > this.max.getFullYear())) - // ); - if (!year) { - return true; - } - return !!((this.min && year < this.min.getFullYear()) || (this.max && year > this.max.getFullYear())); + // #initRoving(): void { + // this.#rovingTabindexController = new RovingTabindexController(this, { + // direction: 'grid', + // directionLength: this.#cols, + // elements: () => this.#getYearButtons(), + // focusInIndex: els => { + // if (!els.length) return -1; + // const sel = els.findIndex(el => el.getAttribute('aria-selected') === 'true'); + // if (sel > -1) return sel; + // const zero = els.findIndex(el => el.tabIndex === 0); + // return zero > -1 ? zero : 0; + // } + // }); + // } + + #refreshRoving(): void { + this.#rovingTabindexController?.clearElementCache(); + this.#rovingTabindexController?.hostUpdated(); + // Re-assert the single tab stop (controller may have cleared it). + // this.#enforceSingleTabstop(); + // queueMicrotask(() => this.#enforceSingleTabstop()); + + requestAnimationFrame(() => this.#enforceSingleTabstop()); } #onClick(year: number): void { this.selectEvent.emit(new Date(year, 0)); this.selected = new Date(year, 0); + const btn = this.#findButtonByYear(year); + if (btn) { + this.#rovingTabindexController?.focusToElement(btn); + this.#setActiveButton(btn, false); // roving already focused + } } - #getYearButtons(): HTMLButtonElement[] { - return Array.from(this.renderRoot.querySelectorAll('ol button')); - } + #onKeydown = (e: KeyboardEvent): void => { + const target = (e.target as HTMLElement).closest('button[data-year]'); + if (!target) return; - #onKeydown(event: KeyboardEvent): void { - const canGoPrevious = !this.#isUnselectable(this.years[0] - 1), - canGoNext = !this.#isUnselectable(this.years[this.years.length - 1] + 1), - buttons = this.#getYearButtons(), - activeElement = this.shadowRoot?.activeElement as HTMLButtonElement | null, - index = activeElement ? buttons.indexOf(activeElement) : -1, - cols = 3; + const isArrow = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key); + if (isArrow) { + e.preventDefault(); + e.stopPropagation(); + } - if (event.key === 'ArrowLeft' && canGoPrevious) { - if (index === 0) { - event.preventDefault(); - event.stopPropagation(); + const buttons = this.#getEnabledYearButtons(); + const index = buttons.indexOf(target); + if (index === -1) return; - this.#onPrevious(); - void this.updateComplete.then(() => { - this.#rovingTabindexController.clearElementCache(); + // const cols = this.#cols; + const canPrevRange = !this.#isUnselectable(this.years[0] - 1); + const canNextRange = !this.#isUnselectable(this.years[this.years.length - 1] + 1); + const lastRowStart = + buttons.length - (buttons.length % this.#cols === 0 ? this.#cols : buttons.length % this.#cols); - const newButtons = this.#getYearButtons(); + const move = (btn: HTMLButtonElement) => { + this.#rovingTabindexController?.focusToElement(btn); + this.#setActiveButton(btn, false); + }; - this.#rovingTabindexController.focusToElement(newButtons[newButtons.length - 1]); - }); + switch (e.key) { + case 'ArrowLeft': + if (index > 0) move(buttons[index - 1]); + else if (canPrevRange) this.#shiftRange(-1, false, () => this.#focusLast()); + break; + case 'ArrowRight': + if (index < buttons.length - 1) move(buttons[index + 1]); + else if (canNextRange) this.#shiftRange(1, false, () => this.#focusFirst()); + break; + case 'ArrowUp': { + const t = index - this.#cols; + if (t >= 0) move(buttons[t]); + else if (canPrevRange) { + const col = index % this.#cols; + this.#shiftRange(-1, false, () => this.#focusColumnFromBottom(col)); + } + break; } - } else if (event.key === 'ArrowRight' && canGoNext) { - if (index === buttons.length - 1) { - event.preventDefault(); - event.stopPropagation(); + case 'ArrowDown': { + const t = index + this.#cols; + if (t < buttons.length) move(buttons[t]); + else if (index >= lastRowStart && canNextRange) { + const col = index % this.#cols; + this.#shiftRange(1, false, () => this.#focusColumnFromTop(col)); + } + break; + } + case 'Home': + e.preventDefault(); + this.#focusFirst(); + break; + case 'End': + e.preventDefault(); + this.#focusLast(); + break; + case 'Escape': + e.preventDefault(); + this.selectEvent.emit(this.year); + break; + } + }; - this.#onNext(); - void this.updateComplete.then(() => { - this.#rovingTabindexController.clearElementCache(); + // header = true when triggered by arrow buttons (keep focus on arrow). + #shiftRange(direction: -1 | 1, header: boolean, after: () => void): void { + const first = this.years[0]; + const last = this.years[this.years.length - 1]; + if (direction === -1) this.#setYears(first - 12, first - 1); + else this.#setYears(last + 1, last + 12); + + const activeBefore = (this.getRootNode() as Document | ShadowRoot).activeElement as HTMLElement | null; + + void this.updateComplete.then(() => { + this.#refreshRoving(); + if (header) { + // Reapply a single tab stop without moving focus into the grid. + const candidate = this.#choosePreferredYearButton(); + if (candidate) { + this.#setActiveButton(candidate, false); + queueMicrotask(() => this.#setActiveButton(candidate, false)); + } + // Restore focus to the original arrow (avoid flicker). + if (activeBefore && activeBefore.isConnected) activeBefore.focus(); + } else { + after(); + } + }); + } - const first = this.#getYearButtons()[0]; + #onPrevious(): void { + this.#shiftRange(-1, true, () => {}); + } - if (first) { - this.#rovingTabindexController.focusToElement(first); - } - }); - } - } else if (event.key === 'ArrowUp' && canGoPrevious) { - // When on first row (any of the first 3 buttons), jump to previous range - // and focus the button in the last row, same column. - if (index > -1 && index < cols) { - event.preventDefault(); - event.stopPropagation(); + #onNext(): void { + this.#shiftRange(1, true, () => {}); + } - const col = index % cols; + #focusFirst(): void { + const btn = this.#getEnabledYearButtons()[0]; + if (btn) { + this.#rovingTabindexController?.focusToElement(btn); + this.#setActiveButton(btn, false); + } + } - this.#onPrevious(); + #focusLast(): void { + const btns = this.#getEnabledYearButtons(); + const btn = btns[btns.length - 1]; + if (btn) { + this.#rovingTabindexController?.focusToElement(btn); + this.#setActiveButton(btn, false); + } + } - void this.updateComplete.then(() => { - this.#rovingTabindexController.clearElementCache(); + #focusColumnFromBottom(col: number): void { + const btns = this.#getEnabledYearButtons(); + if (!btns.length) return; + const total = btns.length; + const lastRowStart = total - (total % this.#cols === 0 ? this.#cols : total % this.#cols); + const target = btns[Math.min(lastRowStart + col, total - 1)]; + if (target) { + this.#rovingTabindexController?.focusToElement(target); + this.#setActiveButton(target, false); + } + } - const newButtons = this.#getYearButtons(); - const total = newButtons.length; + #focusColumnFromTop(col: number): void { + const btns = this.#getEnabledYearButtons(); + if (!btns.length) return; + const target = btns[col] ?? btns[btns.length - 1]; + if (target) { + this.#rovingTabindexController?.focusToElement(target); + this.#setActiveButton(target, false); + } + } - if (!total) { - return; - } + #setActiveButton(btn: HTMLButtonElement, focus: boolean): void { + this.#getYearButtons().forEach(b => (b.tabIndex = -1)); + btn.tabIndex = 0; + this.#lastTabstopYear = Number(btn.dataset.year); + if (focus) btn.focus(); + } - // Start index of last (possibly partial) row - const lastRowStart = total - (total % cols === 0 ? cols : total % cols); - const targetIndex = Math.min(lastRowStart + col, total - 1); + #choosePreferredYearButton(): HTMLButtonElement | undefined { + return ( + (this.#lastTabstopYear && this.#findButtonByYear(this.#lastTabstopYear)) || + (this.selected && this.#findButtonByYear(this.selected.getFullYear())) || + this.#getEnabledYearButtons()[0] + ); + } - const target = newButtons[targetIndex]; - if (target) { - this.#rovingTabindexController.focusToElement(target); - } - }); - } - } else if (event.key === 'ArrowDown' && canGoNext) { - // console.log('down on last day of month'); - if (index > -1) { - const total = buttons.length; - const lastRowStart = total - (total % cols === 0 ? cols : total % cols); - // If on any button in the last row, move to next range keeping column - if (index >= lastRowStart) { - event.preventDefault(); - event.stopPropagation(); - - const col = index % cols; - - this.#onNext(); - - void this.updateComplete.then(() => { - this.#rovingTabindexController.clearElementCache(); - - const newButtons = this.#getYearButtons(); - - if (!newButtons.length) { - return; - } - - let target = newButtons[col]; - if (!target) { - // Last button if fewer buttons than expected - target = newButtons[newButtons.length - 1]; - } - if (target) { - this.#rovingTabindexController.focusToElement(target); - } - }); - } - } - } else if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); + #enforceSingleTabstop(): void { + const candidate = this.#choosePreferredYearButton(); + if (!candidate) return; + this.#getYearButtons().forEach(b => (b.tabIndex = -1)); + candidate.tabIndex = 0; + } - this.selectEvent.emit(this.year); - } // TODO: when using escape should close the years view and go back to month view + #findButtonByYear(year: number): HTMLButtonElement | undefined { + return this.renderRoot.querySelector(`button[data-year="${year}"]`) || undefined; } - #onPrevious(): void { - this.#setYears(this.years[0] - 12, this.years[0] - 1); + #onFocusOut = (): void => { + queueMicrotask(() => { + const active = (this.getRootNode() as Document | ShadowRoot).activeElement; + if (!this.contains(active)) this.#enforceSingleTabstop(); + }); + }; + + #getYearButtons(): HTMLButtonElement[] { + return Array.from(this.renderRoot.querySelectorAll('ol button[data-year]')); } - #onNext(): void { - this.#setYears(this.years[this.years.length - 1] + 1, this.years[this.years.length - 1] + 12); + #getEnabledYearButtons(): HTMLButtonElement[] { + return this.#getYearButtons().filter(b => !b.disabled); } #setYears(start: number, end: number): void { this.years = Array.from({ length: end - start + 1 }, (_, i) => start + i); } + + #isUnselectable(year: number): boolean { + return !!((this.min && year < this.min.getFullYear()) || (this.max && year > this.max.getFullYear())); + } } diff --git a/packages/components/shared/src/controllers/focus-group.ts b/packages/components/shared/src/controllers/focus-group.ts index e13152f76c..57f8d1c3c4 100644 --- a/packages/components/shared/src/controllers/focus-group.ts +++ b/packages/components/shared/src/controllers/focus-group.ts @@ -49,6 +49,8 @@ export class FocusGroupController implements ReactiveCont this.cachedElements = this.#elements(); } + // console.log('this.#elements in get elements', this.cachedElements); + return this.cachedElements; } @@ -115,6 +117,8 @@ export class FocusGroupController implements ReactiveCont this.#elements = elements; this.elementEnterAction = elementEnterAction || this.elementEnterAction; + console.log('this.#elements in focus group constructor', this.#elements); + if (typeof focusInIndex === 'number') { this.#focusInIndex = () => focusInIndex; } else if (typeof focusInIndex === 'function') { From 24f615da3186fc23f5505af40b4b0f926388ad55 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Wed, 15 Oct 2025 08:07:46 +0200 Subject: [PATCH 046/126] fixing tests --- .../calendar/src/select-year.spec.ts | 8 +++++-- .../components/calendar/src/select-year.ts | 24 ++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/components/calendar/src/select-year.spec.ts b/packages/components/calendar/src/select-year.spec.ts index 81a8560883..6560ca0b36 100644 --- a/packages/components/calendar/src/select-year.spec.ts +++ b/packages/components/calendar/src/select-year.spec.ts @@ -154,10 +154,14 @@ describe('sl-select-year', () => { it('should emit sl-select for Escape key returning current year', async () => { const currentYear = el.year.getFullYear(); const onSelect = new Promise(resolve => - el.addEventListener('sl-select', e => resolve(e as CustomEvent)) + el.addEventListener('sl-select', e => resolve(e as CustomEvent), { once: true }) ); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + el.focus(); + await userEvent.keyboard('{Escape}'); + const ev = await onSelect; + expect(ev.detail).to.be.instanceOf(Date); expect((ev.detail as Date).getFullYear()).to.equal(currentYear); }); }); diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index 2932019035..d3a3c475dd 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -217,14 +217,14 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } } - #onKeydown = (e: KeyboardEvent): void => { - const target = (e.target as HTMLElement).closest('button[data-year]'); + #onKeydown = (event: KeyboardEvent): void => { + const target = (event.target as HTMLElement).closest('button[data-year]'); if (!target) return; - const isArrow = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key); + const isArrow = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key); if (isArrow) { - e.preventDefault(); - e.stopPropagation(); + event.preventDefault(); + event.stopPropagation(); } const buttons = this.#getEnabledYearButtons(); @@ -242,7 +242,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { this.#setActiveButton(btn, false); }; - switch (e.key) { + switch (event.key) { case 'ArrowLeft': if (index > 0) move(buttons[index - 1]); else if (canPrevRange) this.#shiftRange(-1, false, () => this.#focusLast()); @@ -270,15 +270,17 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { break; } case 'Home': - e.preventDefault(); + event.preventDefault(); this.#focusFirst(); break; case 'End': - e.preventDefault(); + event.preventDefault(); this.#focusLast(); break; case 'Escape': - e.preventDefault(); + event.preventDefault(); + event.stopPropagation(); + this.selectEvent.emit(this.year); break; } @@ -300,7 +302,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { const candidate = this.#choosePreferredYearButton(); if (candidate) { this.#setActiveButton(candidate, false); - queueMicrotask(() => this.#setActiveButton(candidate, false)); + requestAnimationFrame(() => this.#setActiveButton(candidate, false)); } // Restore focus to the original arrow (avoid flicker). if (activeBefore && activeBefore.isConnected) activeBefore.focus(); @@ -384,7 +386,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } #onFocusOut = (): void => { - queueMicrotask(() => { + requestAnimationFrame(() => { const active = (this.getRootNode() as Document | ShadowRoot).activeElement; if (!this.contains(active)) this.#enforceSingleTabstop(); }); From 2cc505f65eb236912882947ccb766f1481a62902 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Wed, 15 Oct 2025 14:19:09 +0200 Subject: [PATCH 047/126] select year improvements --- .../components/calendar/src/select-year.ts | 353 +++++++----------- 1 file changed, 134 insertions(+), 219 deletions(-) diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index d3a3c475dd..282566a58e 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -2,7 +2,7 @@ import { localized, msg } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { Button } from '@sl-design-system/button'; import { Icon } from '@sl-design-system/icon'; -import { type EventEmitter, RovingTabindexController, event } from '@sl-design-system/shared'; +import { type EventEmitter, EventsController, RovingTabindexController, event } from '@sl-design-system/shared'; import { dateConverter } from '@sl-design-system/shared/converters.js'; import { type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; @@ -30,9 +30,8 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; - // #events = new EventsController(this, { keydown: this.#onKeydown }); - - // #focusLastOnRender = false; + // eslint-disable-next-line no-unused-private-class-members + #events = new EventsController(this, { keydown: this.#onSelectYearKeydown }); /** @internal Emits when the user selects a year. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; @@ -66,62 +65,37 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { #cols = 3; - #lastTabstopYear?: number; - #rovingTabindexController?: RovingTabindexController; override connectedCallback(): void { super.connectedCallback(); this.#setYears(this.year.getFullYear() - 5, this.year.getFullYear() + 6); - this.addEventListener('focusout', this.#onFocusOut); - } - - override disconnectedCallback(): void { - super.disconnectedCallback(); - - this.removeEventListener('focusout', this.#onFocusOut); } override firstUpdated(): void { - // this.#initRoving(); this.#rovingTabindexController = new RovingTabindexController(this, { direction: 'grid', directionLength: this.#cols, - elements: () => this.#getYearButtons(), + elements: () => this.#getYearButtons() ?? [], focusInIndex: els => { if (!els.length) return -1; const sel = els.findIndex(el => el.getAttribute('aria-selected') === 'true'); if (sel > -1) return sel; const zero = els.findIndex(el => el.tabIndex === 0); return zero > -1 ? zero : 0; - } + }, + listenerScope: (): HTMLElement => this.renderRoot.querySelector('ol.years')! }); - this.#rovingTabindexController.hostConnected(); - this.#enforceSingleTabstop(); + this.#rovingTabindexController.focusToElement(0); } override willUpdate(changes: PropertyValues): void { console.log('SelectYear willUpdate changes', changes); - if (changes.has('selected')) { - this.#lastTabstopYear = this.selected?.getFullYear(); - } - } - - override updated(changes: PropertyValues): void { - if (changes.has('years') || changes.has('selected') || changes.has('min') || changes.has('max')) { - this.#refreshRoving(); - } - } - - override focus(options?: FocusOptions): void { - const current = this.renderRoot.querySelector('ol button[tabindex="0"]'); - if (current) current.focus(options); - else { - this.#enforceSingleTabstop(); - this.renderRoot.querySelector('ol button[tabindex="0"]')?.focus(options); + if (changes.has('max') || changes.has('min') || changes.has('years') || changes.has('inert')) { + this.#rovingTabindexController?.clearElementCache(); } } @@ -132,6 +106,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) {
        -
          +
            ${this.years.map(year => { const disabled = this.#isUnselectable(year); const selected = !!(this.selected && this.selected.getFullYear() === year); @@ -182,222 +164,155 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { ].filter(Boolean); } - // #initRoving(): void { - // this.#rovingTabindexController = new RovingTabindexController(this, { - // direction: 'grid', - // directionLength: this.#cols, - // elements: () => this.#getYearButtons(), - // focusInIndex: els => { - // if (!els.length) return -1; - // const sel = els.findIndex(el => el.getAttribute('aria-selected') === 'true'); - // if (sel > -1) return sel; - // const zero = els.findIndex(el => el.tabIndex === 0); - // return zero > -1 ? zero : 0; - // } - // }); - // } - - #refreshRoving(): void { - this.#rovingTabindexController?.clearElementCache(); - this.#rovingTabindexController?.hostUpdated(); - // Re-assert the single tab stop (controller may have cleared it). - // this.#enforceSingleTabstop(); - // queueMicrotask(() => this.#enforceSingleTabstop()); - - requestAnimationFrame(() => this.#enforceSingleTabstop()); - } - #onClick(year: number): void { this.selectEvent.emit(new Date(year, 0)); this.selected = new Date(year, 0); - const btn = this.#findButtonByYear(year); - if (btn) { - this.#rovingTabindexController?.focusToElement(btn); - this.#setActiveButton(btn, false); // roving already focused - } } - #onKeydown = (event: KeyboardEvent): void => { - const target = (event.target as HTMLElement).closest('button[data-year]'); - if (!target) return; - - const isArrow = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key); - if (isArrow) { - event.preventDefault(); + #onHeaderArrowKeydown(event: KeyboardEvent): void { + // Prevent arrow keys on header buttons from being handled by the roving controller. + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { event.stopPropagation(); } + } - const buttons = this.#getEnabledYearButtons(); - const index = buttons.indexOf(target); - if (index === -1) return; + #onYearsFocusIn(_event: FocusEvent): void { + this.#rovingTabindexController?.clearElementCache(); + } - // const cols = this.#cols; - const canPrevRange = !this.#isUnselectable(this.years[0] - 1); - const canNextRange = !this.#isUnselectable(this.years[this.years.length - 1] + 1); - const lastRowStart = - buttons.length - (buttons.length % this.#cols === 0 ? this.#cols : buttons.length % this.#cols); + #onYearsFocusOut(_event: FocusEvent): void { + this.#rovingTabindexController?.clearElementCache(); + } - const move = (btn: HTMLButtonElement) => { - this.#rovingTabindexController?.focusToElement(btn); - this.#setActiveButton(btn, false); - }; + #onKeydown(event: KeyboardEvent): void { + console.log('SelectYear onKeydown...', event.key); - switch (event.key) { - case 'ArrowLeft': - if (index > 0) move(buttons[index - 1]); - else if (canPrevRange) this.#shiftRange(-1, false, () => this.#focusLast()); - break; - case 'ArrowRight': - if (index < buttons.length - 1) move(buttons[index + 1]); - else if (canNextRange) this.#shiftRange(1, false, () => this.#focusFirst()); - break; - case 'ArrowUp': { - const t = index - this.#cols; - if (t >= 0) move(buttons[t]); - else if (canPrevRange) { - const col = index % this.#cols; - this.#shiftRange(-1, false, () => this.#focusColumnFromBottom(col)); - } - break; - } - case 'ArrowDown': { - const t = index + this.#cols; - if (t < buttons.length) move(buttons[t]); - else if (index >= lastRowStart && canNextRange) { - const col = index % this.#cols; - this.#shiftRange(1, false, () => this.#focusColumnFromTop(col)); - } - break; - } - case 'Home': - event.preventDefault(); - this.#focusFirst(); - break; - case 'End': + const canGoPrevious = !this.#isUnselectable(this.years[0] - 1), + canGoNext = !this.#isUnselectable(this.years[this.years.length - 1] + 1), + buttons = this.#getYearButtons(), + activeElement = this.shadowRoot?.activeElement as HTMLButtonElement | null, + index = activeElement ? buttons.indexOf(activeElement) : -1, + cols = 3; + + console.log('SelectYear onKeydown...222', event.key); + + if (event.key === 'ArrowLeft' && canGoPrevious) { + if (index === 0) { event.preventDefault(); - this.#focusLast(); - break; - case 'Escape': + event.stopPropagation(); + + this.#onPrevious(); + void this.updateComplete.then(() => { + this.#rovingTabindexController?.clearElementCache(); + + const newButtons = this.#getYearButtons(); + + this.#rovingTabindexController?.focusToElement(newButtons[newButtons.length - 1]); + }); + } + } else if (event.key === 'ArrowRight' && canGoNext) { + if (index === buttons.length - 1) { event.preventDefault(); event.stopPropagation(); - this.selectEvent.emit(this.year); - break; - } - }; - - // header = true when triggered by arrow buttons (keep focus on arrow). - #shiftRange(direction: -1 | 1, header: boolean, after: () => void): void { - const first = this.years[0]; - const last = this.years[this.years.length - 1]; - if (direction === -1) this.#setYears(first - 12, first - 1); - else this.#setYears(last + 1, last + 12); - - const activeBefore = (this.getRootNode() as Document | ShadowRoot).activeElement as HTMLElement | null; - - void this.updateComplete.then(() => { - this.#refreshRoving(); - if (header) { - // Reapply a single tab stop without moving focus into the grid. - const candidate = this.#choosePreferredYearButton(); - if (candidate) { - this.#setActiveButton(candidate, false); - requestAnimationFrame(() => this.#setActiveButton(candidate, false)); - } - // Restore focus to the original arrow (avoid flicker). - if (activeBefore && activeBefore.isConnected) activeBefore.focus(); - } else { - after(); + this.#onNext(); + void this.updateComplete.then(() => { + this.#rovingTabindexController?.clearElementCache(); + + const first = this.#getYearButtons()[0]; + + if (first) { + this.#rovingTabindexController?.focusToElement(first); + } + }); } - }); - } + } else if (event.key === 'ArrowUp' && canGoPrevious) { + // When on first row (any of the first 3 buttons), jump to previous range + // and focus the button in the last row, same column. + if (index > -1 && index < cols) { + event.preventDefault(); + event.stopPropagation(); - #onPrevious(): void { - this.#shiftRange(-1, true, () => {}); - } + const col = index % cols; - #onNext(): void { - this.#shiftRange(1, true, () => {}); - } + this.#onPrevious(); - #focusFirst(): void { - const btn = this.#getEnabledYearButtons()[0]; - if (btn) { - this.#rovingTabindexController?.focusToElement(btn); - this.#setActiveButton(btn, false); - } - } + void this.updateComplete.then(() => { + this.#rovingTabindexController?.clearElementCache(); - #focusLast(): void { - const btns = this.#getEnabledYearButtons(); - const btn = btns[btns.length - 1]; - if (btn) { - this.#rovingTabindexController?.focusToElement(btn); - this.#setActiveButton(btn, false); - } - } + const newButtons = this.#getYearButtons(); + const total = newButtons.length; - #focusColumnFromBottom(col: number): void { - const btns = this.#getEnabledYearButtons(); - if (!btns.length) return; - const total = btns.length; - const lastRowStart = total - (total % this.#cols === 0 ? this.#cols : total % this.#cols); - const target = btns[Math.min(lastRowStart + col, total - 1)]; - if (target) { - this.#rovingTabindexController?.focusToElement(target); - this.#setActiveButton(target, false); - } - } + if (!total) { + return; + } - #focusColumnFromTop(col: number): void { - const btns = this.#getEnabledYearButtons(); - if (!btns.length) return; - const target = btns[col] ?? btns[btns.length - 1]; - if (target) { - this.#rovingTabindexController?.focusToElement(target); - this.#setActiveButton(target, false); + // Start index of last (possibly partial) row + const lastRowStart = total - (total % cols === 0 ? cols : total % cols); + const targetIndex = Math.min(lastRowStart + col, total - 1); + + const target = newButtons[targetIndex]; + if (target) { + this.#rovingTabindexController?.focusToElement(target); + } + }); + } + } else if (event.key === 'ArrowDown' && canGoNext) { + // console.log('down on last day of month'); + if (index > -1) { + const total = buttons.length; + const lastRowStart = total - (total % cols === 0 ? cols : total % cols); + // If on any button in the last row, move to next range keeping column + if (index >= lastRowStart) { + event.preventDefault(); + event.stopPropagation(); + + const col = index % cols; + + this.#onNext(); + + void this.updateComplete.then(() => { + this.#rovingTabindexController?.clearElementCache(); + + const newButtons = this.#getYearButtons(); + + if (!newButtons.length) { + return; + } + + let target = newButtons[col]; + if (!target) { + // Last button if fewer buttons than expected + target = newButtons[newButtons.length - 1]; + } + if (target) { + this.#rovingTabindexController?.focusToElement(target); + } + }); + } + } } } - #setActiveButton(btn: HTMLButtonElement, focus: boolean): void { - this.#getYearButtons().forEach(b => (b.tabIndex = -1)); - btn.tabIndex = 0; - this.#lastTabstopYear = Number(btn.dataset.year); - if (focus) btn.focus(); - } + #onSelectYearKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); - #choosePreferredYearButton(): HTMLButtonElement | undefined { - return ( - (this.#lastTabstopYear && this.#findButtonByYear(this.#lastTabstopYear)) || - (this.selected && this.#findButtonByYear(this.selected.getFullYear())) || - this.#getEnabledYearButtons()[0] - ); + this.selectEvent.emit(this.year); + } } - #enforceSingleTabstop(): void { - const candidate = this.#choosePreferredYearButton(); - if (!candidate) return; - this.#getYearButtons().forEach(b => (b.tabIndex = -1)); - candidate.tabIndex = 0; + #onPrevious(): void { + this.#setYears(this.years[0] - 12, this.years[0] - 1); } - #findButtonByYear(year: number): HTMLButtonElement | undefined { - return this.renderRoot.querySelector(`button[data-year="${year}"]`) || undefined; + #onNext(): void { + this.#setYears(this.years[this.years.length - 1] + 1, this.years[this.years.length - 1] + 12); } - #onFocusOut = (): void => { - requestAnimationFrame(() => { - const active = (this.getRootNode() as Document | ShadowRoot).activeElement; - if (!this.contains(active)) this.#enforceSingleTabstop(); - }); - }; - #getYearButtons(): HTMLButtonElement[] { - return Array.from(this.renderRoot.querySelectorAll('ol button[data-year]')); - } - - #getEnabledYearButtons(): HTMLButtonElement[] { - return this.#getYearButtons().filter(b => !b.disabled); + return Array.from(this.renderRoot.querySelectorAll('ol.years button[part~="year"]')); } #setYears(start: number, end: number): void { From 212fc8d3d94c3a0e7519f4da9846666599ff79c1 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Wed, 15 Oct 2025 14:42:10 +0200 Subject: [PATCH 048/126] select year improvements and some in select month --- .../components/calendar/src/select-month.ts | 9 ++++ .../components/calendar/src/select-year.ts | 50 +++++++------------ 2 files changed, 26 insertions(+), 33 deletions(-) diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index d3d77a9d7a..7287ccca79 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -160,6 +160,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) {
            ; @@ -78,22 +83,18 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { direction: 'grid', directionLength: this.#cols, elements: () => this.#getYearButtons() ?? [], - focusInIndex: els => { - if (!els.length) return -1; - const sel = els.findIndex(el => el.getAttribute('aria-selected') === 'true'); - if (sel > -1) return sel; - const zero = els.findIndex(el => el.tabIndex === 0); - return zero > -1 ? zero : 0; + focusInIndex: elements => { + const index = elements.findIndex(el => el.hasAttribute('aria-selected')); + + return index === -1 ? 0 : index; }, listenerScope: (): HTMLElement => this.renderRoot.querySelector('ol.years')! }); - this.#rovingTabindexController.focusToElement(0); + this.#rovingTabindexController?.focus(); } override willUpdate(changes: PropertyValues): void { - console.log('SelectYear willUpdate changes', changes); - if (changes.has('max') || changes.has('min') || changes.has('years') || changes.has('inert')) { this.#rovingTabindexController?.clearElementCache(); } @@ -126,13 +127,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) {
            -
              +
                ${this.years.map(year => { const disabled = this.#isUnselectable(year); const selected = !!(this.selected && this.selected.getFullYear() === year); @@ -176,14 +171,6 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } } - #onYearsFocusIn(_event: FocusEvent): void { - this.#rovingTabindexController?.clearElementCache(); - } - - #onYearsFocusOut(_event: FocusEvent): void { - this.#rovingTabindexController?.clearElementCache(); - } - #onKeydown(event: KeyboardEvent): void { console.log('SelectYear onKeydown...', event.key); @@ -191,10 +178,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { canGoNext = !this.#isUnselectable(this.years[this.years.length - 1] + 1), buttons = this.#getYearButtons(), activeElement = this.shadowRoot?.activeElement as HTMLButtonElement | null, - index = activeElement ? buttons.indexOf(activeElement) : -1, - cols = 3; - - console.log('SelectYear onKeydown...222', event.key); + index = activeElement ? buttons.indexOf(activeElement) : -1; if (event.key === 'ArrowLeft' && canGoPrevious) { if (index === 0) { @@ -229,11 +213,11 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } else if (event.key === 'ArrowUp' && canGoPrevious) { // When on first row (any of the first 3 buttons), jump to previous range // and focus the button in the last row, same column. - if (index > -1 && index < cols) { + if (index > -1 && index < this.#cols) { event.preventDefault(); event.stopPropagation(); - const col = index % cols; + const col = index % this.#cols; this.#onPrevious(); @@ -248,7 +232,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } // Start index of last (possibly partial) row - const lastRowStart = total - (total % cols === 0 ? cols : total % cols); + const lastRowStart = total - (total % this.#cols === 0 ? this.#cols : total % this.#cols); const targetIndex = Math.min(lastRowStart + col, total - 1); const target = newButtons[targetIndex]; @@ -261,13 +245,13 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { // console.log('down on last day of month'); if (index > -1) { const total = buttons.length; - const lastRowStart = total - (total % cols === 0 ? cols : total % cols); + const lastRowStart = total - (total % this.#cols === 0 ? this.#cols : total % this.#cols); // If on any button in the last row, move to next range keeping column if (index >= lastRowStart) { event.preventDefault(); event.stopPropagation(); - const col = index % cols; + const col = index % this.#cols; this.#onNext(); From 7f10411eefdbc3353cd9254f021f64df5e2abae3 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Wed, 15 Oct 2025 15:53:35 +0200 Subject: [PATCH 049/126] select year and month changes, some accessibility improvements --- .../components/calendar/src/month-view.ts | 179 +----------------- .../components/calendar/src/select-month.ts | 84 ++++---- .../components/calendar/src/select-year.ts | 24 ++- 3 files changed, 59 insertions(+), 228 deletions(-) diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index 63563b8e73..fc168c6cfd 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -30,93 +30,6 @@ export class MonthView extends LocaleMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; - /* - #rovingTabindexController = new RovingTabindexController(this, { - direction: 'grid', - // NOTE: If we add the ability to toggle the weekend days, we'll need to update this value. - directionLength: 7, - focusInIndex: (elements: HTMLButtonElement[]) => { - // let index = elements.findIndex(el => el.part.contains('selected') && !el.disabled); - // console.log('looking for selected index...', index); - // if (index > -1) { - // console.log('in focusInIndex: focusing selected?', index); - // return index; - // } - // - // index = elements.findIndex(el => el.part.contains('today') && !el.disabled); - // console.log('looking for today index...', index); - // if (index > -1) { - // console.log('in focusInIndex: focusing today?', index); - // return index; - // } - // - // console.log( - // 'in focusInIndex: no selected or today, focusing first enabled element', - // elements.findIndex(el => !el.disabled) - // ); - // - // // TODO: maybe an issue here? when trying to use tab shift? problem with tabindex 0 - // - // return elements.findIndex(el => !el.disabled); - console.log( - 'elements in focusInIndex', - elements, - elements.findIndex(el => !el.disabled) - ); - // return elements.findIndex(el => !el.disabled); - - // If there are no focusable elements (e.g. inert or focus left the grid), don't force any element to tabindex=0 - if (!elements || elements.length === 0) return -1; - - // Prefer selected -> today -> first enabled - const selectedIndex = elements.findIndex(el => el.getAttribute('aria-current') === 'date' && !el.disabled); - if (selectedIndex > -1) return selectedIndex; - - const todayIndex = elements.findIndex(el => (el.getAttribute('part') ?? '').includes('today') && !el.disabled); - if (todayIndex > -1) return todayIndex; - - return elements.findIndex(el => !el.disabled); - }, - elements: (): HTMLButtonElement[] => { - console.log( - 'elements in rovingtabindex controller in month view', - Array.from(this.renderRoot.querySelectorAll('button')), - this.renderRoot.querySelectorAll('button'), - 'inert?', - this.inert - ); - - if (this.inert) { - return []; - } - - // return Array.from(this.renderRoot.querySelectorAll('button')); - // return Array.from(this.renderRoot.querySelectorAll('button')).filter(btn => !btn.disabled); - return Array.from(this.renderRoot.querySelectorAll('button')); // keep disabled buttons to preserve grid alignment - - // const buttons = Array.from(this.renderRoot.querySelectorAll('button')) as HTMLButtonElement[]; - // // If `inert` is set we keep disabled buttons to preserve grid alignment, - // // otherwise return only focusable (not disabled) buttons. - // return this.inert ? buttons : buttons.filter(btn => !btn.disabled); - }, - isFocusableElement: el => !el.disabled, - listenerScope: (): HTMLElement => this - // listenerScope: (): HTMLElement => this.days! - // listenerScope: (): HTMLElement => this.renderRoot.querySelector('button')! - // listenerScope: (): HTMLElement => { - // // if (this.inert) { - // // const el = this.renderRoot.querySelector('button') as HTMLElement | null; - // // console.log('listenerScope in month view...', el, this.days, this.renderRoot.querySelector('button')); - // // - // // return this; - // // } - // console.log('listenerScope in month view...', 'not inert...', this.days, this.renderRoot.querySelector('button')); - // - // return this.days ?? this; //this.renderRoot.querySelector('button') as HTMLElement; - // } - }); -*/ - #rovingTabindexController = new RovingTabindexController(this, { direction: 'grid', directionLength: 7, @@ -133,8 +46,6 @@ export class MonthView extends LocaleMixin(LitElement) { return Array.from(this.renderRoot.querySelectorAll('button')); }, isFocusableElement: el => !el.disabled - // Listen on the host so focus moves between shadow and light DOM are detected - // listenerScope: (): HTMLElement => this }); /** @internal The calendar object. */ @@ -255,19 +166,6 @@ export class MonthView extends LocaleMixin(LitElement) { /** @internal The translated days of the week. */ @state() weekDays: Array<{ long: string; short: string }> = []; - // override connectedCallback(): void { - // super.connectedCallback(); - // // capture so we see focusout/keydown before the roving controller acts - // this.addEventListener('focusout', this.#onFocusOut, true); - // this.addEventListener('keydown', this.#onHostKeydown, true); - // } - // - // override disconnectedCallback(): void { - // this.removeEventListener('focusout', this.#onFocusOut, true); - // this.removeEventListener('keydown', this.#onHostKeydown, true); - // super.disconnectedCallback(); - // } - override willUpdate(changes: PropertyValues): void { if (changes.has('firstDayOfWeek') || changes.has('locale')) { const { locale, firstDayOfWeek } = this, @@ -299,12 +197,12 @@ export class MonthView extends LocaleMixin(LitElement) { override render(): TemplateResult { return html` -
    ${1 + (this.month?.getMonth() ?? 0)}
    ${week.number}
    +
    ${this.renderHeader()} ${this.calendar?.weeks.map( week => html` - + ${this.showWeekNumbers ? html`` : nothing} ${week.days.map(day => this.renderDay(day))} @@ -318,7 +216,7 @@ export class MonthView extends LocaleMixin(LitElement) { renderHeader(): TemplateResult { return html` - + ${this.showWeekNumbers ? html`` : nothing} ${this.weekDays.map(day => html``)} @@ -329,8 +227,6 @@ export class MonthView extends LocaleMixin(LitElement) { renderDay(day: Day): TemplateResult { let template: TemplateResult | undefined; - // TODO: fix roving tab index up and down when days are disabled - if (this.renderer) { template = this.renderer(day, this); } else if (this.hideDaysOtherMonths && (day.nextMonth || day.previousMonth)) { @@ -339,21 +235,6 @@ export class MonthView extends LocaleMixin(LitElement) { const parts = this.getDayParts(day).join(' '), ariaLabel = `${day.date.getDate()}, ${format(day.date, this.locale, { weekday: 'long' })} ${format(day.date, this.locale, { month: 'long', year: 'numeric' })}`; - // TODO: maybe disabled -> unselectable here as well? - - // TODO: why the bunselectable is not disabled button in the DOM? - - console.log( - 'day before template', - day, - day.date.getDate(), - 'readonly?', - this.readonly, - 'day.unselectable?', - day.unselectable, - parts - ); - template = this.readonly || day.unselectable || day.disabled || isDateInList(day.date, this.disabled) ? html`` @@ -382,16 +263,6 @@ export class MonthView extends LocaleMixin(LitElement) { this.indicator ? this.indicator[0].date : 'no indicator', this.indicator?.length ); - // console.log( - // 'indicator part applied?', - // this.indicator && - // isDateInList( - // day.date, - // this.indicator.map(i => i.date) - // ) - // ? 'indicator' - // : '' - // ); return [ 'day', @@ -431,14 +302,6 @@ export class MonthView extends LocaleMixin(LitElement) { } #onKeydown(event: KeyboardEvent, day: Day): void { - // if (event.key === 'Tab') { - // // Allow default tab behaviour so focus can leave the month view naturally. - // // Clear roving controller cache so it does not reset tabindex when focus leaves. - // this.#rovingTabindexController.clearElementCache(); - // setTimeout(() => this.#rovingTabindexController.clearElementCache(), 0); - // return; - // } - console.log('keydown event in month view...', event, day); if (event.key === 'ArrowLeft' && day.currentMonth && day.date.getDate() === 1) { event.preventDefault(); @@ -490,42 +353,9 @@ export class MonthView extends LocaleMixin(LitElement) { this.selectEvent.emit(day.date); this.selected = day.date; - } /*else if (event.key === 'Tab') { - // Allow default tab behaviour so focus can leave the month view naturally. - // Do not stop propagation or prevent default. - // Clear roving controller cache so it does not reset tabindex when focus leaves. - this.#rovingTabindexController.clearElementCache(); - setTimeout(() => this.#rovingTabindexController.clearElementCache(), 0); - - return; - }*/ + } } - // /** Clear roving controller when focus actually leaves the component. */ - // #onFocusOut = (e: FocusEvent): void => { - // const related = e.relatedTarget as Node | null; - // - // console.log('focusout event in month view...', e, related, this.contains(related)); - // - // // TODO: why focus target is null? when using shift tab... - // - // // If focus moved to `null` (window) or to a node outside this host/shadow root, clear cache. - // if (!related || !(this.contains(related) || (this.renderRoot && (this.renderRoot as Node).contains(related)))) { - // this.#rovingTabindexController.clearElementCache(); - // // this.#rovingTabindexController.hostDisconnected(); - // // this.#rovingTabindexController.focus(); - // } - // }; - // - // /** Clear cache when Tab is used so the controller doesn't force a focusable back into the grid. */ - // #onHostKeydown = (e: KeyboardEvent): void => { - // if (e.key === 'Tab') { - // this.#rovingTabindexController.clearElementCache(); - // // also clear on next tick to handle async focus moves - // setTimeout(() => this.#rovingTabindexController.clearElementCache(), 0); - // } - // }; - /** Nearest enabled same-weekday date (weekly steps: -1 or 1) */ #getEnabledSameWeekday(start: Date, direction: 1 | -1): Date | undefined { const findEnabledSameWeekday = (current: Date): Date | undefined => { @@ -545,3 +375,4 @@ export class MonthView extends LocaleMixin(LitElement) { return findEnabledSameWeekday(start); } } +// TODO: role grid is missing? diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 7287ccca79..80df8c2d7a 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -40,32 +40,25 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { static override styles: CSSResultGroup = styles; // eslint-disable-next-line no-unused-private-class-members - #events = new EventsController(this, { keydown: this.#onKeydown }); + #events = new EventsController(this, { keydown: this.#onSelectMonthKeydown }); - #rovingTabindexController = new RovingTabindexController(this, { + #rovingTabindexController = new RovingTabindexController(this, { direction: 'grid', directionLength: 3, - // elements: (): HTMLElement[] => Array.from(this.renderRoot.querySelectorAll('ol button')), - elements: (): HTMLElement[] => { + elements: (): HTMLButtonElement[] => { const list = this.renderRoot.querySelector('ol'); if (!list) return []; - console.log( - 'in select month view: RovingTabindexController elements --- list', - list, - Array.from(list.querySelectorAll('button')).filter(btn => !btn.disabled) - ); return Array.from(list.querySelectorAll('button')).filter(btn => !btn.disabled); }, focusInIndex: elements => { - // const index = elements.findIndex(el => el.hasAttribute('aria-pressed')); - // - // return index === -1 ? 0 : index; - - if (!elements || elements.length === 0) return -1; - const buttons = elements as HTMLButtonElement[]; - const selectedIndex = buttons.findIndex(el => el.hasAttribute('aria-pressed') && !el.disabled); - if (selectedIndex > -1) return selectedIndex; - return buttons.findIndex(el => !el.disabled); + const index = elements.findIndex(el => el.hasAttribute('aria-selected') && !el.disabled); + + if (index !== -1) { + return index; + } + + const firstEnabled = elements.findIndex(el => !el.disabled); + return firstEnabled === -1 ? 0 : firstEnabled; }, listenerScope: (): HTMLElement => this.renderRoot.querySelector('ol')! }); @@ -134,10 +127,7 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { } if (changes.has('month') || changes.has('min') || changes.has('max') || changes.has('inert')) { - // console.log('SelectMonth willUpdate --- changes in month, min, max, inert', changes); this.#rovingTabindexController.clearElementCache(); - // this.#rovingTabindexController.focus(); - // requestAnimationFrame(() => this.#rovingTabindexController.focus()); } } @@ -180,29 +170,32 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { -
      +
        ${this.months.map(month => { const parts = this.getMonthParts(month).join(' '); return html` -
      1. - ${month.unselectable - ? html`` - : html` - - `} +
      2. +
      3. `; })}
      `; - } // TODO: sth wrong with focusing month and year, works when using shirt tab but not just tab? + } /** Returns an array of part names for a day. */ getMonthParts = (month: Month): string[] => { @@ -219,7 +212,6 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { }; #onClick(month: number): void { - console.log('SelectMonth click event --- month', month, this.selected, new Date(this.month.getFullYear(), month)); this.selectEvent.emit(new Date(this.month.getFullYear(), month)); this.selected = new Date(this.month.getFullYear(), month); } @@ -248,19 +240,15 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { #getMonthsButtons(): HTMLButtonElement[] { return Array.from(this.renderRoot.querySelectorAll('ol button')); - // return Array.from(this.renderRoot.querySelectorAll('ol button:not([disabled])')); } #onKeydown(event: KeyboardEvent): void { - console.log('SelectMonth #onKeydown --- event', event); - const buttons = Array.from(this.renderRoot.querySelectorAll('ol button')), activeElement = this.shadowRoot?.activeElement as HTMLButtonElement | null, index = activeElement ? buttons.indexOf(activeElement) : -1, cols = 3; if (event.key === 'ArrowLeft' && !this.#allPreviousUnselectable()) { - // console.log('SelectMonth #onKeydown --- ArrowLeft', event, 'index --> ', index); if (index === 0) { event.preventDefault(); event.stopPropagation(); @@ -355,11 +343,6 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { }); } } - } else if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - - this.selectEvent.emit(this.month); } } @@ -377,4 +360,13 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { #onPrevious(): void { this.month = new Date(this.month.getFullYear() - 1, this.month.getMonth(), this.month.getDate()); } + + #onSelectMonthKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + + this.selectEvent.emit(this.month); + } + } } diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index 0d3862900e..356d313f04 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -1,4 +1,4 @@ -import { localized, msg } from '@lit/localize'; +import { localized, msg, str } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { Button } from '@sl-design-system/button'; import { Icon } from '@sl-design-system/icon'; @@ -84,9 +84,14 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { directionLength: this.#cols, elements: () => this.#getYearButtons() ?? [], focusInIndex: elements => { - const index = elements.findIndex(el => el.hasAttribute('aria-selected')); + const index = elements.findIndex(el => el.hasAttribute('aria-selected') && !el.disabled); - return index === -1 ? 0 : index; + if (index !== -1) { + return index; + } + + const firstEnabled = elements.findIndex(el => !el.disabled); + return firstEnabled === -1 ? 0 : firstEnabled; }, listenerScope: (): HTMLElement => this.renderRoot.querySelector('ol.years')! }); @@ -127,7 +132,14 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { -
        +
          ${this.years.map(year => { const disabled = this.#isUnselectable(year); const selected = !!(this.selected && this.selected.getFullYear() === year); @@ -139,7 +151,6 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { part=${this.getYearParts(year).join(' ')} ?disabled=${disabled} aria-selected=${selected ? 'true' : 'false'} - data-year=${year} > ${year} @@ -172,8 +183,6 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } #onKeydown(event: KeyboardEvent): void { - console.log('SelectYear onKeydown...', event.key); - const canGoPrevious = !this.#isUnselectable(this.years[0] - 1), canGoNext = !this.#isUnselectable(this.years[this.years.length - 1] + 1), buttons = this.#getYearButtons(), @@ -242,7 +251,6 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { }); } } else if (event.key === 'ArrowDown' && canGoNext) { - // console.log('down on last day of month'); if (index > -1) { const total = buttons.length; const lastRowStart = total - (total % this.#cols === 0 ? this.#cols : total % this.#cols); From 63b7b9023d87bde86c70cd5e7f0f819986f4875b Mon Sep 17 00:00:00 2001 From: anna-lach Date: Thu, 16 Oct 2025 09:48:04 +0200 Subject: [PATCH 050/126] some accessibility improvements --- packages/components/calendar/src/month-view.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index fc168c6cfd..867c65fb74 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -237,9 +237,10 @@ export class MonthView extends LocaleMixin(LitElement) { template = this.readonly || day.unselectable || day.disabled || isDateInList(day.date, this.disabled) - ? html`` + ? html`` : html` - - `; - })} -
        +
    + ${monthRows.map( + row => html` + + ${row.map(month => { + const parts = this.getMonthParts(month).join(' '); + return html` + + `; + })} + + ` + )} + +
    ${week.number}
    ${this.localizedWeekOfYear}${day.short}
    + +
    `; } @@ -239,11 +248,19 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #getMonthsButtons(): HTMLButtonElement[] { - return Array.from(this.renderRoot.querySelectorAll('ol button')); + return Array.from(this.renderRoot.querySelectorAll('.months button')); + } + + #getMonthRows(): Month[][] { + const rows: Month[][] = []; + for (let i = 0; i < this.months.length; i += 3) { + rows.push(this.months.slice(i, i + 3)); + } + return rows; } #onKeydown(event: KeyboardEvent): void { - const buttons = Array.from(this.renderRoot.querySelectorAll('ol button')), + const buttons = Array.from(this.renderRoot.querySelectorAll('.months button')), activeElement = this.shadowRoot?.activeElement as HTMLButtonElement | null, index = activeElement ? buttons.indexOf(activeElement) : -1, cols = 3; From 332055952e51a6a2eb60d6be7bc10574c79fcbf8 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Thu, 16 Oct 2025 11:50:32 +0200 Subject: [PATCH 052/126] select month accessibility improvements --- .../components/calendar/src/select-month.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 7614615aee..03d228e7ce 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -51,7 +51,13 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { return Array.from(list.querySelectorAll('button')).filter(btn => !btn.disabled); }, focusInIndex: elements => { - const index = elements.findIndex(el => el.hasAttribute('aria-selected') && !el.disabled); + const index = elements.findIndex(el => { + if (el.disabled) { + return false; + } + const cell = el.closest('td[role="gridcell"]'); + return !!cell && cell.getAttribute('aria-selected') === 'true'; + }); if (index !== -1) { return index; @@ -180,18 +186,22 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { > ${monthRows.map( - row => html` - - ${row.map(month => { + (row, rowIndex) => html` + + ${row.map((month, colIndex) => { const parts = this.getMonthParts(month).join(' '); return html` - + From b19bf19f7103cc84aa31ba59e9ccdb0f5052c182 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Thu, 16 Oct 2025 14:10:17 +0200 Subject: [PATCH 053/126] select year accessibility improvements (table instead of list and so on), other styling changes --- .../components/calendar/src/select-month.scss | 2 +- .../components/calendar/src/select-year.scss | 22 +++---- .../components/calendar/src/select-year.ts | 66 ++++++++++++------- 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/packages/components/calendar/src/select-month.scss b/packages/components/calendar/src/select-month.scss index 5ae939c0a5..59c5d5d293 100644 --- a/packages/components/calendar/src/select-month.scss +++ b/packages/components/calendar/src/select-month.scss @@ -30,9 +30,9 @@ table.months { block-size: 100%; border-collapse: separate; border-spacing: var(--sl-size-100) var(--sl-size-200); + box-sizing: content-box; inline-size: 100%; margin: 0; - padding: 0 var(--sl-size-100); } table.months td { diff --git a/packages/components/calendar/src/select-year.scss b/packages/components/calendar/src/select-year.scss index dc025ba905..6aa0208bf6 100644 --- a/packages/components/calendar/src/select-year.scss +++ b/packages/components/calendar/src/select-year.scss @@ -13,7 +13,6 @@ } .current-range { - // font-size: 1.2em; font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); margin-inline-end: auto; } @@ -27,24 +26,19 @@ sl-button { user-select: none; } -ol { - display: grid; - flex: 1; - gap: var(--sl-size-050); - gap: var(--sl-size-200) var(--sl-size-100); - grid-template-columns: repeat(3, auto); - justify-items: center; - list-style: none; +table.years { + block-size: 100%; + border-collapse: separate; + border-spacing: var(--sl-size-100) var(--sl-size-200); + box-sizing: content-box; + inline-size: 100%; margin: 0; - padding: 0; } -li { - align-items: center; - display: inline-flex; - justify-content: center; +table.years td { margin: 0; padding: 0; + text-align: center; } button { diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index 356d313f04..b343949139 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -84,7 +84,11 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { directionLength: this.#cols, elements: () => this.#getYearButtons() ?? [], focusInIndex: elements => { - const index = elements.findIndex(el => el.hasAttribute('aria-selected') && !el.disabled); + const index = elements.findIndex(el => { + if (el.disabled) return false; + const cell = el.closest('td[role="gridcell"]'); + return !!cell && cell.getAttribute('aria-selected') === 'true'; + }); if (index !== -1) { return index; @@ -93,7 +97,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { const firstEnabled = elements.findIndex(el => !el.disabled); return firstEnabled === -1 ? 0 : firstEnabled; }, - listenerScope: (): HTMLElement => this.renderRoot.querySelector('ol.years')! + listenerScope: (): HTMLElement => this.renderRoot.querySelector('table.years')! }); this.#rovingTabindexController?.focus(); @@ -106,6 +110,12 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } override render(): TemplateResult { + // chunk years into rows of #cols + const rows: number[][] = []; + for (let i = 0; i < this.years.length; i += this.#cols) { + rows.push(this.years.slice(i, i + this.#cols)); + } + return html`
    ${this.years.at(0)} - ${this.years.at(-1)} @@ -132,7 +142,8 @@ export class SelectYear extends ScopedElementsMixin(LitElement) {
    -
      - ${this.years.map(year => { - const disabled = this.#isUnselectable(year); - const selected = !!(this.selected && this.selected.getFullYear() === year); - return html` -
    1. - -
    2. - `; - })} -
    + + ${rows.map( + (row, rowIndex) => html` + + ${row.map((year, colIndex) => { + const disabled = this.#isUnselectable(year); + const selected = !!(this.selected && this.selected.getFullYear() === year); + return html` + + + + `; + })} + + ` + )} + + `; } @@ -304,7 +326,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { } #getYearButtons(): HTMLButtonElement[] { - return Array.from(this.renderRoot.querySelectorAll('ol.years button[part~="year"]')); + return Array.from(this.renderRoot.querySelectorAll('table.years button[part~="year"]')); } #setYears(start: number, end: number): void { From 95566382f5b35109fddceaa600d2530e344c5f11 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Thu, 16 Oct 2025 16:36:31 +0200 Subject: [PATCH 054/126] select day and month view accessibility improvements --- .../components/calendar/src/month-view.ts | 34 +++++++++++++------ .../components/calendar/src/select-day.ts | 8 +++-- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index 867c65fb74..ea81b9fb5e 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -1,4 +1,4 @@ -import { localized } from '@lit/localize'; +import { localized, msg, str } from '@lit/localize'; import { format } from '@sl-design-system/format-date'; import { type EventEmitter, RovingTabindexController, event } from '@sl-design-system/shared'; import { dateConverter } from '@sl-design-system/shared/converters.js'; @@ -189,10 +189,7 @@ export class MonthView extends LocaleMixin(LitElement) { if (changes.has('max') || changes.has('min') || changes.has('month') || changes.has('inert')) { this.#rovingTabindexController.clearElementCache(); - // this.#rovingTabindexController.focus(); } - - console.log('find all focusable elements in month view...', this.renderRoot.querySelectorAll('[tabindex="0"]')); } override render(): TemplateResult { @@ -203,7 +200,17 @@ export class MonthView extends LocaleMixin(LitElement) { ${this.calendar?.weeks.map( week => html` - ${this.showWeekNumbers ? html`${week.number}` : nothing} + ${this.showWeekNumbers + ? html` + + ${week.number} + + ` + : nothing} ${week.days.map(day => this.renderDay(day))} ` @@ -217,7 +224,13 @@ export class MonthView extends LocaleMixin(LitElement) { return html` - ${this.showWeekNumbers ? html`${this.localizedWeekOfYear}` : nothing} + ${this.showWeekNumbers + ? html` + + ${this.localizedWeekOfYear} + + ` + : nothing} ${this.weekDays.map(day => html`${day.short}`)} @@ -230,17 +243,16 @@ export class MonthView extends LocaleMixin(LitElement) { if (this.renderer) { template = this.renderer(day, this); } else if (this.hideDaysOtherMonths && (day.nextMonth || day.previousMonth)) { - return html``; + return html``; } else { const parts = this.getDayParts(day).join(' '), ariaLabel = `${day.date.getDate()}, ${format(day.date, this.locale, { weekday: 'long' })} ${format(day.date, this.locale, { month: 'long', year: 'numeric' })}`; template = this.readonly || day.unselectable || day.disabled || isDateInList(day.date, this.disabled) - ? html`` + ? html`` : html`
    -
    +
    ${this.showWeekNumbers ? html` - ${this.localizedWeekOfYear} ` : nothing} - ${this.weekDays.map(day => html`${day.short}`)} + ${this.weekDays.map( + day => html`${day.short}` + )}
    Date: Fri, 17 Oct 2025 11:40:27 +0200 Subject: [PATCH 055/126] more accessibility improvements (tooltips for indicators to improve a11y). --- packages/components/calendar/package.json | 3 +- .../calendar/src/calendar.stories.ts | 62 +++++++--- .../components/calendar/src/month-view.ts | 113 +++++++++++++++++- 3 files changed, 154 insertions(+), 24 deletions(-) diff --git a/packages/components/calendar/package.json b/packages/components/calendar/package.json index bc485c7ff2..ddfed2b30f 100644 --- a/packages/components/calendar/package.json +++ b/packages/components/calendar/package.json @@ -40,7 +40,8 @@ "dependencies": { "@sl-design-system/button": "^1.2.5", "@sl-design-system/format-date": "^0.1.3", - "@sl-design-system/icon": "^1.3.0" + "@sl-design-system/icon": "^1.3.0", + "@sl-design-system/tooltip": "^1.3.0" }, "devDependencies": { "@lit/localize": "^0.12.2", diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index e36bffbf4a..9483f8b7b9 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -61,7 +61,7 @@ export default { }, indicator: { control: { type: 'object' }, - description: 'Array of objects: {date: Date, color: string}' + description: 'Array of objects: {date: Date, color: string, label?: string}' } }, render: ({ @@ -115,7 +115,8 @@ export default { .filter(item => item?.date) .map(item => ({ date: item.date.toISOString(), - ...(item.color ? { color: item.color } : {}) + ...(item.color ? { color: item.color } : {}), + ...(item.label ? { label: item.label } : {}) })) ) : undefined @@ -127,6 +128,28 @@ export default { // [{date: new Date(), color: ''}, {date: new Date(), color: ''}, {date: new Date(), color: ''}] +const INDICATOR_LABELS: Record = { + red: { + label: 'Exam — Important' + }, + blue: { + label: 'Homework Deadline' + }, + green: { + label: 'Available — Open slot for study' + }, + yellow: { + label: 'Reminder — A parent‑teacher meeting' + }, + grey: { + label: 'Event — Informational' + }, + default: { + // same as blue + label: 'Homework Deadline' + } +}; + export const Basic: Story = {}; export const FirstDayOfWeek: Story = { @@ -168,12 +191,13 @@ export const Negative: Story = { export const WithIndicator: Story = { args: { indicator: [ - { date: new Date(), color: 'red' }, - { date: new Date('2025-09-05'), color: 'blue' as IndicatorColor }, - { date: new Date('2025-09-07') }, - { date: new Date('2025-09-09'), color: 'green' as IndicatorColor }, - { date: new Date('2025-09-11'), color: 'grey' as IndicatorColor }, - { date: new Date('2025-09-12'), color: 'yellow' as IndicatorColor } + { date: new Date(), color: 'red', label: INDICATOR_LABELS.red.label }, + { date: new Date('2025-09-05'), color: 'blue' as IndicatorColor, label: INDICATOR_LABELS.blue.label }, + { date: new Date('2025-09-24'), label: INDICATOR_LABELS.default.label }, + { date: new Date('2025-09-09'), color: 'green' as IndicatorColor, label: INDICATOR_LABELS.green.label }, + { date: new Date('2025-09-11'), color: 'grey' as IndicatorColor, label: INDICATOR_LABELS.grey.label }, + { date: new Date('2025-09-12'), color: 'yellow' as IndicatorColor, label: INDICATOR_LABELS.yellow.label }, + { date: new Date('2025-09-18'), color: 'red', label: INDICATOR_LABELS.red.label } ], showToday: true, month: new Date('2025-09-01') //new Date(1755640800000) @@ -235,7 +259,8 @@ export const All: Story = { .filter(item => item?.date) .map(item => ({ date: item.date.toISOString(), - ...(item.color ? { color: item.color } : {}) + ...(item.color ? { color: item.color } : {}), + ...(item.label ? { label: item.label } : {}) })) ) : undefined @@ -243,11 +268,12 @@ export const All: Story = { > `; }; - const monthEndDate = new Date(); //new Date(2025, 8, 29); + const monthEndDate = new Date(); const monthEnd = { negative: [getOffsetDate(2, monthEndDate)], - // indicator: [getOffsetDate(3, monthEndDate)], - indicator: [{ date: getOffsetDate(3, monthEndDate), color: 'red' as IndicatorColor }], + indicator: [ + { date: getOffsetDate(3, monthEndDate), color: 'red' as IndicatorColor, label: INDICATOR_LABELS.red.label } + ], selected: getOffsetDate(4, monthEndDate), showToday: false, month: monthEndDate, @@ -257,12 +283,12 @@ export const All: Story = { const indicator = { indicator: [ - { date: getOffsetDate(0), color: 'red' as IndicatorColor }, - { date: getOffsetDate(1), color: 'blue' as IndicatorColor }, - { date: getOffsetDate(2), color: 'yellow' as IndicatorColor }, - { date: getOffsetDate(3), color: 'grey' as IndicatorColor }, - { date: getOffsetDate(5), color: 'green' as IndicatorColor }, - { date: getOffsetDate(8), color: 'green' as IndicatorColor } + { date: getOffsetDate(0), color: 'red' as IndicatorColor, label: INDICATOR_LABELS.red.label }, + { date: getOffsetDate(1), color: 'blue' as IndicatorColor, label: INDICATOR_LABELS.blue.label }, + { date: getOffsetDate(2), color: 'yellow' as IndicatorColor, label: INDICATOR_LABELS.yellow.label }, + { date: getOffsetDate(3), color: 'grey' as IndicatorColor, label: INDICATOR_LABELS.grey.label }, + { date: getOffsetDate(5), color: 'green' as IndicatorColor, label: INDICATOR_LABELS.green.label }, + { date: getOffsetDate(8), color: 'green' as IndicatorColor, label: INDICATOR_LABELS.green.label } ], // make sure one is outside the min/max range selected: getOffsetDate(1), showToday: true, diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index ea81b9fb5e..ef1074494e 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -1,9 +1,11 @@ import { localized, msg, str } from '@lit/localize'; +import { type ScopedElementsMap } from '@open-wc/scoped-elements/lit-element.js'; import { format } from '@sl-design-system/format-date'; import { type EventEmitter, RovingTabindexController, event } from '@sl-design-system/shared'; import { dateConverter } from '@sl-design-system/shared/converters.js'; import { type SlChangeEvent, type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { LocaleMixin } from '@sl-design-system/shared/mixins.js'; +import { Tooltip } from '@sl-design-system/tooltip'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -20,13 +22,20 @@ export type IndicatorColor = 'blue' | 'red' | 'yellow' | 'green' | 'grey'; export type MonthViewRenderer = (day: Day, monthView: MonthView) => TemplateResult; -export type Indicator = { date: Date; color?: IndicatorColor }; +export type Indicator = { date: Date; color?: IndicatorColor; label?: string }; /** * Component that renders a single month of a calendar. */ @localized() export class MonthView extends LocaleMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-tooltip': Tooltip + }; + } + /** @internal */ static override styles: CSSResultGroup = styles; @@ -166,6 +175,8 @@ export class MonthView extends LocaleMixin(LitElement) { /** @internal The translated days of the week. */ @state() weekDays: Array<{ long: string; short: string }> = []; + @state() private tooltipsRendered = false; + override willUpdate(changes: PropertyValues): void { if (changes.has('firstDayOfWeek') || changes.has('locale')) { const { locale, firstDayOfWeek } = this, @@ -192,9 +203,29 @@ export class MonthView extends LocaleMixin(LitElement) { } } + override updated(changes: PropertyValues): void { + super.updated(changes); + + console.log('month view updated, changes:', changes); + + if (changes.has('indicator') || changes.has('calendar') || changes.has('disabled') || changes.has('month')) { + // render toolips after a short delay + this.tooltipsRendered = false; + setTimeout(() => { + this.tooltipsRendered = true; + }, 100); + } + } + override render(): TemplateResult { return html` - +
    ${this.renderHeader()} ${this.calendar?.weeks.map( @@ -217,6 +248,8 @@ export class MonthView extends LocaleMixin(LitElement) { )}
    + + ${this.tooltipsRendered ? this.#renderTooltips() : nothing} `; } @@ -245,22 +278,63 @@ export class MonthView extends LocaleMixin(LitElement) { } else if (this.hideDaysOtherMonths && (day.nextMonth || day.previousMonth)) { return html``; } else { - const parts = this.getDayParts(day).join(' '), - ariaLabel = `${day.date.getDate()}, ${format(day.date, this.locale, { weekday: 'long' })} ${format(day.date, this.locale, { month: 'long', year: 'numeric' })}`; + const parts = this.getDayParts(day).join(' '); + + let ariaLabel = `${day.date.getDate()}, ${format(day.date, this.locale, { weekday: 'long' })} ${format(day.date, this.locale, { month: 'long', year: 'numeric' })}`; + + // Append negative state to aria label if applicable + const isNegative = + this.negative && + isDateInList( + day.date, + // this.negative is Date[]; ensure we map correctly + this.negative.map(d => d) + ); + + if (isNegative) { + ariaLabel += `, ${msg('Unavailable', { id: 'sl.calendar.unavailable' })}`; + } + + // Collect indicators for this date + const indicators = (this.indicator ?? []).filter(i => isSameDate(i.date, day.date)); + + // Build accessible indicator description(s). Prefer semantic label; fall back to a generic localized label. + const indicatorDescriptions = indicators.map(i => + i.label + ? i.label + : i.color + ? msg(str`Indicator, ${i.color}`, { id: 'sl.calendar.indicatorWithColor' }) + : msg('Indicator', { id: 'sl.calendar.indicator' }) + ); + + // If there are indicators, create a sanitized id and add aria-describedby + const describedById = + indicatorDescriptions.length > 0 + ? `sl-calendar-indicator-${day.date.toISOString().replace(/[^a-z0-9_-]/gi, '-')}` + : undefined; + + console.log('indicators and describedById', indicators, describedById); template = this.readonly || day.unselectable || day.disabled || isDateInList(day.date, this.disabled) - ? html`` + ? html` + + ` : html` `; + //aria-describedby=${ifDefined(describedById)} + // this.#renderTooltip(describedById, indicatorDescriptions); } return html` @@ -270,6 +344,35 @@ export class MonthView extends LocaleMixin(LitElement) { `; } // TODO: buttons instead of spans for unselectable days, still problems with disabled? + #renderTooltips(): TemplateResult | typeof nothing { + if (!this.calendar) { + return nothing; + } + + const tooltips: TemplateResult[] = []; + + for (const week of this.calendar.weeks) { + for (const day of week.days) { + const indicators = (this.indicator ?? []).filter(i => isSameDate(i.date, day.date)); + if (!indicators.length) continue; + + const indicatorDescriptions = indicators.map(i => + i.label + ? i.label + : i.color + ? msg(str`Indicator, ${i.color}`, { id: 'sl.calendar.indicatorWithColor' }) + : msg('Indicator', { id: 'sl.calendar.indicator' }) + ); + + const describedById = `sl-calendar-indicator-${day.date.toISOString().replace(/[^a-z0-9_-]/gi, '-')}`; + + tooltips.push(html`${indicatorDescriptions.join(', ')}`); + } + } + + return html`${tooltips}`; + } + /** Returns an array of part names for a day. */ getDayParts = (day: Day): string[] => { console.log( From 946538588ecd34d0b7eebd2f6cfdb06cc3889e81 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Fri, 17 Oct 2025 12:43:57 +0200 Subject: [PATCH 056/126] more accessibility improvements, some cleanup --- .../calendar/src/calendar.stories.ts | 3 - packages/components/calendar/src/calendar.ts | 61 ++----------------- .../calendar/src/month-view.stories.ts | 4 -- .../components/calendar/src/month-view.ts | 50 ++++++--------- .../components/calendar/src/select-day.ts | 45 +++----------- .../calendar/src/select-month.stories.ts | 1 - .../components/calendar/src/select-month.ts | 2 + .../calendar/src/select-year.stories.ts | 1 - .../components/calendar/src/select-year.ts | 4 ++ packages/components/calendar/src/utils.ts | 6 -- 10 files changed, 37 insertions(+), 140 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index 9483f8b7b9..30ec07c6c5 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -87,10 +87,7 @@ export default { return value instanceof Date ? value : new Date(value); }; - // const selectedDate: Date | undefined = parseDate(selected); - const onSelectDate = (event: CustomEvent) => { - console.log('Date selected:', event.detail.getFullYear(), event.detail.getMonth()); updateArgs({ selected: new Date(event.detail).getTime() }); //needs to be set to the 'time' otherwise Storybook chokes on the date format 🤷 }; diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 5294d695f0..bff14c753a 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -77,12 +77,10 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { // TODO: make it possible to disable certain days of the week (e.g. all Sundays) ? or just exact dates? - // /** The list of dates that should have 'negative' styling. */ - // @property({ converter: dateListConverter }) indicator?: Date[]; - - /** The list of dates that should have an indicator. */ - // @property({ converter: dateConverter }) indicator?: Indicator[]; // Date[]; - + /** + * The list of dates that should display an indicator. + * Each item is an Indicator with a `date`, an optional `color` + * and 'label' that is used to improve accessibility (added as a tooltip). */ @property({ attribute: 'indicator', converter: { @@ -115,7 +113,7 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { override connectedCallback(): void { super.connectedCallback(); - document.addEventListener('pointerdown', this.#onPointerDown, true); // TODO: or maybe click better? + document.addEventListener('pointerdown', this.#onPointerDown, true); } override disconnectedCallback(): void { @@ -130,33 +128,14 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { if (changes.has('selected') && this.selected) { // If only the `selected` property is set, make sure the `month` property is set // to the same date, so the selected day is visible in the calendar. - // this.month ??= this.selected; //new Date(this.selected); // only assigns this.selected to this.month if this.month is null or undefined this.month = this.selected; - // TODO: jumping here when selected has changed? - console.log( - 'willUpdate selected, setting month to', - this.month, - this.selected, - this.month.getMonth(), - this.selected.getMonth() - ); } else { // Otherwise default to the current month. this.month ??= new Date(); - console.log('willUpdate month - should be current', this.month, this.selected); } } override render(): TemplateResult { - console.log('in render', this.month, this.selected); - - console.log('indicator in the calendar render', this.indicator); - - console.log('disabled dates', this.disabled, 'min and max', this.min, this.max, 'month', this.month); - - console.log('find all focusable elements in calendar...', this.renderRoot.querySelectorAll('[tabindex="0"]')); - // TODO: in each month view there is one focusable (tabindex 0) element, probably only in the currently visible one there should be a tabindex 0? - return html` ', - this.mode, - 'previousMode -> ', - this.#previousMode - ); - - // todo: after closing years view, when I'm back to months view, I cannot use arrow keys properly anymore... why? - - // todo: if mode year and previous month, should go back to month, in other cases go back to day ??? - const path = event.composedPath(); if (!path.includes(this)) { if (this.mode === 'year' && this.#previousMode === 'month') { @@ -240,21 +202,14 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { } else { this.mode = 'day'; } - - // requestAnimationFrame(() => { - // this.renderRoot.querySelector('sl-select-day')?.focus(); // TODO: really necessary? - // }); - // - // this.requestUpdate(); } - }; // TODO: stop Propagation needs to be added to prevent bubbling up? So it would not close the dialog for example? + }; #onSelect(event: SlSelectEvent): void { event.preventDefault(); event.stopPropagation(); if (!this.selected || !isSameDate(this.selected, event.detail)) { - console.log('select date', event.detail, this.selected); this.selected = new Date(event.detail); this.changeEvent.emit(this.selected); } @@ -285,7 +240,6 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #onToggleMonthYear(event: SlToggleEvent<'month' | 'year'>): void { - // TODO: should be used when clicking outsie the component to close month/year views ?? event.preventDefault(); event.stopPropagation(); @@ -297,6 +251,3 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { }); } } -// TODO: there is an issue when I go to months view and then go to the years view and then select a year and go back to selecting month from months view - I cannot use arrow keays properly there... why? - -// TODO: what aria for current day (today) and what for selected day? diff --git a/packages/components/calendar/src/month-view.stories.ts b/packages/components/calendar/src/month-view.stories.ts index 0422256b9b..4fd62c615e 100644 --- a/packages/components/calendar/src/month-view.stories.ts +++ b/packages/components/calendar/src/month-view.stories.ts @@ -124,10 +124,6 @@ export default { export const Basic: Story = {}; -// TODO: selecting when clicking on it should work in the mont-view as well? - -// TODO: disabled days story is missing - export const FirstDayOfWeek: Story = { args: { firstDayOfWeek: 0 diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index ef1074494e..df7f0911f1 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -121,10 +121,10 @@ export class MonthView extends LocaleMixin(LitElement) { /** The list of dates that should have 'negative' styling. */ @property({ converter: dateConverter }) negative?: Date[]; - /** The list of dates that should have an indicator. */ - // @property({ converter: dateConverter }) indicator?: Indicator[]; // Date[]; - // @property({ type: Object }) indicator?: Indicator[]; // Date[]; - + /** + * The list of dates that should display an indicator. + * Each item is an Indicator with a `date`, an optional `color` + * and 'label' that is used to improve accessibility (added as a tooltip). */ @property({ attribute: 'indicator', converter: { @@ -148,15 +148,6 @@ export class MonthView extends LocaleMixin(LitElement) { }) indicator?: Indicator[]; - // TODO: maybe implement indicator color and indicator (date) separately? - - // /** - // * The color of the indicator. - // * Should be used together with the `indicator` property. - // * @default 'blue' - // */ - // @property({ reflect: true, attribute: 'indicator-color' }) indicatorColor?: IndicatorColor; - // eslint-disable-next-line lit/no-native-attributes @property({ type: Boolean }) override inert = false; @@ -206,8 +197,6 @@ export class MonthView extends LocaleMixin(LitElement) { override updated(changes: PropertyValues): void { super.updated(changes); - console.log('month view updated, changes:', changes); - if (changes.has('indicator') || changes.has('calendar') || changes.has('disabled') || changes.has('month')) { // render toolips after a short delay this.tooltipsRendered = false; @@ -273,6 +262,9 @@ export class MonthView extends LocaleMixin(LitElement) { renderDay(day: Day): TemplateResult { let template: TemplateResult | undefined; + const partsArr = this.getDayParts(day); + const isSelected = partsArr.includes('selected'); + if (this.renderer) { template = this.renderer(day, this); } else if (this.hideDaysOtherMonths && (day.nextMonth || day.previousMonth)) { @@ -313,8 +305,6 @@ export class MonthView extends LocaleMixin(LitElement) { ? `sl-calendar-indicator-${day.date.toISOString().replace(/[^a-z0-9_-]/gi, '-')}` : undefined; - console.log('indicators and describedById', indicators, describedById); - template = this.readonly || day.unselectable || day.disabled || isDateInList(day.date, this.disabled) ? html` @@ -326,23 +316,27 @@ export class MonthView extends LocaleMixin(LitElement) { `; - //aria-describedby=${ifDefined(describedById)} - // this.#renderTooltip(describedById, indicatorDescriptions); } return html` - this.#onClick(event, day)} data-date=${day.date.toISOString()}> + this.#onClick(event, day)} + aria-selected=${isSelected ? 'true' : 'false'} + data-date=${day.date.toISOString()} + > ${template} `; - } // TODO: buttons instead of spans for unselectable days, still problems with disabled? + } #renderTooltips(): TemplateResult | typeof nothing { if (!this.calendar) { @@ -375,13 +369,6 @@ export class MonthView extends LocaleMixin(LitElement) { /** Returns an array of part names for a day. */ getDayParts = (day: Day): string[] => { - console.log( - 'indicator in getDayParts', - this.indicator, - this.indicator ? this.indicator[0].date : 'no indicator', - this.indicator?.length - ); - return [ 'day', day.nextMonth ? 'next-month' : '', @@ -412,7 +399,6 @@ export class MonthView extends LocaleMixin(LitElement) { } #onClick(event: Event, day: Day): void { - console.log('click event in month view...', event, day); if (event.target instanceof HTMLButtonElement && !event.target.disabled) { this.selectEvent.emit(day.date); this.selected = day.date; @@ -420,7 +406,6 @@ export class MonthView extends LocaleMixin(LitElement) { } #onKeydown(event: KeyboardEvent, day: Day): void { - console.log('keydown event in month view...', event, day); if (event.key === 'ArrowLeft' && day.currentMonth && day.date.getDate() === 1) { event.preventDefault(); event.stopPropagation(); @@ -493,4 +478,3 @@ export class MonthView extends LocaleMixin(LitElement) { return findEnabledSameWeekday(start); } } -// TODO: role grid is missing? diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 0d5e702b46..29e1006b45 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -92,7 +92,10 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The list of dates that should have 'negative' styling. */ @property({ converter: dateConverter }) negative?: Date[]; - /** The list of dates that should have an indicator. */ + /** + * The list of dates that should display an indicator. + * Each item is an Indicator with a `date`, an optional `color` + * and 'label' that is used to improve accessibility (added as a tooltip). */ @property({ attribute: 'indicator', converter: { @@ -159,36 +162,20 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { this.observer = new IntersectionObserver( entries => { entries.forEach(entry => { - // console.log( - // 'entry in intersection observer', - // entry, - // 'entry.isIntersecting && entry.intersectionRatio', - // entry.isIntersecting, - // 'ratio:', - // entry.intersectionRatio, - // 'month...', - // normalizeDateTime((entry.target as MonthView).month!), - // 'root for intersection observer', - // this.scroller, - // 'monthView....?', - // this.monthView - // ); if (entry.isIntersecting && entry.intersectionRatio === 1) { this.month = normalizeDateTime((entry.target as MonthView).month!); - console.log('month in intersection observer', this.month); this.#scrollToMonth(0); } }); }, - { root: this.scroller, rootMargin: `${totalHorizontal}px`, threshold: [0, 0.25, 0.5, 0.75, /*0.99,*/ 1] } // TODO: check maybe rootMargin 20px or sth? + { root: this.scroller, rootMargin: `${totalHorizontal}px`, threshold: [0, 0.25, 0.5, 0.75, 1] } ); this.#scrollToMonth(0); const monthViews = this.renderRoot.querySelectorAll('sl-month-view'); monthViews.forEach(mv => this.observer?.observe(mv)); - console.log('monthViews observed', monthViews); }); - } // TODO: maybe rovingtabindex for days should be added here as well? not only in the month view? + } override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); @@ -229,8 +216,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { (this.min && this.previousMonth?.getTime() >= new Date(this.min.getFullYear(), this.min.getMonth()).getTime()) : false; - console.log('canSelectPreviousMonth in select day', canSelectPreviousMonth, this.previousMonth, this.min); - return html`
    @@ -393,7 +378,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { requestAnimationFrame(() => { this.renderRoot.querySelector('sl-month-view:nth-child(2)')?.focusDay(event.detail); - }); // TODO: maybe focus next month shoudl be added explicitly here as well? + }); } #onPrevious(): void { @@ -401,7 +386,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #onNext(): void { - // TODO: why the header is not updated when clicking next and week days are visible? this.#scrollToMonth(1, true); } @@ -421,24 +405,11 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #scrollToMonth(month: -1 | 0 | 1, smooth = false): void { - console.log( - 'scroll to month', - month, - smooth, - 'left:', - parseInt(getComputedStyle(this).width) * month + parseInt(getComputedStyle(this).width) - ); - // // Prefer scroller width (viewport of observed root). Fall back to host computed width - // const width = this.scroller?.clientWidth ?? parseInt(getComputedStyle(this).width), //parseInt(getComputedStyle(this).width), - // left = width * month + width; - // Prefer scroller width (viewport of observed root). Fall back to host computed width. const hostWidth = parseInt(getComputedStyle(this).width) || 0; - const width = /*this.scroller?.clientWidth ??*/ hostWidth; + const width = hostWidth; const left = width * month + width; - console.log('scroll to month - left', left, 'width used', width, 'hostWidth', hostWidth); - this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); } } diff --git a/packages/components/calendar/src/select-month.stories.ts b/packages/components/calendar/src/select-month.stories.ts index a2e26c5476..f995e2706b 100644 --- a/packages/components/calendar/src/select-month.stories.ts +++ b/packages/components/calendar/src/select-month.stories.ts @@ -47,7 +47,6 @@ export default { }; const onSelectMonth = (event: CustomEvent) => { - console.log('Month selected:', event.detail.getFullYear(), event.detail.getMonth()); updateArgs({ month: new Date(event.detail.getFullYear(), event.detail.getMonth(), 1).getTime() }); //needs to be set to the 'time' otherwise Storybook chokes on the date format 🤷 }; diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 03d228e7ce..6843e9b446 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -202,6 +202,8 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { @click=${() => this.#onClick(month.value)} ?autofocus=${currentMonth === month.value} ?disabled=${month.unselectable} + aria-current=${ifDefined(parts.includes('today') ? 'date' : undefined)} + aria-pressed=${parts.includes('selected') ? 'true' : 'false'} > ${month.long} diff --git a/packages/components/calendar/src/select-year.stories.ts b/packages/components/calendar/src/select-year.stories.ts index 37b5c303ba..2fb84af726 100644 --- a/packages/components/calendar/src/select-year.stories.ts +++ b/packages/components/calendar/src/select-year.stories.ts @@ -47,7 +47,6 @@ export default { }; const onSelectYear = (event: CustomEvent) => { - console.log('Year selected:', event.detail.getFullYear()); updateArgs({ year: new Date(event.detail.getFullYear()).getTime() }); //needs to be set to the 'time' otherwise Storybook chokes on the date format 🤷 }; diff --git a/packages/components/calendar/src/select-year.ts b/packages/components/calendar/src/select-year.ts index b343949139..8883aa8958 100644 --- a/packages/components/calendar/src/select-year.ts +++ b/packages/components/calendar/src/select-year.ts @@ -7,6 +7,7 @@ import { dateConverter } from '@sl-design-system/shared/converters.js'; import { type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; import { property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import styles from './select-year.scss.js'; declare global { @@ -158,6 +159,7 @@ export class SelectYear extends ScopedElementsMixin(LitElement) { ${row.map((year, colIndex) => { const disabled = this.#isUnselectable(year); const selected = !!(this.selected && this.selected.getFullYear() === year); + const parts = this.getYearParts(year).join(' '); return html` !disabled && this.#onClick(year)} part=${this.getYearParts(year).join(' ')} ?disabled=${disabled} + aria-current=${ifDefined(parts.includes('today') ? 'date' : undefined)} + aria-pressed=${parts.includes('selected') ? 'true' : 'false'} > ${year} diff --git a/packages/components/calendar/src/utils.ts b/packages/components/calendar/src/utils.ts index 2d894a1175..f6b09bf414 100644 --- a/packages/components/calendar/src/utils.ts +++ b/packages/components/calendar/src/utils.ts @@ -135,12 +135,6 @@ export function isDateInList(date: Date, list?: Date[] | string): boolean { if (typeof list === 'string') { list = list.split(',').map(item => new Date(item)); } - console.log( - 'is day in list', - date, - list, - list.some(item => isSameDate(item, date)) - ); return list.some(item => isSameDate(item, date)); } From 59e01935afe7c92deb325c43f39dd5d84327ba1c Mon Sep 17 00:00:00 2001 From: anna-lach Date: Fri, 17 Oct 2025 13:32:52 +0200 Subject: [PATCH 057/126] some refactor --- .../calendar/src/calendar.stories.ts | 2 +- packages/components/calendar/src/calendar.ts | 24 ++----------- .../components/calendar/src/month-view.ts | 34 ++++++------------- .../components/calendar/src/select-day.ts | 28 +++------------ packages/components/calendar/src/utils.ts | 25 ++++++++++++++ 5 files changed, 43 insertions(+), 70 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index 30ec07c6c5..cbd72835fa 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -5,7 +5,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { useArgs } from 'storybook/internal/preview-api'; import '../register.js'; import { type Calendar } from './calendar.js'; -import { type IndicatorColor } from './month-view.js'; +import { IndicatorColor } from './utils.js'; type Props = Pick< Calendar, diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index bff14c753a..e09feb3df2 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -7,11 +7,10 @@ import { property, state } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import styles from './calendar.scss.js'; -import { Indicator, IndicatorColor } from './month-view.js'; import { SelectDay } from './select-day.js'; import { SelectMonth } from './select-month.js'; import { SelectYear } from './select-year.js'; -import { isSameDate } from './utils.js'; +import { Indicator, indicatorConverter, isSameDate } from './utils.js'; declare global { interface HTMLElementTagNameMap { @@ -75,32 +74,13 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The list of dates that should have 'negative' styling. */ @property({ converter: dateListConverter }) negative?: Date[]; - // TODO: make it possible to disable certain days of the week (e.g. all Sundays) ? or just exact dates? - /** * The list of dates that should display an indicator. * Each item is an Indicator with a `date`, an optional `color` * and 'label' that is used to improve accessibility (added as a tooltip). */ @property({ attribute: 'indicator', - converter: { - toAttribute: (value?: Indicator[]) => - value - ? JSON.stringify( - value.map(i => ({ - ...i, - date: dateConverter.toAttribute?.(i.date) - })) - ) - : undefined, - fromAttribute: (value: string | null, _type?: unknown) => - value - ? (JSON.parse(value) as Array<{ date: string; color?: IndicatorColor }>).map(i => ({ - ...i, - date: dateConverter.fromAttribute?.(i.date) - })) - : undefined - } + converter: indicatorConverter }) indicator?: Indicator[]; diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index df7f0911f1..6518ebce6a 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -10,7 +10,16 @@ import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResu import { property, query, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import styles from './month-view.scss.js'; -import { type Calendar, type Day, createCalendar, getWeekdayNames, isDateInList, isSameDate } from './utils.js'; +import { + type Calendar, + type Day, + Indicator, + createCalendar, + getWeekdayNames, + indicatorConverter, + isDateInList, + isSameDate +} from './utils.js'; declare global { interface HTMLElementTagNameMap { @@ -18,12 +27,8 @@ declare global { } } -export type IndicatorColor = 'blue' | 'red' | 'yellow' | 'green' | 'grey'; - export type MonthViewRenderer = (day: Day, monthView: MonthView) => TemplateResult; -export type Indicator = { date: Date; color?: IndicatorColor; label?: string }; - /** * Component that renders a single month of a calendar. */ @@ -127,24 +132,7 @@ export class MonthView extends LocaleMixin(LitElement) { * and 'label' that is used to improve accessibility (added as a tooltip). */ @property({ attribute: 'indicator', - converter: { - toAttribute: (value?: Indicator[]) => - value - ? JSON.stringify( - value.map(i => ({ - ...i, - date: dateConverter.toAttribute?.(i.date) - })) - ) - : undefined, - fromAttribute: (value: string | null, _type?: unknown) => - value - ? (JSON.parse(value) as Array<{ date: string; color?: IndicatorColor }>).map(i => ({ - ...i, - date: dateConverter.fromAttribute?.(i.date) - })) - : undefined - } + converter: indicatorConverter }) indicator?: Indicator[]; diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index 29e1006b45..c90355b853 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -9,9 +9,9 @@ import { type SlChangeEvent, type SlSelectEvent, SlToggleEvent } from '@sl-desig import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { Indicator, IndicatorColor, MonthView } from './month-view.js'; +import { MonthView } from './month-view.js'; import styles from './select-day.scss.js'; -import { getWeekdayNames, normalizeDateTime } from './utils.js'; +import { Indicator, getWeekdayNames, indicatorConverter, normalizeDateTime } from './utils.js'; declare global { // These are too new to be in every TypeScript version yet @@ -83,9 +83,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal The scroller element. */ @query('.scroller') scroller?: HTMLElement; - // /** @internal The scroller element. */ - // @query('.scroll-wrapper') scrollWrapper?: HTMLElement; - /** The selected date. */ @property({ converter: dateConverter }) selected?: Date; @@ -98,26 +95,9 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { * and 'label' that is used to improve accessibility (added as a tooltip). */ @property({ attribute: 'indicator', - converter: { - toAttribute: (value?: Indicator[]) => - value - ? JSON.stringify( - value.map(i => ({ - ...i, - date: dateConverter.toAttribute?.(i.date) - })) - ) - : undefined, - fromAttribute: (value: string | null, _type?: unknown) => - value - ? (JSON.parse(value) as Array<{ date: string; color?: IndicatorColor }>).map(i => ({ - ...i, - date: dateConverter.fromAttribute?.(i.date) - })) - : undefined - } + converter: indicatorConverter }) - indicator?: Indicator[]; // TODO: maybe sth like dateConverter is needed here? + indicator?: Indicator[]; /** @internal Emits when the user selects a day. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; diff --git a/packages/components/calendar/src/utils.ts b/packages/components/calendar/src/utils.ts index f6b09bf414..a0290e1007 100644 --- a/packages/components/calendar/src/utils.ts +++ b/packages/components/calendar/src/utils.ts @@ -1,3 +1,5 @@ +import { dateConverter } from '@sl-design-system/shared/converters.js'; + export interface Day { ariaCurrent?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'; ariaPressed?: 'true' | 'false' | 'mixed'; @@ -44,6 +46,10 @@ export interface Calendar { weeks: Week[]; } +export type IndicatorColor = 'blue' | 'red' | 'yellow' | 'green' | 'grey'; + +export type Indicator = { date: Date; color?: IndicatorColor; label?: string }; + const weekdayNamesCache: Record = {}; /** @@ -285,3 +291,22 @@ export function createDay( weekOrder }; } + +export const indicatorConverter = { + fromAttribute: (value: string | null) => + value + ? (JSON.parse(value) as Array<{ date: string; color?: IndicatorColor; label?: string }>).map(i => ({ + ...i, + date: dateConverter.fromAttribute?.(i.date) + })) + : undefined, + toAttribute: (value?: Indicator[]) => + value + ? JSON.stringify( + value.map(i => ({ + ...i, + date: dateConverter.toAttribute?.(i.date) + })) + ) + : undefined +}; From 428f95a5a72fb7496b5de57827914c7641c0e6f6 Mon Sep 17 00:00:00 2001 From: anna-lach Date: Fri, 17 Oct 2025 14:41:59 +0200 Subject: [PATCH 058/126] alphabetical order --- .../calendar/src/calendar.stories.ts | 121 +++++++++--------- packages/components/calendar/src/calendar.ts | 37 +++--- .../components/calendar/src/month-view.scss | 13 +- .../calendar/src/month-view.stories.ts | 32 ++++- .../components/calendar/src/month-view.ts | 61 ++++----- .../components/calendar/src/select-day.scss | 22 +--- .../components/calendar/src/select-day.ts | 36 +++--- .../calendar/src/select-month.stories.ts | 10 +- .../components/calendar/src/select-month.ts | 102 +++++++-------- .../calendar/src/select-year.stories.ts | 14 +- .../components/calendar/src/select-year.ts | 86 ++++++------- 11 files changed, 262 insertions(+), 272 deletions(-) diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index cbd72835fa..d5b72088a0 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -11,16 +11,16 @@ type Props = Pick< Calendar, | 'disabled' | 'firstDayOfWeek' - | 'locale' | 'indicator' + | 'locale' | 'max' | 'min' | 'month' + | 'negative' | 'readonly' | 'selected' | 'showToday' | 'showWeekNumbers' - | 'negative' >; type Story = StoryObj; @@ -40,6 +40,10 @@ export default { firstDayOfWeek: { control: 'number' }, + indicator: { + control: { type: 'object' }, + description: 'Array of objects: {date: Date, color: string, label?: string}' + }, locale: { control: 'inline-radio', options: ['de', 'en-GB', 'es', 'fi', 'fr', 'it', 'nl', 'nl-BE', 'no', 'pl', 'sv'] @@ -53,15 +57,11 @@ export default { month: { control: 'date' }, - selected: { - control: 'date' - }, negative: { control: 'date' }, - indicator: { - control: { type: 'object' }, - description: 'Array of objects: {date: Date, color: string, label?: string}' + selected: { + control: 'date' } }, render: ({ @@ -99,12 +99,6 @@ export default { ?show-week-numbers=${showWeekNumbers} disabled=${ifDefined(disabled?.map(date => date.toISOString()).join(','))} first-day-of-week=${ifDefined(firstDayOfWeek)} - locale=${ifDefined(locale)} - max=${ifDefined(parseDate(max)?.toISOString())} - min=${ifDefined(parseDate(min)?.toISOString())} - month=${ifDefined(parseDate(month)?.toISOString())} - selected=${ifDefined(parseDate(selected)?.toISOString())} - negative=${ifDefined(negative?.map(date => date.toISOString()).join(','))} indicator=${ifDefined( Array.isArray(indicator) ? JSON.stringify( @@ -118,14 +112,18 @@ export default { ) : undefined )} + locale=${ifDefined(locale)} + max=${ifDefined(parseDate(max)?.toISOString())} + min=${ifDefined(parseDate(min)?.toISOString())} + month=${ifDefined(parseDate(month)?.toISOString())} + negative=${ifDefined(negative?.map(date => date.toISOString()).join(','))} + selected=${ifDefined(parseDate(selected)?.toISOString())} > `; } } satisfies Meta; -// [{date: new Date(), color: ''}, {date: new Date(), color: ''}, {date: new Date(), color: ''}] - -const INDICATOR_LABELS: Record = { +const indicatorLabels: Record = { red: { label: 'Exam — Important' }, @@ -157,9 +155,9 @@ export const FirstDayOfWeek: Story = { export const MinMax: Story = { args: { - month: new Date(2025, 0, 1), max: new Date(2025, 0, 20), - min: new Date(2025, 0, 10) + min: new Date(2025, 0, 10), + month: new Date(2025, 0, 1) } }; @@ -171,40 +169,39 @@ export const Readonly: Story = { export const Selected: Story = { args: { + month: new Date(1755640800000), selected: new Date(1755640800000), - showToday: true, - month: new Date(1755640800000) + showToday: true } }; export const Negative: Story = { args: { + month: new Date(1755640800000), negative: [new Date(), new Date('2025-08-07')], - showToday: true, - month: new Date(1755640800000) + showToday: true } }; export const WithIndicator: Story = { args: { indicator: [ - { date: new Date(), color: 'red', label: INDICATOR_LABELS.red.label }, - { date: new Date('2025-09-05'), color: 'blue' as IndicatorColor, label: INDICATOR_LABELS.blue.label }, - { date: new Date('2025-09-24'), label: INDICATOR_LABELS.default.label }, - { date: new Date('2025-09-09'), color: 'green' as IndicatorColor, label: INDICATOR_LABELS.green.label }, - { date: new Date('2025-09-11'), color: 'grey' as IndicatorColor, label: INDICATOR_LABELS.grey.label }, - { date: new Date('2025-09-12'), color: 'yellow' as IndicatorColor, label: INDICATOR_LABELS.yellow.label }, - { date: new Date('2025-09-18'), color: 'red', label: INDICATOR_LABELS.red.label } + { date: new Date(), color: 'red', label: indicatorLabels.red.label }, + { date: new Date('2025-09-05'), color: 'blue' as IndicatorColor, label: indicatorLabels.blue.label }, + { date: new Date('2025-09-24'), label: indicatorLabels.default.label }, + { date: new Date('2025-09-09'), color: 'green' as IndicatorColor, label: indicatorLabels.green.label }, + { date: new Date('2025-09-11'), color: 'grey' as IndicatorColor, label: indicatorLabels.grey.label }, + { date: new Date('2025-09-12'), color: 'yellow' as IndicatorColor, label: indicatorLabels.yellow.label }, + { date: new Date('2025-09-18'), color: 'red', label: indicatorLabels.red.label } ], - showToday: true, - month: new Date('2025-09-01') //new Date(1755640800000) + month: new Date('2025-09-01'), + showToday: true } -}; // indicator: [{new Date()}, {new Date('2025-08-05')}, {new Date('2025-10-05')}], +}; export const DisabledDays: Story = { args: { disabled: [new Date('2025-10-06'), new Date('2025-10-07'), new Date('2025-10-10')], - // showToday: true, max: new Date(2025, 10, 20), min: new Date(2025, 9, 4), month: new Date(2025, 9, 20) //new Date(1755640800000) @@ -213,8 +210,8 @@ export const DisabledDays: Story = { export const Today: Story = { args: { - showToday: true, - month: undefined + month: undefined, + showToday: true } }; @@ -243,12 +240,6 @@ export const All: Story = { return html` date.toISOString()).join(','))} indicator=${ifDefined( Array.isArray(settings.indicator) ? JSON.stringify( @@ -262,48 +253,54 @@ export const All: Story = { ) : undefined )} + max=${ifDefined(parseDate(settings.max)?.toISOString())} + min=${ifDefined(parseDate(settings.min)?.toISOString())} + month=${ifDefined(parseDate(settings.month)?.toISOString())} + negative=${ifDefined(settings.negative?.map(date => date.toISOString()).join(','))} + selected=${ifDefined(parseDate(settings.selected)?.toISOString())} + show-week-numbers="true" > `; }; const monthEndDate = new Date(); const monthEnd = { - negative: [getOffsetDate(2, monthEndDate)], indicator: [ - { date: getOffsetDate(3, monthEndDate), color: 'red' as IndicatorColor, label: INDICATOR_LABELS.red.label } + { date: getOffsetDate(3, monthEndDate), color: 'red' as IndicatorColor, label: indicatorLabels.red.label } ], - selected: getOffsetDate(4, monthEndDate), - showToday: false, - month: monthEndDate, max: getOffsetDate(5, monthEndDate), - min: getOffsetDate(-5, monthEndDate) + min: getOffsetDate(-5, monthEndDate), + month: monthEndDate, + negative: [getOffsetDate(2, monthEndDate)], + selected: getOffsetDate(4, monthEndDate), + showToday: false }; const indicator = { indicator: [ - { date: getOffsetDate(0), color: 'red' as IndicatorColor, label: INDICATOR_LABELS.red.label }, - { date: getOffsetDate(1), color: 'blue' as IndicatorColor, label: INDICATOR_LABELS.blue.label }, - { date: getOffsetDate(2), color: 'yellow' as IndicatorColor, label: INDICATOR_LABELS.yellow.label }, - { date: getOffsetDate(3), color: 'grey' as IndicatorColor, label: INDICATOR_LABELS.grey.label }, - { date: getOffsetDate(5), color: 'green' as IndicatorColor, label: INDICATOR_LABELS.green.label }, - { date: getOffsetDate(8), color: 'green' as IndicatorColor, label: INDICATOR_LABELS.green.label } + { date: getOffsetDate(0), color: 'red' as IndicatorColor, label: indicatorLabels.red.label }, + { date: getOffsetDate(1), color: 'blue' as IndicatorColor, label: indicatorLabels.blue.label }, + { date: getOffsetDate(2), color: 'yellow' as IndicatorColor, label: indicatorLabels.yellow.label }, + { date: getOffsetDate(3), color: 'grey' as IndicatorColor, label: indicatorLabels.grey.label }, + { date: getOffsetDate(5), color: 'green' as IndicatorColor, label: indicatorLabels.green.label }, + { date: getOffsetDate(8), color: 'green' as IndicatorColor, label: indicatorLabels.green.label } ], // make sure one is outside the min/max range - selected: getOffsetDate(1), - showToday: true, - month: new Date(), max: getOffsetDate(5), - min: getOffsetDate(-5) + min: getOffsetDate(-5), + month: new Date(), + selected: getOffsetDate(1), + showToday: true }; const indicatorToday = { ...indicator, selected: getOffsetDate(0) }; const negative = { + max: getOffsetDate(5), + min: getOffsetDate(-5), + month: new Date(), negative: [getOffsetDate(0), getOffsetDate(1), getOffsetDate(6)], // make sure one it outside the min/max range selected: getOffsetDate(1), - showToday: true, - month: new Date(), - max: getOffsetDate(5), - min: getOffsetDate(-5) + showToday: true }; const negativeToday = { diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index e09feb3df2..e79e026af8 100644 --- a/packages/components/calendar/src/calendar.ts +++ b/packages/components/calendar/src/calendar.ts @@ -30,14 +30,19 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { }; } - #previousMode: 'day' | 'month' | 'year' = 'day'; - /** @internal */ static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true }; /** @internal */ static override styles: CSSResultGroup = styles; + /** + * Tracks the previously active calendar mode (`'day' | 'month' | 'year'`) so the + * component can restore the correct view when closing or switching between + * month and year views. + */ + #previousMode: 'day' | 'month' | 'year' = 'day'; + /** @internal Emits when the value changes. */ @event({ name: 'sl-change' }) changeEvent!: EventEmitter>; @@ -47,6 +52,16 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The first day of the week; 0 for Sunday, 1 for Monday. */ @property({ type: Number, attribute: 'first-day-of-week' }) firstDayOfWeek?: number; + /** + * The list of dates that should display an indicator. + * Each item is an Indicator with a `date`, an optional `color` + * and 'label' that is used to improve accessibility (added as a tooltip). */ + @property({ + attribute: 'indicator', + converter: indicatorConverter + }) + indicator?: Indicator[]; + /** * The maximum date selectable in the calendar. * @default undefined @@ -65,25 +80,15 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The month that the calendar opens on. */ @property({ converter: dateConverter }) month?: Date; + /** The list of dates that should have 'negative' styling. */ + @property({ converter: dateListConverter }) negative?: Date[]; + /** Will disable the ability to select a date when set. */ @property({ type: Boolean }) readonly?: boolean; /** The selected date. */ @property({ converter: dateConverter }) selected?: Date; - /** The list of dates that should have 'negative' styling. */ - @property({ converter: dateListConverter }) negative?: Date[]; - - /** - * The list of dates that should display an indicator. - * Each item is an Indicator with a `date`, an optional `color` - * and 'label' that is used to improve accessibility (added as a tooltip). */ - @property({ - attribute: 'indicator', - converter: indicatorConverter - }) - indicator?: Indicator[]; - /** Highlights today's date when set. */ @property({ type: Boolean, attribute: 'show-today' }) showToday?: boolean; @@ -169,8 +174,8 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { `; } + /** Close month/year view when clicking outside the component */ #onPointerDown = (event: PointerEvent): void => { - // Close month/year view when clicking outside the component if (this.mode === 'day') { return; } diff --git a/packages/components/calendar/src/month-view.scss b/packages/components/calendar/src/month-view.scss index 1e41b1a56d..8888c1d177 100644 --- a/packages/components/calendar/src/month-view.scss +++ b/packages/components/calendar/src/month-view.scss @@ -44,10 +44,7 @@ button { [part~='day'] { --_bg-color: transparent; - --_bg-mix-color: var( - --sl-color-background-neutral-interactive-bold - ); // var(--sl-color-background-info-interactive-plain); - + --_bg-mix-color: var(--sl-color-background-neutral-interactive-bold); --_bg-opacity: var(--sl-opacity-interactive-plain-idle); align-items: center; @@ -137,19 +134,19 @@ button { } [part~='indicator-green']:not([part~='unselectable'])::after { - background: var(--sl-color-background-accent-green-bold); // TODO: change.. + background: var(--sl-color-background-accent-green-bold); } [part~='indicator-grey']:not([part~='unselectable'])::after { - background: var(--sl-color-background-accent-grey-bold); // TODO: change.. + background: var(--sl-color-background-accent-grey-bold); } [part~='indicator-red']:not([part~='unselectable'])::after { - background: var(--sl-color-background-accent-red-bold); // TODO: change.. + background: var(--sl-color-background-accent-red-bold); } [part~='indicator-yellow']:not([part~='unselectable'])::after { - background: var(--sl-color-background-accent-yellow-bold); // TODO: change.. + background: var(--sl-color-background-accent-yellow-bold); } [part~='week-day'], diff --git a/packages/components/calendar/src/month-view.stories.ts b/packages/components/calendar/src/month-view.stories.ts index 4fd62c615e..d0ecdb3d98 100644 --- a/packages/components/calendar/src/month-view.stories.ts +++ b/packages/components/calendar/src/month-view.stories.ts @@ -17,6 +17,7 @@ type Props = Pick< | 'max' | 'min' | 'month' + | 'negative' | 'readonly' | 'renderer' | 'selected' @@ -60,6 +61,9 @@ export default { month: { control: 'date' }, + negative: { + control: 'date' + }, renderer: { table: { disable: true } }, @@ -75,10 +79,11 @@ export default { firstDayOfWeek, hideDaysOtherMonths, indicator, + locale, max, min, month, - locale, + negative, readonly, renderer, selected, @@ -99,6 +104,8 @@ export default { ?show-today=${showToday} ?show-week-numbers=${showWeekNumbers} .disabled=${ifDefined(disabled?.map(date => date.toISOString()).join(','))} + .negative=${ifDefined(negative?.map(date => date.toISOString()).join(','))} + .renderer=${renderer} first-day-of-week=${ifDefined(firstDayOfWeek)} indicator=${ifDefined( Array.isArray(indicator) @@ -117,7 +124,6 @@ export default { min=${ifDefined(min?.toISOString())} month=${ifDefined(month?.toISOString())} selected=${ifDefined(selected?.toISOString())} - .renderer=${renderer} > ` } satisfies Meta; @@ -138,9 +144,9 @@ export const HideDaysOtherMonths: Story = { export const MinMax: Story = { args: { - month: new Date(2025, 0, 1), max: new Date(2025, 0, 20), - min: new Date(2025, 0, 10) + min: new Date(2025, 0, 10), + month: new Date(2025, 0, 1) } }; @@ -194,6 +200,20 @@ export const Selected: Story = { } }; +export const Negative: Story = { + args: { + month: new Date(2024, 11, 10), + negative: [ + new Date(2024, 11, 4), + new Date(2024, 11, 8), + new Date(2024, 11, 15), + new Date(2024, 11, 22), + new Date(2024, 11, 29), + new Date(2024, 11, 30) + ] + } +}; + export const Today: Story = { args: { showToday: true @@ -212,8 +232,8 @@ export const Indicator: Story = { { date: new Date('2025-08-22'), color: 'green' }, { date: new Date('2025-08-27'), color: 'yellow' } ], - showToday: true, - month: new Date(1755640800000) + month: new Date(1755640800000), + showToday: true } }; diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index 6518ebce6a..cbb95a5438 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -90,6 +90,19 @@ export class MonthView extends LocaleMixin(LitElement) { */ @property({ type: Boolean, attribute: 'hide-days-other-months' }) hideDaysOtherMonths?: boolean; + /** + * The list of dates that should display an indicator. + * Each item is an Indicator with a `date`, an optional `color` + * and 'label' that is used to improve accessibility (added as a tooltip). */ + @property({ + attribute: 'indicator', + converter: indicatorConverter + }) + indicator?: Indicator[]; + + // eslint-disable-next-line lit/no-native-attributes + @property({ type: Boolean }) override inert = false; + /** @internal The localized "week of year" label. */ @state() localizedWeekOfYear?: string; @@ -108,6 +121,9 @@ export class MonthView extends LocaleMixin(LitElement) { /** The current month to display. */ @property({ converter: dateConverter }) month?: Date; + /** The list of dates that should have 'negative' styling. */ + @property({ converter: dateConverter }) negative?: Date[]; + /** * If set, will not render buttons for each day. * @default false @@ -123,22 +139,6 @@ export class MonthView extends LocaleMixin(LitElement) { /** The selected date. */ @property({ converter: dateConverter }) selected?: Date; - /** The list of dates that should have 'negative' styling. */ - @property({ converter: dateConverter }) negative?: Date[]; - - /** - * The list of dates that should display an indicator. - * Each item is an Indicator with a `date`, an optional `color` - * and 'label' that is used to improve accessibility (added as a tooltip). */ - @property({ - attribute: 'indicator', - converter: indicatorConverter - }) - indicator?: Indicator[]; - - // eslint-disable-next-line lit/no-native-attributes - @property({ type: Boolean }) override inert = false; - /** * Highlights today's date when set. * @default false @@ -154,7 +154,8 @@ export class MonthView extends LocaleMixin(LitElement) { /** @internal The translated days of the week. */ @state() weekDays: Array<{ long: string; short: string }> = []; - @state() private tooltipsRendered = false; + /** @internal Whether per day indicator tooltips are rendered into the DOM. */ + @state() tooltipsRendered = false; override willUpdate(changes: PropertyValues): void { if (changes.has('firstDayOfWeek') || changes.has('locale')) { @@ -197,23 +198,23 @@ export class MonthView extends LocaleMixin(LitElement) { override render(): TemplateResult { return html` ${this.renderHeader()} ${this.calendar?.weeks.map( week => html` - + ${this.showWeekNumbers ? html` @@ -236,7 +237,7 @@ export class MonthView extends LocaleMixin(LitElement) { ${this.showWeekNumbers ? html` - ` @@ -262,23 +263,14 @@ export class MonthView extends LocaleMixin(LitElement) { let ariaLabel = `${day.date.getDate()}, ${format(day.date, this.locale, { weekday: 'long' })} ${format(day.date, this.locale, { month: 'long', year: 'numeric' })}`; - // Append negative state to aria label if applicable - const isNegative = - this.negative && - isDateInList( - day.date, - // this.negative is Date[]; ensure we map correctly - this.negative.map(d => d) - ); + const isNegative = isDateInList(day.date, this.negative ?? []); if (isNegative) { ariaLabel += `, ${msg('Unavailable', { id: 'sl.calendar.unavailable' })}`; } - // Collect indicators for this date const indicators = (this.indicator ?? []).filter(i => isSameDate(i.date, day.date)); - // Build accessible indicator description(s). Prefer semantic label; fall back to a generic localized label. const indicatorDescriptions = indicators.map(i => i.label ? i.label @@ -287,7 +279,6 @@ export class MonthView extends LocaleMixin(LitElement) { : msg('Indicator', { id: 'sl.calendar.indicator' }) ); - // If there are indicators, create a sanitized id and add aria-describedby const describedById = indicatorDescriptions.length > 0 ? `sl-calendar-indicator-${day.date.toISOString().replace(/[^a-z0-9_-]/gi, '-')}` @@ -296,7 +287,7 @@ export class MonthView extends LocaleMixin(LitElement) { template = this.readonly || day.unselectable || day.disabled || isDateInList(day.date, this.disabled) ? html` - ` @@ -316,10 +307,10 @@ export class MonthView extends LocaleMixin(LitElement) { return html` diff --git a/packages/components/calendar/src/select-day.scss b/packages/components/calendar/src/select-day.scss index c485b53ef4..c40a53dbe9 100644 --- a/packages/components/calendar/src/select-day.scss +++ b/packages/components/calendar/src/select-day.scss @@ -5,14 +5,14 @@ :host([show-week-numbers]) { .days-of-week { - grid-template-columns: var(--sl-size-600) repeat(7, var(--sl-size-450)); // repeat(8, var(--sl-size-450)); + grid-template-columns: var(--sl-size-600) repeat(7, var(--sl-size-450)); padding: var(--sl-size-050) var(--sl-size-075) 0 0; } .scroller { max-inline-size: calc( var(--sl-size-600) + var(--sl-size-borderWidth-subtle) + 7 * var(--sl-size-450) + var(--sl-size-100) - ); // calc(8 * var(--sl-size-450) + var(--sl-size-100)); + ); } } @@ -27,10 +27,6 @@ .current-month, .current-year { font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); - - // sl-icon { - // color: var(--sl-color-foreground-subtlest); - // } } .current-year { @@ -42,22 +38,12 @@ gap: var(--sl-size-100); } -// .previous-month { -// margin-inline-start: auto; -// } - -// .next-month { -// margin-inline-start: var(--sl-size-100) auto; -// } - .days-of-week { color: var(--sl-color-foreground-disabled); display: grid; font-weight: normal; grid-template-columns: repeat(7, var(--sl-size-450)); padding: var(--sl-size-050) var(--sl-size-075); - - // padding-inline: var(--sl-size-100); } .day-of-week, @@ -67,8 +53,6 @@ .week-number { border-inline-end: var(--sl-size-borderWidth-subtle) solid var(--sl-color-border-neutral-plain); - - // inline-size: calc(var(--sl-size-450) - var(--sl-size-borderWidth-subtle)); } .scroller { @@ -78,8 +62,6 @@ max-inline-size: calc(7 * var(--sl-size-450) + var(--sl-size-100)); outline: none; overflow: scroll hidden; - - // padding-block: var(--sl-size-050); scroll-snap-type: x mandatory; scrollbar-width: none; } diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index c90355b853..116d582537 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -50,6 +50,19 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The first day of the week; 0 for Sunday, 1 for Monday. */ @property({ type: Number, attribute: 'first-day-of-week' }) firstDayOfWeek = 1; + /** + * The list of dates that should display an indicator. + * Each item is an Indicator with a `date`, an optional `color` + * and 'label' that is used to improve accessibility (added as a tooltip). */ + @property({ + attribute: 'indicator', + converter: indicatorConverter + }) + indicator?: Indicator[]; + + // eslint-disable-next-line lit/no-native-attributes + @property({ type: Boolean }) override inert = false; + /** @internal The localized "week of year" label. */ @state() localizedWeekOfYear?: string; @@ -71,6 +84,9 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal The month-view element. */ @query('sl-month-view') monthView?: HTMLElement; + /** The list of dates that should have 'negative' styling. */ + @property({ converter: dateConverter }) negative?: Date[]; + /** @internal The next month in the calendar. */ @state() nextMonth?: Date; @@ -86,19 +102,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** The selected date. */ @property({ converter: dateConverter }) selected?: Date; - /** The list of dates that should have 'negative' styling. */ - @property({ converter: dateConverter }) negative?: Date[]; - - /** - * The list of dates that should display an indicator. - * Each item is an Indicator with a `date`, an optional `color` - * and 'label' that is used to improve accessibility (added as a tooltip). */ - @property({ - attribute: 'indicator', - converter: indicatorConverter - }) - indicator?: Indicator[]; - /** @internal Emits when the user selects a day. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; @@ -114,9 +117,6 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal The translated days of the week. */ @state() weekDays: Array<{ long: string; short: string }> = []; - // eslint-disable-next-line lit/no-native-attributes - @property({ type: Boolean }) override inert = false; - observer?: IntersectionObserver; override disconnectedCallback(): void { @@ -385,9 +385,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } #scrollToMonth(month: -1 | 0 | 1, smooth = false): void { - // Prefer scroller width (viewport of observed root). Fall back to host computed width. - const hostWidth = parseInt(getComputedStyle(this).width) || 0; - const width = hostWidth; + const width = parseInt(getComputedStyle(this).width) || 0; const left = width * month + width; this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); diff --git a/packages/components/calendar/src/select-month.stories.ts b/packages/components/calendar/src/select-month.stories.ts index f995e2706b..b74938b1d3 100644 --- a/packages/components/calendar/src/select-month.stories.ts +++ b/packages/components/calendar/src/select-month.stories.ts @@ -20,15 +20,15 @@ export default { showToday: true }, argTypes: { - month: { - control: 'date' - }, max: { control: 'date' }, min: { control: 'date' }, + month: { + control: 'date' + }, showToday: { control: 'boolean' }, @@ -36,7 +36,7 @@ export default { table: { disable: true } } }, - render: ({ month, max, min, styles, showToday }) => { + render: ({ max, min, month, styles, showToday }) => { const [_, updateArgs] = useArgs(); const parseDate = (value: string | Date | undefined): Date | undefined => { if (!value) { @@ -60,9 +60,9 @@ export default { : nothing} `; diff --git a/packages/components/calendar/src/select-month.ts b/packages/components/calendar/src/select-month.ts index 6843e9b446..d4bb631a39 100644 --- a/packages/components/calendar/src/select-month.ts +++ b/packages/components/calendar/src/select-month.ts @@ -69,12 +69,6 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { listenerScope: (): HTMLElement => this.renderRoot.querySelector('table.months')! }); - /** The month/year to display. */ - @property({ converter: dateConverter }) month = new Date(); - - /** The currently selected date. (In order to style current month) */ - @property({ converter: dateConverter }) selected?: Date; - /** * The maximum date selectable in the month. * @default undefined @@ -87,21 +81,27 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) { */ @property({ converter: dateConverter }) min?: Date; + /** The month/year to display. */ + @property({ converter: dateConverter }) month = new Date(); + + /** @internal The months to display. */ + @state() months: Month[] = []; + + /** The currently selected date. (In order to style current month) */ + @property({ converter: dateConverter }) selected?: Date; + + /** @internal Emits when the user selects a month. */ + @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; + /** * Highlights the current month when set. * @default false */ @property({ type: Boolean, attribute: 'show-today' }) showToday?: boolean; - /** @internal The months to display. */ - @state() months: Month[] = []; - /** @internal Emits when the user clicks the month/year button. */ @event({ name: 'sl-toggle' }) toggleEvent!: EventEmitter>; - /** @internal Emits when the user selects a month. */ - @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; - override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); @@ -159,49 +159,49 @@ export class SelectMonth extends LocaleMixin(ScopedElementsMixin(LitElement)) {
    ${week.number}
    + ${this.localizedWeekOfYear} this.#onClick(event, day)} aria-selected=${isSelected ? 'true' : 'false'} data-date=${day.date.toISOString()} + role="gridcell" > ${template}
    ${monthRows.map( (row, rowIndex) => html` - + ${row.map((month, colIndex) => { const parts = this.getMonthParts(month).join(' '); return html`