diff --git a/.changeset/all-dryers-talk.md b/.changeset/all-dryers-talk.md new file mode 100644 index 0000000000..606782d05f --- /dev/null +++ b/.changeset/all-dryers-talk.md @@ -0,0 +1,9 @@ +--- +'@sl-design-system/shared': minor +--- + +New utilities: +- `dateListConverter` added +- `NewFocusGroupController` added + +Both of these are currently only in use within the `calendar` package, but are being added to `@sl-design-system/shared` for potential reuse in other packages in the future. diff --git a/.changeset/fast-months-smash.md b/.changeset/fast-months-smash.md new file mode 100644 index 0000000000..5c0f7844ba --- /dev/null +++ b/.changeset/fast-months-smash.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/calendar': patch +--- + +New version of the calendar component. Improved styling and accessibility. \ No newline at end of file diff --git a/.changeset/nine-shrimps-poke.md b/.changeset/nine-shrimps-poke.md new file mode 100644 index 0000000000..a75ca4c2a4 --- /dev/null +++ b/.changeset/nine-shrimps-poke.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/date-field': patch +--- + +Make a date-field working with dates (not dates and hours). diff --git a/.github/agents/testing-specialist.agent.md b/.github/agents/testing-specialist.agent.md index edebf1e94d..efc7020a85 100644 --- a/.github/agents/testing-specialist.agent.md +++ b/.github/agents/testing-specialist.agent.md @@ -150,6 +150,21 @@ it('should emit a click event when clicked', () => { ... }); it('should be disabled when disabled prop is set', () => { ... }); ``` +**Important**: Do not use "by default" in test descriptions within the `defaults` describe block, as this is redundant: +```typescript +// ✅ Good +describe('defaults', () => { + it('should not be disabled', () => { ... }); + it('should not show week numbers', () => { ... }); +}); + +// ❌ Bad +describe('defaults', () => { + it('should not be disabled by default', () => { ... }); + it('should not show week numbers by default', () => { ... }); +}); +``` + ## What to Test ### Essential Tests diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 83de334335..bcfab3dcac 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -36,6 +36,22 @@ All components are built using Lit and TypeScript. All components follow the fol - Public properties should come before public properties - Public methods should come before private methods +### Code style + +When declaring multiple constants, use a single multiline `const` statement instead of multiple separate statements: + +```typescript +// ✅ Good +const foo = 'bar', + baz = 'qux', + another = 'value'; + +// ❌ Bad +const foo = 'bar'; +const baz = 'qux'; +const another = 'value'; +``` + ## Tools To build the components, use the following command from the root of the repository: diff --git a/.storybook/preview.ts b/.storybook/preview.ts index b574746898..df9571e129 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,12 +1,14 @@ +import '@af-utils/scrollend-polyfill'; import '@webcomponents/scoped-custom-element-registry/scoped-custom-element-registry.min.js'; import '@sl-design-system/announcer/register.js'; import { configureLocalization } from '@lit/localize'; import * as locales from '@sl-design-system/locales'; import { type Preview } from '@storybook/web-components-vite'; +import MockDate from 'mockdate'; import { INITIAL_VIEWPORTS } from 'storybook/viewport'; import { updateTheme, themes } from './themes.js'; -import MockDate from 'mockdate'; +// Set a fixed date in non-development environments for consistent Storybook snapshots if (!import.meta.env?.DEV) { MockDate.set('2025-06-01T00:00:00Z'); } diff --git a/chromatic/.storybook/stories/all.stories.ts b/chromatic/.storybook/stories/all.stories.ts index b54ada115e..674eaf75bb 100644 --- a/chromatic/.storybook/stories/all.stories.ts +++ b/chromatic/.storybook/stories/all.stories.ts @@ -4,6 +4,7 @@ import { Colors, Sizes } from '../../../packages/components/avatar/src/avatar.st import { All as AllBadge } from '../../../packages/components/badge/src/badge.stories'; import { All as AllBreadcrumbs } from '../../../packages/components/breadcrumbs/src/breadcrumbs.stories'; import { All as AllButton } from '../../../packages/components/button/src/button.stories'; +import { All as AllCalendar } from '../../../packages/components/calendar/src/calendar.stories'; import { All as AllCallout } from '../../../packages/components/callout/src/callout.stories'; import { All as AllCard } from '../../../packages/components/card/src/card.stories'; import { All as AllCheckbox } from '../../../packages/components/checkbox/src/root.stories'; @@ -13,6 +14,7 @@ import { All as AllIcon } from '../../../packages/components/icon/src/icon.stori import { All as AllInlineMessage } from '../../../packages/components/inline-message/src/inline-message.stories'; import { All as AllMenu } from '../../../packages/components/menu/src/menu.stories'; import { All as AllMenuButton } from '../../../packages/components/menu/src/menu-button.stories'; +import { All as AllMonthView } from '../../../packages/components/calendar/src/month-view.stories'; import { All as AllPopover } from '../../../packages/components/popover/src/popover.stories'; import { All as AllRadioGroup } from '../../../packages/components/radio-group/src/radio-group.stories'; import { All as AllSearchField } from '../../../packages/components/search-field/src/search-field.stories'; @@ -70,6 +72,7 @@ export const AvatarSizes = { render: Sizes.render }; export const Badge = { render: AllBadge.render }; export const Breadcrumbs = { render: AllBreadcrumbs.render }; export const Button = { render: AllButton.render }; +export const Calendar = { render: AllCalendar.render }; export const Callout = { render: AllCallout.render }; export const Card = { render: AllCard.render }; export const Checkbox = { render: AllCheckbox.render }; @@ -79,6 +82,7 @@ export const Icon = { render: AllIcon.render }; export const InlineMessage = { render: AllInlineMessage.render }; export const Menu = { render: AllMenu.render }; export const MenuButton = { render: AllMenuButton.render }; +export const MonthView = { render: AllMonthView.render }; export const Popover = { render: AllPopover.render }; export const RadioGroup = { render: AllRadioGroup.render }; export const SearchField = { render: AllSearchField.render }; diff --git a/chromatic/package.json b/chromatic/package.json index e643b4cebf..7affdda5ce 100644 --- a/chromatic/package.json +++ b/chromatic/package.json @@ -40,13 +40,13 @@ } }, "devDependencies": { - "@storybook/addon-a11y": "^9.1.13", - "@storybook/addon-docs": "^9.1.13", - "@storybook/addon-themes": "^9.1.13", - "@storybook/web-components-vite": "^9.1.13", + "@storybook/addon-a11y": "^10.0.7", + "@storybook/addon-docs": "^10.0.7", + "@storybook/addon-themes": "^10.0.7", + "@storybook/web-components-vite": "^10.0.7", "lit": "^3.3.1", - "storybook": "^9.1.13", - "storybook-addon-pseudo-states": "^9.1.13", + "storybook": "^10.0.7", + "storybook-addon-pseudo-states": "^10.0.7", "tslib": "^2.8.1", "typescript": "^5.5.4", "wireit": "^0.14.12" diff --git a/package.json b/package.json index 0d8e3ebf95..c7993556b7 100644 --- a/package.json +++ b/package.json @@ -452,24 +452,27 @@ } }, "devDependencies": { + "@af-utils/scrollend-polyfill": "^0.0.14", "@changesets/cli": "^2.29.7", "@changesets/get-github-info": "^0.6.0", "@custom-elements-manifest/analyzer": "^0.10.10", "@faker-js/faker": "^10.1.0", "@lit/localize-tools": "^0.8.0", - "@storybook/addon-a11y": "^9.1.13", - "@storybook/addon-docs": "^9.1.13", - "@storybook/addon-vitest": "^9.1.13", - "@storybook/web-components": "^9.1.13", - "@storybook/web-components-vite": "^9.1.13", + "@storybook/addon-a11y": "^10.0.7", + "@storybook/addon-docs": "^10.0.7", + "@storybook/addon-vitest": "^10.0.7", + "@storybook/web-components": "^10.0.7", + "@storybook/web-components-vite": "^10.0.7", + "@types/chai-datetime": "^1.0.0", "@types/chai-dom": "^1.11.3", - "@types/sinon": "^17.0.4", + "@types/sinon": "^21.0.0", "@types/sinon-chai": "^4.0.0", - "@vitest/browser": "^3.2.4", - "@vitest/coverage-v8": "^3.2.4", - "@vitest/ui": "^3.2.4", + "@vitest/browser-playwright": "^4.0.9", + "@vitest/coverage-v8": "^4.0.9", + "@vitest/ui": "^4.0.9", "@webcomponents/scoped-custom-element-registry": "^0.0.10", - "chai": "^6.2.0", + "chai": "^6.2.1", + "chai-datetime": "^1.8.1", "chai-dom": "^1.12.1", "chromatic": "^13.3.0", "eslint": "^9.27.0", @@ -480,11 +483,11 @@ "playwright": "^1.56.1", "sinon": "^21.0.0", "sinon-chai": "^4.0.1", - "storybook": "^9.1.13", + "storybook": "^10.0.7", "stylelint": "^16.19.1", "typescript": "^5.5.4", - "vite": "^7.1.11", - "vitest": "^3.2.4", + "vite": "^7.2.2", + "vitest": "^4.0.9", "wireit": "^0.14.12" } } diff --git a/packages/angular/package.json b/packages/angular/package.json index 8d349f1d36..b31420aa64 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -86,7 +86,7 @@ "@angular/platform-browser": "^19.2.14", "@angular/platform-browser-dynamic": "^19.2.14", "@angular/router": "^19.2.14", - "@storybook/angular": "^9.1.13", + "@storybook/angular": "^10.0.7", "@types/jasmine": "~5.1.12", "jasmine-core": "~5.12.0", "karma": "~6.4.4", @@ -96,7 +96,7 @@ "karma-jasmine-html-reporter": "~2.1.0", "ng-packagr": "^19.2.2", "rxjs": "~7.8.2", - "storybook": "^9.1.13", + "storybook": "^10.0.7", "tslib": "^2.8.1", "typescript": "~5.5.4", "wireit": "^0.14.12", diff --git a/packages/components/accordion/src/accordion-item.spec.ts b/packages/components/accordion/src/accordion-item.spec.ts index 848766de66..bd75d78dae 100644 --- a/packages/components/accordion/src/accordion-item.spec.ts +++ b/packages/components/accordion/src/accordion-item.spec.ts @@ -1,7 +1,7 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { AccordionItem } from './accordion-item.js'; diff --git a/packages/components/accordion/src/accordion.spec.ts b/packages/components/accordion/src/accordion.spec.ts index 43a1028931..da1748571f 100644 --- a/packages/components/accordion/src/accordion.spec.ts +++ b/packages/components/accordion/src/accordion.spec.ts @@ -1,7 +1,7 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { Accordion } from './accordion.js'; diff --git a/packages/components/announcer/index.ts b/packages/components/announcer/index.ts index 135c7e0262..4c6e5fa456 100644 --- a/packages/components/announcer/index.ts +++ b/packages/components/announcer/index.ts @@ -1,2 +1,2 @@ -export * from './src/announce.js'; -export * from './src/announcer.js'; +export { announce } from './src/announce.js'; +export { type SlAnnounceEvent, Announcer } from './src/announcer.js'; diff --git a/packages/components/breadcrumbs/src/breadcrumbs.spec.ts b/packages/components/breadcrumbs/src/breadcrumbs.spec.ts index 1daebf7435..083f56daa1 100644 --- a/packages/components/breadcrumbs/src/breadcrumbs.spec.ts +++ b/packages/components/breadcrumbs/src/breadcrumbs.spec.ts @@ -1,7 +1,7 @@ import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { page } from '@vitest/browser/context'; import { html } from 'lit'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; import '../register.js'; import { Breadcrumbs } from './breadcrumbs.js'; diff --git a/packages/components/button/src/button.spec.ts b/packages/components/button/src/button.spec.ts index d504d02614..8fe1dad4d3 100644 --- a/packages/components/button/src/button.spec.ts +++ b/packages/components/button/src/button.spec.ts @@ -1,10 +1,10 @@ import { type Form } from '@sl-design-system/form'; import '@sl-design-system/form/register.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { html } from 'lit'; import { restore, spy, stub } from 'sinon'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { type Button, type ButtonType } from './button.js'; 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.spec.ts b/packages/components/calendar/src/calendar.spec.ts new file mode 100644 index 0000000000..c36a0b50de --- /dev/null +++ b/packages/components/calendar/src/calendar.spec.ts @@ -0,0 +1,584 @@ +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import '../register.js'; +import { Calendar } from './calendar.js'; +import { type MonthView } from './month-view.js'; +import { type SelectDay } from './select-day.js'; +import { type SelectMonth } from './select-month.js'; +import { type SelectYear } from './select-year.js'; + +describe('sl-calendar', () => { + let el: Calendar; + + beforeEach(() => { + // March 2023 + // -------------------- + // Mo Tu We Th Fr Sa Su + // 27 28 1 2 3 4 5 + // 6 7 8 9 10 11 12 + // 13 14 15 16 17 18 19 + // 20 21 22 23 24 25 26 + // 27 28 29 30 31 1 2 + vi.setSystemTime(new Date(2023, 2, 14)); + }); + + afterEach(() => vi.useRealTimers()); + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should use day mode', () => { + expect(el.mode).to.equal('day'); + }); + + it('should render sl-select-day component', () => { + const selectDay = el.renderRoot.querySelector('sl-select-day'); + + expect(selectDay).to.exist; + expect(selectDay).not.to.have.attribute('aria-hidden'); + expect(selectDay).not.to.have.attribute('inert'); + }); + + it('should not render month or year selectors', () => { + const selectMonth = el.renderRoot.querySelector('sl-select-month'), + selectYear = el.renderRoot.querySelector('sl-select-year'); + + expect(selectMonth).not.to.exist; + expect(selectYear).not.to.exist; + }); + + it('should not be readonly', () => { + const selectDay = el.renderRoot.querySelector('sl-select-day'); + + expect(el.readonly).not.to.be.true; + expect(selectDay?.readonly).not.to.be.true; + }); + + it('should be readonly when set', async () => { + el.readonly = true; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay?.readonly).to.be.true; + }); + + it('should not have a selected date', () => { + const selectDay = el.renderRoot.querySelector('sl-select-day'); + + expect(el.selected).to.be.undefined; + expect(selectDay?.selected).to.be.undefined; + }); + + it('should show the selected date when set', async () => { + const selectedDate = new Date(2023, 5, 15); + + el.selected = selectedDate; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay?.selected).to.equalDate(selectedDate); + }); + + it('should set month to selected date when only selected is provided', async () => { + const selectedDate = new Date(2023, 5, 15); + + el.selected = selectedDate; + await el.updateComplete; + + expect(el.month).to.equalDate(selectedDate); + }); + + it('should default month to current date when no selected date', () => { + expect(el.month).to.exist; + expect(el.month?.getMonth()).to.equal(new Date().getMonth()); + expect(el.month?.getFullYear()).to.equal(new Date().getFullYear()); + }); + + it('should not have disabled dates', () => { + expect(el.disabledDates).to.be.undefined; + }); + + it('should pass disabled dates to select-day', async () => { + const disabledDates = [new Date(2023, 5, 10), new Date(2023, 5, 20)]; + + el.disabledDates = disabledDates; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay?.disabledDates).to.have.lengthOf(2); + expect(selectDay?.disabledDates).to.deep.equal(disabledDates); + }); + + it('should not have indicator dates', () => { + expect(el.indicatorDates).to.be.undefined; + }); + + it('should pass indicator dates to select-day', async () => { + const indicatorDates = [ + { date: new Date(2023, 5, 10) }, + { date: new Date(2023, 5, 20), color: 'red' as const, label: 'Holiday' } + ]; + + el.indicatorDates = indicatorDates; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay?.indicatorDates).to.have.lengthOf(2); + expect(selectDay?.indicatorDates).to.deep.equal(indicatorDates); + }); + + it('should not show today', () => { + expect(el.showToday).not.to.be.true; + }); + + it('should pass show-today to select-day', async () => { + el.showToday = true; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay?.showToday).to.be.true; + }); + + it('should not show week numbers', () => { + expect(el.showWeekNumbers).not.to.be.true; + }); + + it('should pass show-week-numbers to select-day', async () => { + el.showWeekNumbers = true; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay?.showWeekNumbers).to.be.true; + }); + + it('should pass firstDayOfWeek to select-day', async () => { + el.firstDayOfWeek = 0; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay).to.have.attribute('first-day-of-week', '0'); + }); + + it('should pass locale to select-day', async () => { + el.locale = 'nl'; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay).to.have.attribute('locale', 'nl'); + }); + + it('should pass min date to select-day', async () => { + const minDate = new Date(2023, 0, 1); + + el.min = minDate; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay).to.have.attribute('min', minDate.toISOString()); + }); + + it('should pass max date to select-day', async () => { + const maxDate = new Date(2023, 11, 31); + + el.max = maxDate; + await el.updateComplete; + + const selectDay = el.renderRoot.querySelector('sl-select-day'); + expect(selectDay).to.have.attribute('max', maxDate.toISOString()); + }); + }); + + describe('date selection', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should select a date when a day is clicked', () => { + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('sl-month-view:not([inert])') + ?.renderRoot.querySelector('button[part="day"]') + ?.click(); + + expect(el.selected).to.equalDate(new Date(2023, 2, 1)); + }); + + it('should emit sl-change event when a date is selected', () => { + let callCount = 0, + selectedDate: Date | undefined; + + el.addEventListener('sl-change', (event: SlChangeEvent) => { + callCount++; + selectedDate = event.detail; + }); + + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('sl-month-view:not([inert])') + ?.renderRoot.querySelector('button[part="day"]') + ?.click(); + + expect(callCount).to.equal(1); + expect(selectedDate).to.equalDate(new Date(2023, 2, 1)); + }); + }); + + describe('mode switching', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should switch to month mode when month toggle is clicked', async () => { + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-month') + ?.click(); + await el.updateComplete; + + expect(el.mode).to.equal('month'); + expect(el.renderRoot.querySelector('sl-select-month')).to.exist; + }); + + it('should switch to year mode when year toggle is clicked', async () => { + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-year') + ?.click(); + await el.updateComplete; + + expect(el.mode).to.equal('year'); + expect(el.renderRoot.querySelector('sl-select-year')).to.exist; + }); + + it('should hide select-day when in month mode', async () => { + const selectDay = el.renderRoot.querySelector('sl-select-day'); + + selectDay?.renderRoot.querySelector('.current-month')?.click(); + await el.updateComplete; + + expect(selectDay).to.have.attribute('aria-hidden', 'true'); + expect(selectDay).to.have.attribute('inert'); + }); + + it('should hide select-day when in year mode', async () => { + const selectDay = el.renderRoot.querySelector('sl-select-day'); + + selectDay?.renderRoot.querySelector('.current-year')?.click(); + await el.updateComplete; + + expect(selectDay).to.have.attribute('aria-hidden', 'true'); + expect(selectDay).to.have.attribute('inert'); + }); + + it('should return to day mode when a month is selected', async () => { + // Switch to month mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-month') + ?.click(); + await el.updateComplete; + + // Select a month + el.renderRoot + .querySelector('sl-select-month') + ?.renderRoot.querySelector('button') + ?.click(); + await el.updateComplete; + + expect(el.mode).to.equal('day'); + }); + + it('should update month when a month is selected', async () => { + el.month = new Date(2023, 0, 15); + await el.updateComplete; + + // Switch to month mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-month') + ?.click(); + await el.updateComplete; + + // Select June (6th button = index 5) + const monthButtons = Array.from( + el.renderRoot.querySelector('sl-select-month')?.renderRoot.querySelectorAll('button') ?? [] + ); + monthButtons.at(5)?.click(); + await el.updateComplete; + + expect(el.month?.getMonth()).to.equal(5); + expect(el.month?.getDate()).to.equal(15); // Should preserve day + }); + + it('should return to day mode when a year is selected', async () => { + // Switch to year mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-year') + ?.click(); + await el.updateComplete; + + // Select a year + el.renderRoot + .querySelector('sl-select-year') + ?.renderRoot.querySelector('button') + ?.click(); + await el.updateComplete; + + expect(el.mode).to.equal('day'); + }); + + it('should update month year when a year is selected', async () => { + el.month = new Date(2023, 5, 15); + await el.updateComplete; + + // Switch to year mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-year') + ?.click(); + await el.updateComplete; + + // Select 2025 (year range is 2018-2029, so 2025 is at index 7) + const selectYear = el.renderRoot.querySelector('sl-select-year'), + yearButtons = selectYear?.renderRoot.querySelectorAll('button'); + yearButtons?.[7]?.click(); + await el.updateComplete; + + expect(el.month?.getFullYear()).to.equal(2025); + expect(el.month?.getMonth()).to.equal(5); // Should preserve month + expect(el.month?.getDate()).to.equal(15); // Should preserve day + }); + + it('should restore previous mode when returning from year selector', async () => { + // Start in day mode, go to month mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-month') + ?.click(); + await el.updateComplete; + + // From month mode, go to year mode + el.renderRoot + .querySelector('sl-select-month') + ?.renderRoot.querySelector('.current-year') + ?.click(); + await el.updateComplete; + + expect(el.mode).to.equal('year'); + + // Select a year - should return to month mode + el.renderRoot + .querySelector('sl-select-year') + ?.renderRoot.querySelector('button') + ?.click(); + await el.updateComplete; + + expect(el.mode).to.equal('month'); + }); + + it('should focus select-day after returning from month selector', async () => { + // Switch to month mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-month') + ?.click(); + await el.updateComplete; + + // Select a month + el.renderRoot + .querySelector('sl-select-month') + ?.renderRoot.querySelector('button') + ?.click(); + await el.updateComplete; + + // Wait for requestAnimationFrame + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.shadowRoot?.activeElement).to.match('sl-select-day'); + }); + + it('should focus select-day after returning from year selector', async () => { + // Switch to year mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-year') + ?.click(); + await el.updateComplete; + + // Select a year + el.renderRoot + .querySelector('sl-select-year') + ?.renderRoot.querySelector('button') + ?.click(); + await el.updateComplete; + + // Wait for requestAnimationFrame + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.shadowRoot?.activeElement).to.match('sl-select-day'); + }); + + it('should focus select-month when switching to month mode', async () => { + // Switch to month mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-month') + ?.click(); + await el.updateComplete; + + // Wait for requestAnimationFrame + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.shadowRoot?.activeElement).to.match('sl-select-month'); + }); + + it('should focus select-year when switching to year mode', async () => { + // Switch to year mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-year') + ?.click(); + await el.updateComplete; + + // Wait for requestAnimationFrame + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.shadowRoot?.activeElement).to.match('sl-select-year'); + }); + }); + + describe('month selector properties', () => { + beforeEach(async () => { + el = await fixture(html``); + + // Switch to month mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-month') + ?.click(); + await el.updateComplete; + }); + + it('should pass show-today to select-month', async () => { + el.showToday = true; + await el.updateComplete; + + const selectMonth = el.renderRoot.querySelector('sl-select-month'); + expect(selectMonth).to.have.attribute('show-today'); + }); + + it('should pass selected date to select-month', async () => { + const testDate = new Date(2023, 5, 15); + + el.selected = testDate; + await el.updateComplete; + + const selectMonth = el.renderRoot.querySelector('sl-select-month'); + expect(selectMonth?.selected).to.equalDate(testDate); + }); + + it('should pass month to select-month', async () => { + const testMonth = new Date(2023, 5, 15); + + el.month = testMonth; + await el.updateComplete; + + const selectMonth = el.renderRoot.querySelector('sl-select-month'); + expect(selectMonth?.month).to.equalDate(testMonth); + }); + + it('should pass locale to select-month', async () => { + el.locale = 'nl'; + await el.updateComplete; + + const selectMonth = el.renderRoot.querySelector('sl-select-month'); + expect(selectMonth).to.have.attribute('locale', 'nl'); + }); + + it('should pass min to select-month', async () => { + const minDate = new Date(2023, 0, 1); + + el.min = minDate; + await el.updateComplete; + + const selectMonth = el.renderRoot.querySelector('sl-select-month'); + expect(selectMonth).to.have.attribute('min', minDate.toISOString()); + }); + + it('should pass max to select-month', async () => { + const maxDate = new Date(2023, 11, 31); + + el.max = maxDate; + await el.updateComplete; + + const selectMonth = el.renderRoot.querySelector('sl-select-month'); + expect(selectMonth).to.have.attribute('max', maxDate.toISOString()); + }); + }); + + describe('year selector properties', () => { + beforeEach(async () => { + el = await fixture(html``); + + // Switch to year mode + el.renderRoot + .querySelector('sl-select-day') + ?.renderRoot.querySelector('.current-year') + ?.click(); + await el.updateComplete; + }); + + it('should pass show-today to select-year', async () => { + el.showToday = true; + await el.updateComplete; + + const selectYear = el.renderRoot.querySelector('sl-select-year'); + expect(selectYear).to.have.attribute('show-today'); + }); + + it('should pass selected date to select-year', async () => { + const testDate = new Date(2023, 5, 15); + + el.selected = testDate; + await el.updateComplete; + + const selectYear = el.renderRoot.querySelector('sl-select-year'); + expect(selectYear?.selected).to.equalDate(testDate); + }); + + it('should pass month as year to select-year', async () => { + const testMonth = new Date(2023, 5, 15); + + el.month = testMonth; + await el.updateComplete; + + const selectYear = el.renderRoot.querySelector('sl-select-year'); + expect(selectYear?.year).to.equalDate(testMonth); + }); + + it('should pass min to select-year', async () => { + const minDate = new Date(2023, 0, 1); + + el.min = minDate; + await el.updateComplete; + + const selectYear = el.renderRoot.querySelector('sl-select-year'); + expect(selectYear).to.have.attribute('min', minDate.toISOString()); + }); + + it('should pass max to select-year', async () => { + const maxDate = new Date(2025, 11, 31); + + el.max = maxDate; + await el.updateComplete; + + const selectYear = el.renderRoot.querySelector('sl-select-year'); + expect(selectYear).to.have.attribute('max', maxDate.toISOString()); + }); + }); +}); diff --git a/packages/components/calendar/src/calendar.stories.ts b/packages/components/calendar/src/calendar.stories.ts index a9eaa1a342..2ed4e0a0ef 100644 --- a/packages/components/calendar/src/calendar.stories.ts +++ b/packages/components/calendar/src/calendar.stories.ts @@ -1,12 +1,25 @@ +import '@sl-design-system/format-date/register.js'; import { type Meta, type StoryObj } from '@storybook/web-components-vite'; import { 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'; +import { IndicatorColor } from './utils.js'; type Props = Pick< Calendar, - 'firstDayOfWeek' | 'locale' | 'max' | 'min' | 'month' | 'readonly' | 'selected' | 'showToday' | 'showWeekNumbers' + | 'disabledDates' + | 'firstDayOfWeek' + | 'indicatorDates' + | 'locale' + | 'max' + | 'min' + | 'month' + | 'readonly' + | 'selected' + | 'showToday' + | 'showWeekNumbers' >; type Story = StoryObj; @@ -16,13 +29,19 @@ export default { args: { readonly: false, showToday: false, - showWeekNumbers: false, - month: new Date(2024, 8, 15) + showWeekNumbers: false }, argTypes: { + disabledDates: { + control: 'date' + }, firstDayOfWeek: { control: 'number' }, + indicatorDates: { + 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'] @@ -40,7 +59,20 @@ export default { control: 'date' } }, - render: ({ firstDayOfWeek, locale, max, min, month, readonly, selected, showToday, showWeekNumbers }) => { + render: ({ + disabledDates, + firstDayOfWeek, + indicatorDates, + locale, + max, + min, + month, + readonly, + selected, + showToday, + showWeekNumbers + }) => { + const [_, updateArgs] = useArgs(); const parseDate = (value: string | Date | undefined): Date | undefined => { if (!value) { return undefined; @@ -49,12 +81,31 @@ export default { return value instanceof Date ? value : new Date(value); }; + const onSelectDate = (event: CustomEvent) => { + 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(','))} first-day-of-week=${ifDefined(firstDayOfWeek)} + indicator-dates=${ifDefined( + Array.isArray(indicatorDates) + ? JSON.stringify( + indicatorDates + .filter(item => item?.date) + .map(item => ({ + date: item.date.toISOString(), + ...(item.color ? { color: item.color } : {}), + ...(item.label ? { label: item.label } : {}) + })) + ) + : undefined + )} locale=${ifDefined(locale)} max=${ifDefined(parseDate(max)?.toISOString())} min=${ifDefined(parseDate(min)?.toISOString())} @@ -65,6 +116,28 @@ export default { } } satisfies Meta; +const indicatorLabels: 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 = { @@ -75,9 +148,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) } }; @@ -89,14 +162,41 @@ export const Readonly: Story = { export const Selected: Story = { args: { - selected: new Date(2024, 8, 15) + month: new Date(1755640800000), + selected: new Date(1755640800000), + showToday: true + } +}; + +export const IndicatorDates: Story = { + args: { + indicatorDates: [ + { 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 } + ], + month: new Date('2025-09-01'), + showToday: true + } +}; + +export const DisabledDates: Story = { + args: { + disabledDates: [new Date('2025-10-06'), new Date('2025-10-07'), new Date('2025-10-10')], + max: new Date(2025, 10, 20), + min: new Date(2025, 9, 4), + month: new Date(2025, 9, 20) } }; export const Today: Story = { args: { - showToday: true, - month: undefined + month: undefined, + showToday: true } }; @@ -105,3 +205,99 @@ export const WeekNumbers: Story = { showWeekNumbers: true } }; + +export const All: Story = { + render: () => { + // Mock date in Chromatic is 2025-06-01 + const mockDate = new Date('2025-06-01'), + selectedDate = new Date('2025-06-15'); + + return html` + +
+
+ Basic + +
+ +
+ Selected + +
+ +
+ Show Today + +
+ +
+ Week Numbers + +
+ +
+ First Day Sunday + +
+ +
+ Min/Max + +
+ +
+ Disabled Dates + date.toISOString()) + .join(',')} + month=${mockDate.toISOString()} + > +
+ +
+ Indicator Dates + +
+ +
+ Readonly + +
+
+ `; + } +}; diff --git a/packages/components/calendar/src/calendar.ts b/packages/components/calendar/src/calendar.ts index 04170c4c5b..15decf1ecc 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'; @@ -10,7 +10,7 @@ import styles from './calendar.scss.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 { @@ -22,6 +22,7 @@ declare global { * A calendar component for displaying and selecting dates. */ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { + /** @internal */ static get scopedElements(): ScopedElementsMap { return { 'sl-select-day': SelectDay, @@ -36,12 +37,28 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @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>; + /** The list of dates that should be set as disabled. */ + @property({ attribute: 'disabled-dates', converter: dateListConverter }) disabledDates?: Date[]; + /** 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 has a `date` and optional `color` + * and `label` values that are used to improve accessibility. + */ + @property({ attribute: 'indicator-dates', converter: indicatorConverter }) indicatorDates?: Indicator[]; + /** * The maximum date selectable in the calendar. * @default undefined @@ -78,7 +95,7 @@ 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); + this.month = this.selected; } else { // Otherwise default to the current month. this.month ??= new Date(); @@ -94,9 +111,11 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { ?readonly=${this.readonly} ?show-today=${this.showToday} ?show-week-numbers=${this.showWeekNumbers} + .disabledDates=${this.disabledDates} + .indicatorDates=${this.indicatorDates} .month=${this.month} .selected=${this.selected} - aria-hidden=${this.mode !== 'day'} + aria-hidden=${ifDefined(this.mode !== 'day' ? 'true' : undefined)} first-day-of-week=${ifDefined(this.firstDayOfWeek)} locale=${ifDefined(this.locale)} max=${ifDefined(this.max?.toISOString())} @@ -109,15 +128,27 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { () => html` ` ], [ 'year', () => html` - + ` ] ])} @@ -129,7 +160,6 @@ export class Calendar extends LocaleMixin(ScopedElementsMixin(LitElement)) { event.stopPropagation(); if (!this.selected || !isSameDate(this.selected, event.detail)) { - this.month = new Date(event.detail.getFullYear(), event.detail.getMonth()); this.selected = new Date(event.detail); this.changeEvent.emit(this.selected); } @@ -152,7 +182,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(); @@ -163,6 +193,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.scss b/packages/components/calendar/src/month-view.scss index 7b0fe10243..0001fed7ce 100644 --- a/packages/components/calendar/src/month-view.scss +++ b/packages/components/calendar/src/month-view.scss @@ -2,6 +2,22 @@ display: inline-flex; } +:host([show-today]) { + [part~='today'] span { + border: var(--sl-size-borderWidth-subtle) solid var(--sl-color-border-bold); + inline-size: calc(var(--sl-size-300) - var(--sl-size-borderWidth-subtle) * 2); + } + + [part~='today'][part~='selected'] span { + 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~='today'][part~='disabled'] span { + border-color: var(--sl-color-border-disabled); + } +} + table { border-collapse: collapse; } @@ -9,22 +25,62 @@ table { td, th { padding: var(--sl-size-025); + position: relative; +} + +[part~='week-day'], +[part~='week-number'] { + color: var(--sl-color-foreground-disabled); + font-weight: normal; +} + +[part='week-day'] { + block-size: var(--sl-size-250); + padding-inline: 0; + position: relative; + + span { + inset: 50% auto auto 50%; + position: absolute; + translate: -50% -50%; + } +} + +[part~='week-number'] { + min-inline-size: var(--sl-size-400); + position: relative; + text-align: center; + + // Use a pseudo-element to prevent weird resizing within the table + // With a border, the sibling td was suddenly 0.5px wider + &::after { + background: var(--sl-color-border-neutral-plain); + content: ''; + inline-size: var(--sl-size-borderWidth-subtle); + inset: 0 0 0 auto; + position: absolute; + } } button { + --_bg-color: transparent; + --_bg-mix-color: var(--sl-color-background-neutral-interactive-bold); + --_bg-opacity: var(--sl-opacity-interactive-plain-idle); + appearance: none; + background: transparent; border: 0; border-radius: var(--sl-size-borderRadius-default); color: var(--sl-color-foreground-plain); cursor: pointer; + display: inline-flex; font: inherit; - outline: transparent solid var(--sl-size-borderWidth-focusRing); - outline-offset: var(--sl-size-outlineOffset-default); - padding: 0; + outline: none; + padding: var(--sl-size-050); - @media (prefers-reduced-motion: no-preference) { - transition: 0.2s ease-in-out; - transition-property: background, border-radius, color; + &:disabled { + cursor: default; + pointer-events: none; } &:hover { @@ -35,60 +91,78 @@ button { --_bg-opacity: var(--sl-opacity-interactive-plain-active); } - &:focus-visible { + &:focus-visible span { outline-color: var(--sl-color-border-focused); } + + span { + aspect-ratio: 1; + background: color-mix(in srgb, var(--_bg-color), var(--_bg-mix-color) calc(100% * var(--_bg-opacity))); + border-radius: inherit; + display: inline-grid; + inline-size: var(--sl-size-300); + outline: transparent solid var(--sl-size-borderWidth-focusRing); + outline-offset: var(--sl-size-outlineOffset-default); + place-items: center; + + @media (prefers-reduced-motion: no-preference) { + transition: 0.2s ease-in-out; + transition-property: background, border-radius, color; + } + } } -[part~='day'] { - --_bg-color: transparent; - --_bg-mix-color: var(--sl-color-background-info-interactive-plain); - --_bg-opacity: var(--sl-opacity-interactive-plain-idle); +[part~='previous-month'], +[part~='next-month'] { + color: var(--sl-color-foreground-subtlest); +} - align-items: center; - aspect-ratio: 1; - background: color-mix(in srgb, var(--_bg-color), var(--_bg-mix-color) calc(100% * var(--_bg-opacity))); - box-sizing: border-box; - color: var(--sl-color-foreground-plain); - display: inline-flex; - inline-size: var(--sl-size-400); - justify-content: center; +[part~='selected'] { + --_bg-color: var(--sl-color-background-selected-bold); + --_bg-mix-color: var(--sl-color-background-selected-interactive-bold); + + color: var(--sl-color-foreground-selected-onBold); } -[part~='previous-month'], -[part~='next-month'], -[part~='unselectable'] { +[part~='disabled'], +[part~='out-of-range'] { color: var(--sl-color-foreground-disabled); + cursor: default; } -[part~='highlight'] { - --_bg-color: var(--sl-color-background-info-subtle); +[part~='disabled'][part~='selected'] { + --_bg-color: var(--sl-color-background-disabled); + --_bg-mix-color: var(--sl-color-background-disabled); - border-radius: 50%; - color: var(--sl-color-foreground-info-plain); + color: var(--sl-color-foreground-disabled); } -[part~='today'] { - border: var(--sl-size-borderWidth-subtle) solid var(--sl-color-border-neutral-plain); +[part~='indicator']::after { + background: var(--sl-color-background-accent-blue-bold); + block-size: var(--sl-size-075); + border: var(--sl-size-borderWidth-default) solid var(--sl-elevation-surface-raised-default); border-radius: var(--sl-size-borderRadius-full); + box-sizing: border-box; + color: var(--sl-color-foreground-info-onBold); + content: ''; + inline-size: 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; } -[part~='selected'] { - --_bg-color: var(--sl-color-background-selected-bold); - --_bg-mix-color: var(--sl-color-background-selected-interactive-bold); +[part~='indicator-green']::after { + background: var(--sl-color-background-accent-green-bold); +} - border-radius: var(--sl-size-borderRadius-default); - color: var(--sl-color-foreground-selected-onBold); +[part~='indicator-grey']::after { + background: var(--sl-color-background-accent-grey-bold); } -[part~='week-day'], -[part~='week-number'] { - color: var(--sl-color-foreground-subtlest); - font-weight: normal; +[part~='indicator-red']::after { + background: var(--sl-color-background-accent-red-bold); } -[part~='week-number'] { - border-inline-end: var(--sl-size-borderWidth-subtle) solid var(--sl-color-border-plain); - inline-size: calc(var(--sl-size-400) - var(--sl-size-010)); - text-align: center; +[part~='indicator-yellow']::after { + background: var(--sl-color-background-accent-yellow-bold); } 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..5f040a49be --- /dev/null +++ b/packages/components/calendar/src/month-view.spec.ts @@ -0,0 +1,951 @@ +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import '@sl-design-system/tooltip/register.js'; +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { type TemplateResult, html } from 'lit'; +import { spy } from 'sinon'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import '../register.js'; +import { MonthView } from './month-view.js'; +import { type Day } from './utils.js'; + +describe('sl-month-view', () => { + let el: MonthView; + + beforeEach(() => { + // March 2023 + // -------------------- + // Mo Tu We Th Fr Sa Su + // 27 28 1 2 3 4 5 + // 6 7 8 9 10 11 12 + // 13 14 15 16 17 18 19 + // 20 21 22 23 24 25 26 + // 27 28 29 30 31 1 2 + vi.setSystemTime(new Date(2023, 2, 14)); + }); + + afterEach(() => vi.useRealTimers()); + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should use the english locale', () => { + expect(el.locale).to.equal('en'); + }); + + it('should use the current month', () => { + const now = new Date(); + + expect(el.month.getFullYear()).to.equal(now.getFullYear()); + expect(el.month.getMonth()).to.equal(now.getMonth()); + }); + + it('should not have a min date', () => { + expect(el.min).to.be.undefined; + }); + + it('should not have a max date', () => { + expect(el.max).to.be.undefined; + }); + + it('should not be readonly', () => { + expect(el.readonly).not.to.be.true; + expect(el).not.to.have.attribute('readonly'); + }); + + it('should be readonly when set', async () => { + el.readonly = true; + await el.updateComplete; + + expect(el).to.have.attribute('readonly'); + }); + + it('should not show today', () => { + expect(el.showToday).not.to.be.true; + }); + + it('should set a header part on the thead element', () => { + const thead = el.renderRoot.querySelector('thead'); + + expect(thead).to.have.attribute('part', 'header'); + }); + + it('should render the weekday short names', () => { + const weekdays = Array.from(el.renderRoot.querySelectorAll('thead th[part~="week-day"]')); + + expect(weekdays).to.have.length(7); + expect(weekdays.map(th => th.textContent?.trim())).to.deep.equal([ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun' + ]); + }); + + it('should render localized weekday short names', async () => { + el.locale = 'it'; + await el.updateComplete; + + const weekdays = Array.from(el.renderRoot.querySelectorAll('thead th[part~="week-day"]')); + + expect(weekdays).to.have.length(7); + expect(weekdays.map(th => th.textContent?.trim())).to.deep.equal([ + 'lun', + 'mar', + 'mer', + 'gio', + 'ven', + 'sab', + 'dom' + ]); + }); + + it('should not show the week numbers', () => { + const weekNumbers = el.renderRoot.querySelectorAll('[part~="week-number"]'); + + expect(el.showWeekNumbers).not.to.be.true; + expect(weekNumbers).to.have.length(0); + }); + + it('should show the week numbers when set', async () => { + el.showWeekNumbers = true; + await el.updateComplete; + + expect(el.showWeekNumbers).to.be.true; + + const weekNumbers = el.renderRoot.querySelectorAll('[part~="week-number"]'); + expect(weekNumbers.length).to.be.greaterThan(0); + }); + + it('should render week number column header when showWeekNumbers is set', async () => { + el.showWeekNumbers = true; + await el.updateComplete; + + const weekNumberHeader = el.renderRoot.querySelector('th[part~="week-number"]'); + + expect(weekNumberHeader).to.have.trimmed.text('wk.'); + }); + + it('should render localized week number header', async () => { + el.locale = 'it'; + el.showWeekNumbers = true; + await el.updateComplete; + + const weekNumberHeader = el.renderRoot.querySelector('th[part~="week-number"]'); + + expect(weekNumberHeader).to.have.trimmed.text('sett.'); + }); + + it('should have Monday as the first day of the week', () => { + expect(el.firstDayOfWeek).to.equal(1); + }); + + it('should reorder weekdays when firstDayOfWeek changes', async () => { + el.firstDayOfWeek = 0; // Sunday + await el.updateComplete; + + const weekdays = Array.from(el.renderRoot.querySelectorAll('th[part~="week-day"]')); + expect(weekdays.map(th => th.textContent?.trim())).to.deep.equal([ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat' + ]); + }); + + it('should render the days of the month', () => { + const dayButtons = Array.from(el.renderRoot.querySelectorAll('button[part~="day"]')), + dayNumbers = dayButtons.map(button => Number(button.textContent?.trim())); + + expect(dayNumbers).to.deep.equal([ + 27, 28, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, + 29, 30, 31, 1, 2 + ]); + }); + + it('should not hide the days from the other months', () => { + expect(el.hideDaysOtherMonths).not.to.be.true; + + const daysFromOtherMonths = Array.from( + el.renderRoot.querySelectorAll(':where([part~="next-month"], [part~="previous-month"])') + ); + + expect(daysFromOtherMonths.length).to.be.greaterThan(0); + }); + + it('should hide days from other months when hideDaysOtherMonths is set', async () => { + el.hideDaysOtherMonths = true; + await el.updateComplete; + + const daysFromOtherMonths = Array.from( + el.renderRoot.querySelectorAll(':where([part~="next-month"], [part~="previous-month"])') + ); + + expect(daysFromOtherMonths).to.have.length(0); + }); + }); + + describe('custom renderer', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should not have a custom renderer by default', () => { + expect(el.renderer).to.be.undefined; + }); + + it('should call the renderer callback for every day', async () => { + const renderer = spy(() => undefined); + + el.renderer = renderer; + await el.updateComplete; + + expect(renderer.callCount).to.equal(7 * 5); + }); + + it('should render custom content from the renderer', async () => { + el.renderer = (day: Day, monthView: MonthView): TemplateResult | undefined => { + if (day.today) { + return html``; + } + + return undefined; + }; + await el.updateComplete; + + const dayButtons = Array.from(el.renderRoot.querySelectorAll('button[part~="day"]')), + dayLabels = dayButtons.map(button => button.textContent?.trim()); + + expect(dayLabels).to.deep.equal([ + '27', + '28', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + 'TODAY', + '15', + '16', + '17', + '18', + '19', + '20', + '21', + '22', + '23', + '24', + '25', + '26', + '27', + '28', + '29', + '30', + '31', + '1', + '2' + ]); + }); + + it('should fallback to default rendering when undefined is returned', async () => { + el.renderer = () => undefined; + await el.updateComplete; + + const dayButtons = Array.from(el.renderRoot.querySelectorAll('button[part~="day"]')), + dayNumbers = dayButtons.map(button => Number(button.textContent?.trim())); + + expect(dayNumbers).to.deep.equal([ + 27, 28, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, + 29, 30, 31, 1, 2 + ]); + }); + }); + + describe('parts', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should apply "week-number" to the column header', () => { + const weekNumber = el.renderRoot.querySelector('th[part~="week-number"]'); + + expect(weekNumber).to.exist; + }); + + it('should apply "week-number" to week number cells', () => { + const weekNumbers = Array.from(el.renderRoot.querySelectorAll('td[part~="week-number"]')); + + expect(weekNumbers).to.have.length(5); + }); + + it('should apply "week-day" to the days of the week headers', () => { + const weekDays = Array.from(el.renderRoot.querySelectorAll('th[part~="week-day"]')); + + expect(weekDays).to.have.length(7); + }); + + it('should apply "day" to the day buttons', () => { + const days = Array.from(el.renderRoot.querySelectorAll('button[part~="day"]')); + + expect(days).to.be.length(7 * 5); + }); + + it('should apply "previous-month" to days from previous month', () => { + const prevMonthDays = Array.from(el.renderRoot.querySelectorAll('button[part~="previous-month"]')); + + expect(prevMonthDays).to.have.length(2); + }); + + it('should apply "next-month" to days from next month', () => { + const nextMonthDays = Array.from(el.renderRoot.querySelectorAll('button[part~="next-month"]')); + + expect(nextMonthDays).to.have.length(2); + }); + + it('should apply "today" part when showToday is set', () => { + const today = el.renderRoot.querySelector('button[part~="today"]'); + + expect(today).to.exist; + expect(today?.textContent?.trim()).to.equal('14'); + }); + + it('should apply "selected" to the selected day', async () => { + el.selected = new Date(el.month.getFullYear(), el.month.getMonth(), 15); + await el.updateComplete; + + const selected = el.renderRoot.querySelector('button[part~="selected"]'); + + expect(selected).to.exist; + expect(selected?.textContent?.trim()).to.equal('15'); + }); + + it('should apply "indicator" part when indicator dates provided', async () => { + el.indicatorDates = [{ date: new Date(el.month.getFullYear(), el.month.getMonth(), 20) }]; + await el.updateComplete; + + const indicator = el.renderRoot.querySelector('button[part~="indicator"]'); + + expect(indicator).to.exist; + expect(indicator?.textContent?.trim()).to.equal('20'); + }); + + it('should apply "indicator-" part when indicator with color is provided', async () => { + el.indicatorDates = [{ date: new Date(el.month.getFullYear(), el.month.getMonth(), 20), color: 'red' }]; + await el.updateComplete; + + const indicator = el.renderRoot.querySelector('button[part~="indicator-red"]'); + + expect(indicator).to.exist; + expect(indicator?.textContent?.trim()).to.equal('20'); + }); + + it('should apply "disabled" part when disabled dates provided', async () => { + el.disabledDates = [new Date(el.month.getFullYear(), el.month.getMonth(), 25)]; + await el.updateComplete; + + const disabled = el.renderRoot.querySelector('button[part~="disabled"]'); + + expect(disabled).to.exist; + expect(disabled?.textContent?.trim()).to.equal('25'); + }); + + it('should apply "out-of-range" part when min and max are set', async () => { + el.min = new Date(el.month.getFullYear(), el.month.getMonth(), 10); + el.max = new Date(el.month.getFullYear(), el.month.getMonth(), 20); + await el.updateComplete; + + const outOfRange = Array.from(el.renderRoot.querySelectorAll('button[part~="out-of-range"]')), + outOfRangeDays = outOfRange.map(button => Number(button.textContent?.trim())); + + expect(outOfRangeDays).to.deep.equal([ + 27, 28, 1, 2, 3, 4, 5, 6, 7, 8, 9, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 1, 2 + ]); + }); + }); + + describe('disabled dates', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should not disable dates by default', () => { + const buttons = Array.from(el.renderRoot.querySelectorAll('button[disabled]')); + + expect(buttons).to.have.length(0); + }); + + it('should disable specified dates', async () => { + el.disabledDates = [new Date(el.month.getFullYear(), el.month.getMonth(), 10)]; + await el.updateComplete; + + const button = el.renderRoot.querySelector('button[disabled]'); + + expect(button).to.exist; + expect(button?.textContent?.trim()).to.equal('10'); + }); + + it('should skip disabled dates when navigating using arrow left/right', async () => { + // Disable days 11 and 12 + el.disabledDates = [ + new Date(el.month.getFullYear(), el.month.getMonth(), 11), + new Date(el.month.getFullYear(), el.month.getMonth(), 12) + ]; + await el.updateComplete; + + // Focus on day 10 + el.focus(new Date(2023, 2, 10)); + + // Press arrow right - should skip 11 and 12, landing on 13 + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('13'); + + // Press arrow left - should skip 12 and 11, landing back on 10 + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('10'); + }); + + it('should skip disabled dates when navigating using arrow up/down', async () => { + // Looking at the calendar grid: + // Mo Tu We Th Fr Sa Su + // 6 7 8 9 10 11 12 + // 13 14 15 16 17 18 19 + // 20 21 22 23 24 25 26 + // + // Disable days 13 and 20 (same column as 6 - Monday) + el.disabledDates = [ + new Date(el.month.getFullYear(), el.month.getMonth(), 13), + new Date(el.month.getFullYear(), el.month.getMonth(), 20) + ]; + await el.updateComplete; + + // Focus on day 6 (Monday, week 2) + el.focus(new Date(2023, 2, 6)); + + // Press arrow down - should skip 13 and 20, landing on 27 + await userEvent.keyboard('{ArrowDown}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('27'); + + // Press arrow up - should skip 20 and 13, landing back on 6 + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('6'); + }); + }); + + describe('indicator dates', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should not have indicator dates by default', () => { + const buttons = Array.from(el.renderRoot.querySelectorAll('button[part~="indicator"]')); + + expect(buttons).to.have.length(0); + }); + + it('should render a tooltip for the indicator with label', async () => { + el.indicatorDates = [{ date: new Date(el.month.getFullYear(), el.month.getMonth(), 13), label: 'Special day' }]; + await el.updateComplete; + + const button = el.renderRoot.querySelector('button[part~="indicator"]'), + tooltip = button?.nextElementSibling; + + expect(button).to.exist; + expect(button).to.have.attribute('aria-describedby', tooltip?.id); + + expect(tooltip).to.match('sl-tooltip'); + expect(tooltip).to.have.attribute('id'); + expect(tooltip?.textContent?.trim()).to.equal('Special day'); + }); + + it('should render no tooltip when no color or label provided', async () => { + el.indicatorDates = [{ date: new Date(el.month.getFullYear(), el.month.getMonth(), 15) }]; + await el.updateComplete; + + const button = el.renderRoot.querySelector('button[part~="indicator"]'); + + expect(button).to.exist; + expect(button?.nextElementSibling).to.be.null; + }); + }); + + describe('min/max', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should not set min/max by default', () => { + expect(el.min).to.be.undefined; + expect(el.max).to.be.undefined; + }); + + it('should not have any out of range dates by default', () => { + const buttons = Array.from(el.renderRoot.querySelectorAll('button[part~="out-of-range"]')); + + expect(buttons).to.have.length(0); + }); + + it('should disable dates before min', async () => { + el.min = new Date(el.month.getFullYear(), el.month.getMonth(), 10); + await el.updateComplete; + + const buttons = Array.from(el.renderRoot.querySelectorAll('button[part~="out-of-range"]')), + days = buttons.map(button => Number(button.textContent?.trim())); + + expect(buttons.every(b => b.disabled)).to.be.true; + expect(days).to.deep.equal([27, 28, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('should disable dates after max', async () => { + el.max = new Date(el.month.getFullYear(), el.month.getMonth(), 20); + await el.updateComplete; + + const buttons = Array.from(el.renderRoot.querySelectorAll('button[part~="out-of-range"]')), + days = buttons.map(button => Number(button.textContent?.trim())); + + expect(buttons.every(b => b.disabled)).to.be.true; + expect(days).to.deep.equal([21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 1, 2]); + }); + }); + + describe('accessibility', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have an aria-label on the table', () => { + const table = el.renderRoot.querySelector('table'); + + expect(table).to.have.attribute('aria-label', 'Days of March 2023'); + }); + + it('should have an aria-label for the week number column header', () => { + const weekNumberHeader = el.renderRoot.querySelector('th[part~="week-number"]'); + + expect(weekNumberHeader).to.have.attribute('aria-label', 'Week'); + }); + + it('should have an aria-label for the week number cells', () => { + const weekNumberCells = Array.from(el.renderRoot.querySelectorAll('td[part~="week-number"]')), + labels = weekNumberCells.map(td => td.getAttribute('aria-label')); + + expect(labels).to.deep.equal(['Week 9', 'Week 10', 'Week 11', 'Week 12', 'Week 13']); + }); + + it('should have an aria-label for the days of the week headers', () => { + const weekDayHeaders = Array.from(el.renderRoot.querySelectorAll('th[part~="week-day"]')), + labels = weekDayHeaders.map(th => th.getAttribute('aria-label')); + + expect(labels).to.deep.equal(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']); + }); + + it('should set aria-current="date" on today', () => { + const today = el.renderRoot.querySelector('button[part~="today"]'); + + expect(today).to.have.attribute('aria-current', 'date'); + }); + + it('should set aria-pressed="true" on selected day', async () => { + el.selected = new Date(el.month.getFullYear(), el.month.getMonth(), el.month.getDate()); + await el.updateComplete; + + const selected = el.renderRoot.querySelector('button[part~="selected"]'); + + expect(selected).to.exist; + expect(selected).to.have.attribute('aria-pressed', 'true'); + }); + }); + + describe('selection', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + describe('current month', () => { + it('should select a day when clicked', () => { + let date: Date | null = null; + + el.addEventListener('sl-select', (event: SlChangeEvent) => (date = event.detail)); + el.renderRoot + .querySelector('button[part~="day"]:not([part~="previous-month"]):not([part~="next-month"])') + ?.click(); + + expect(date).to.equalDate(new Date(2023, 2, 1)); + expect(el.selected).to.equalDate(new Date(2023, 2, 1)); + }); + + it('should not emit an sl-select event when clicking an already selected day', async () => { + const onSelect = spy(); + + el.selected = new Date(2023, 2, 1); + await el.updateComplete; + + el.addEventListener('sl-select', onSelect); + el.renderRoot + .querySelector('button[part~="day"]:not([part~="previous-month"]):not([part~="next-month"])') + ?.click(); + await el.updateComplete; + + expect(onSelect).to.not.have.been.called; + }); + + it('should select a day when focused and Enter is pressed', async () => { + let date: Date | null = null; + + el.addEventListener('sl-select', (event: SlChangeEvent) => (date = event.detail)); + el.focus(); + + await userEvent.keyboard('{Enter}'); + + expect(date).to.equalDate(new Date(2023, 2, 1)); + expect(el.selected).to.equalDate(new Date(2023, 2, 1)); + }); + + it('should select a day when focused and Space is pressed', async () => { + let date: Date | null = null; + + el.addEventListener('sl-select', (event: SlChangeEvent) => (date = event.detail)); + el.focus(); + + await userEvent.keyboard('{Space}'); + + expect(date).to.equalDate(new Date(2023, 2, 1)); + expect(el.selected).to.equalDate(new Date(2023, 2, 1)); + }); + + it('should toggle the selection when a different day is selected', async () => { + el.selected = new Date(2023, 2, 5); + await el.updateComplete; + + el.renderRoot + .querySelector('button[part~="day"]:not([part~="previous-month"]):not([part~="next-month"])') + ?.click(); + await el.updateComplete; + + expect(el.selected).to.equalDate(new Date(2023, 2, 1)); + }); + }); + + describe('not the current month', () => { + it('should select a day when clicked', async () => { + let date: Date | null = null; + + el.addEventListener('sl-select', (event: SlChangeEvent) => (date = event.detail)); + el.renderRoot.querySelector('button[part~="next-month"]')?.click(); + await el.updateComplete; + + expect(date).to.equalDate(new Date(2023, 3, 1)); + expect(el.selected).to.equalDate(new Date(2023, 3, 1)); + }); + + it('should emit an sl-change event when the month changes due to selection', () => { + let date: Date | null = null; + + el.addEventListener('sl-change', (event: SlChangeEvent) => (date = event.detail)); + el.renderRoot.querySelector('button[part~="next-month"]')?.click(); + + expect(date).to.equalDate(new Date(2023, 3, 1)); + }); + }); + }); + + describe('focus', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should focus the first day of the month on initial focus', () => { + el.focus(); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.have.attribute('autofocus'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('1'); + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + }); + + it('should focus the first enabled day of the month on initial focus', async () => { + el.disabledDates = [ + new Date(el.month.getFullYear(), el.month.getMonth(), 1), + new Date(el.month.getFullYear(), el.month.getMonth(), 2), + new Date(el.month.getFullYear(), el.month.getMonth(), 3) + ]; + await el.updateComplete; + + el.focus(); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.have.attribute('autofocus'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('4'); + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + }); + + it('should focus the first day that is not out of range on initial focus', async () => { + el.min = new Date(el.month.getFullYear(), el.month.getMonth(), 5); + await el.updateComplete; + + el.focus(); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.have.attribute('autofocus'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('5'); + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + }); + + it('should focus today on initial focus when showToday is set', async () => { + el.showToday = true; + await el.updateComplete; + + el.focus(); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.have.attribute('autofocus'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('14'); + expect(el.shadowRoot?.activeElement).to.match('button[part~="today"]'); + }); + + it('should focus the selected day on initial focus if set', async () => { + el.selected = new Date(el.month.getFullYear(), el.month.getMonth(), 20); + await el.updateComplete; + + el.focus(); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.have.attribute('autofocus'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('20'); + expect(el.shadowRoot?.activeElement).to.match('button[part~="selected"]'); + }); + + it('should focus the first day of the month when called without arguments', () => { + el.focus(); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('1'); + }); + + it('should accept focus options', () => { + el.focus({ preventScroll: true }); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + }); + + it('should focus a specific date when a Date is passed', () => { + const targetDate = new Date(2023, 2, 15); + + el.focus(targetDate); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('15'); + }); + + it('should focus the selected date when it exists', async () => { + el.selected = new Date(2023, 2, 20); + await el.updateComplete; + + const targetDate = new Date(2023, 2, 20); + + el.focus(targetDate); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="selected"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('20'); + }); + }); + + describe('keyboard navigation', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should focus the next day on arrow right', async () => { + el.focus(); + + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('2'); + }); + + it('should focus the previous day on arrow left', async () => { + el.showToday = true; + await el.updateComplete; + + el.focus(); + + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('13'); + }); + + it('should not change focus when pressing arrow left on the first enabled day of the month', async () => { + el.min = new Date(el.month.getFullYear(), el.month.getMonth(), 10); + await el.updateComplete; + + el.focus(); + + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('10'); + }); + + it('should not change focus when pressing arrow right on the last enabled day of the month', async () => { + el.selected = new Date(2023, 2, 20); + el.max = new Date(el.month.getFullYear(), el.month.getMonth(), 20); + await el.updateComplete; + + el.focus(); + + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('20'); + }); + + it('should focus the day below on arrow down', async () => { + el.focus(); + + await userEvent.keyboard('{ArrowDown}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('8'); + }); + + it('should not change focus when pressing arrow down where there is no enabled day of the month below it', async () => { + el.selected = new Date(2023, 2, 24); + el.max = new Date(el.month.getFullYear(), el.month.getMonth(), 30); + await el.updateComplete; + + el.focus(); + + await userEvent.keyboard('{ArrowDown}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('24'); + }); + + it('should focus the day above on arrow up', async () => { + el.showToday = true; + await el.updateComplete; + + el.focus(); + + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('7'); + }); + + it('should not change focus when pressing arrow up where there is no enabled day of the month above it', async () => { + el.min = new Date(el.month.getFullYear(), el.month.getMonth(), 2); + el.selected = new Date(2023, 2, 8); + await el.updateComplete; + + el.focus(); + + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot?.activeElement).to.exist; + expect(el.shadowRoot?.activeElement).to.match('button[part~="day"]'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('8'); + }); + + it('should emit an sl-change event after pressing arrow left on the first day of the current month', async () => { + let date: Date | null = null; + + el.addEventListener('sl-change', (event: SlChangeEvent) => (date = event.detail)); + el.focus(); + + await userEvent.keyboard('{ArrowLeft}'); + + expect(date).to.equalDate(new Date(2023, 1, 28)); + }); + + it('should emit an sl-change event after pressing arrow right on the last day of the current month', async () => { + let date: Date | null = null; + + el.selected = new Date(2023, 2, 31); + await el.updateComplete; + + el.addEventListener('sl-change', (event: SlChangeEvent) => (date = event.detail)); + el.focus(); + + await userEvent.keyboard('{ArrowRight}'); + + expect(date).to.equalDate(new Date(2023, 3, 1)); + }); + + it('should emit an sl-change event after pressing arrow up on a day in the first week of the current month', async () => { + let date: Date | null = null; + + el.addEventListener('sl-change', (event: SlChangeEvent) => (date = event.detail)); + el.focus(); + + await userEvent.keyboard('{ArrowUp}'); + + expect(date).to.equalDate(new Date(2023, 1, 22)); + }); + + it('should emit an sl-change event after pressing arrow down on a day in the last week of the current month', async () => { + let date: Date | null = null; + + el.selected = new Date(2023, 2, 30); + await el.updateComplete; + + el.addEventListener('sl-change', (event: SlChangeEvent) => (date = event.detail)); + el.focus(); + + await userEvent.keyboard('{ArrowDown}'); + + expect(date).to.equalDate(new Date(2023, 3, 6)); + }); + + it('should not emit an sl-change event when navigating within the current month', async () => { + const onChange = spy(); + + el.addEventListener('sl-change', onChange); + el.focus(); + + await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowDown}{ArrowLeft}{ArrowUp}'); + + expect(onChange).to.not.have.been.called; + }); + }); +}); diff --git a/packages/components/calendar/src/month-view.stories.ts b/packages/components/calendar/src/month-view.stories.ts index 91401dbecc..0a7eed848c 100644 --- a/packages/components/calendar/src/month-view.stories.ts +++ b/packages/components/calendar/src/month-view.stories.ts @@ -9,8 +9,10 @@ import { type Day } from './utils.js'; type Props = Pick< MonthView, + | 'disabledDates' | 'firstDayOfWeek' | 'hideDaysOtherMonths' + | 'indicatorDates' | 'locale' | 'max' | 'min' @@ -36,9 +38,15 @@ export default { showWeekNumbers: false }, argTypes: { + disabledDates: { + control: 'date' + }, firstDayOfWeek: { control: 'number' }, + indicatorDates: { + control: 'date' + }, locale: { control: 'inline-radio', options: ['de', 'en-GB', 'es', 'fi', 'fr', 'it', 'nl', 'nl-BE', 'no', 'pl', 'sv'] @@ -63,12 +71,14 @@ export default { } }, render: ({ + disabledDates, firstDayOfWeek, hideDaysOtherMonths, + indicatorDates, + locale, max, min, month, - locale, readonly, renderer, selected, @@ -88,17 +98,53 @@ export default { ?readonly=${readonly} ?show-today=${showToday} ?show-week-numbers=${showWeekNumbers} + .renderer=${renderer} + disabled-dates=${ifDefined(disabledDates?.map(date => date.toISOString()).join(','))} first-day-of-week=${ifDefined(firstDayOfWeek)} + indicator-dates=${ifDefined( + Array.isArray(indicatorDates) + ? JSON.stringify( + indicatorDates + .filter(item => item?.date) + .map(item => ({ + date: item.date.toISOString(), + ...(item.color ? { color: item.color } : {}), + ...(item.label ? { label: item.label } : {}) + })) + ) + : undefined + )} locale=${ifDefined(locale)} max=${ifDefined(max?.toISOString())} min=${ifDefined(min?.toISOString())} - month=${ifDefined(month?.toISOString())} - selected=${ifDefined(selected?.toISOString())} - .renderer=${renderer} + month=${ifDefined(month ? new Date(month).toISOString() : undefined)} + selected=${ifDefined(selected ? new Date(selected).toISOString() : undefined)} > ` } satisfies Meta; +const indicatorLabels: 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 = { @@ -115,9 +161,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) } }; @@ -130,35 +176,24 @@ export const Readonly: Story = { export const Renderer: Story = { args: { renderer: (day: Day, monthView: MonthView) => { - const parts = monthView.getDayParts(day); - - if (day.currentMonth && [2, 4, 7, 10, 16, 22].includes(day.date.getDate())) { - parts.push('highlight'); - } - if (day.currentMonth && day.date.getDate() === 24) { - parts.push('finish'); + const label = `${monthView.getDayLabel(day)}, Goal achieved!`, + parts = [...monthView.getDayParts(day), 'finish']; - return html``; - } else if (day.currentMonth) { - return html``; + return html` + + `; } else { - return html`${day.date.getDate()}`; + // Returning undefined will fallback to the default rendering + return undefined; } }, styles: ` sl-month-view::part(finish) { - background: var(--sl-color-success-plain); - border-radius: 50%; - color: var(--sl-color-text-inverted); - } - - sl-month-view::part(finish):hover { - background: var(--sl-color-success-bold); - } - - sl-month-view::part(finish):active { - background: var(--sl-color-success-heavy); + --_bg-color: var(--sl-color-background-positive-subtle); + --_bg-mix-color: var(--sl-color-background-positive-interactive-bold); } ` } @@ -177,8 +212,117 @@ export const Today: Story = { } }; +export const IndicatorDates: Story = { + args: { + indicatorDates: [ + { date: new Date('2025-08-05'), label: indicatorLabels.default.label }, + { date: new Date('2025-08-06'), color: 'blue', label: indicatorLabels.blue.label }, + { date: new Date('2025-08-07'), color: 'red', label: indicatorLabels.red.label }, + { date: new Date('2025-08-09'), color: 'yellow', label: indicatorLabels.yellow.label }, + { date: new Date('2025-08-10'), color: 'green', label: indicatorLabels.green.label }, + { date: new Date('2025-08-20'), color: 'grey', label: indicatorLabels.grey.label }, + { date: new Date('2025-08-22'), color: 'green', label: indicatorLabels.green.label }, + { date: new Date('2025-08-27'), color: 'yellow', label: indicatorLabels.yellow.label } + ], + month: new Date(1755640800000), + showToday: true + } +}; + +export const DisabledDates: Story = { + args: { + disabledDates: [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 } }; + +export const All: Story = { + render: () => html` + +
+
+

Basic

+ +
+
+

First day of week (Sunday)

+ +
+
+

Hide days from other months

+ +
+
+

Min/Max range

+ +
+
+

Readonly

+ +
+
+

Selected date

+ +
+
+

Show today

+ +
+
+

Indicator dates

+ +
+
+

Disabled dates

+ +
+
+

Week numbers

+ +
+
+ ` +}; diff --git a/packages/components/calendar/src/month-view.ts b/packages/components/calendar/src/month-view.ts index b6234fd893..d71dee9432 100644 --- a/packages/components/calendar/src/month-view.ts +++ b/packages/components/calendar/src/month-view.ts @@ -1,14 +1,27 @@ -import { localized } from '@lit/localize'; +import { localized, msg, str } from '@lit/localize'; +import { type ScopedElementsMap, ScopedElementsMixin } 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 { Icon } from '@sl-design-system/icon'; +import { type EventEmitter, NewFocusGroupController, event } from '@sl-design-system/shared'; +import { dateConverter, dateListConverter } 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 '@sl-design-system/tooltip/register.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, isSameDate } from './utils.js'; +import { + type Calendar, + type Day, + Indicator, + createCalendar, + getWeekdayNames, + indicatorConverter, + isDateInList, + isSameDate +} from './utils.js'; declare global { interface HTMLElementTagNameMap { @@ -16,35 +29,79 @@ declare global { } } -export type MonthViewRenderer = (day: Day, monthView: MonthView) => TemplateResult; +export type MonthViewRenderer = (day: Day, monthView: MonthView) => TemplateResult | undefined; + +const DAYS_IN_WEEK = 7; /** * Component that renders a single month of a calendar. + * + * @csspart day - The day button. + * @csspart disabled - The day button when shown as disabled. + * @csspart indicator - The day button for a date with an indicator. + * @csspart next-month - The day button for a day in the next month. + * @csspart previous-month - The day button for a day in the previous month. + * @csspart selected - The day button for the selected date. + * @csspart today - The day button for today's date. + * @csspart week-day - The week day header cell. + * @csspart week-number - The week number cell. */ @localized() -export class MonthView extends LocaleMixin(LitElement) { +export class MonthView extends LocaleMixin(ScopedElementsMixin(LitElement)) { + /** @internal */ + static override get observedAttributes(): string[] { + // Observe the `inert` attribute to update the roving tabindex + return [...(super.observedAttributes ?? []), 'inert']; + } + + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-icon': Icon, + 'sl-tooltip': Tooltip + }; + } + + /** @internal */ + static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true }; + /** @internal */ static override styles: CSSResultGroup = styles; - #rovingTabindexController = new RovingTabindexController(this, { + /** The current month. */ + #month = new Date(); + + /** Manage focus group for day buttons. */ + #focusGroupController = new NewFocusGroupController(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); - if (index > -1) { - return index; + directionLength: DAYS_IN_WEEK, + focusInIndex: (elements: HTMLButtonElement[]) => { + if (!elements || elements.length === 0) { + return -1; } - index = elements.findIndex(el => el.part.contains('today') && !el.disabled); - if (index > -1) { - return index; + // If there is a selected day, focus that one + const selectedIndex = elements.findIndex(el => !el.disabled && el.getAttribute('aria-pressed') === 'true'); + if (selectedIndex > -1) { + return selectedIndex; } - return elements.findIndex(el => !el.disabled); + // Otherwise, focus today if visible + const todayIndex = elements.findIndex(el => !el.disabled && el.part.contains('today')); + if (todayIndex > -1) { + return todayIndex; + } + + // Otherwise, focus the first available day of the month + return elements.findIndex( + el => !el.disabled && !el.part.contains('previous-month') && !el.part.contains('next-month') + ); }, - elements: (): HTMLButtonElement[] => Array.from(this.renderRoot.querySelectorAll('button')), - isFocusableElement: el => !el.disabled + elements: (): HTMLButtonElement[] => { + return this.inert ? [] : Array.from(this.renderRoot.querySelectorAll('button')); + }, + isFocusableElement: el => + !!el && !el.disabled && !el.part.contains('previous-month') && !el.part.contains('next-month') }); /** @internal The calendar object. */ @@ -53,6 +110,12 @@ 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 element. */ + @query('.days') days?: HTMLElement; + + /** The list of dates that should be disabled. */ + @property({ attribute: 'disabled-dates', converter: dateListConverter }) disabledDates?: Date[]; + /** * The first day of the week; 0 for Sunday, 1 for Monday. * @@ -69,6 +132,12 @@ 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-dates', converter: indicatorConverter }) indicatorDates?: Indicator[]; + /** @internal The localized "week of year" label. */ @state() localizedWeekOfYear?: string; @@ -84,8 +153,18 @@ export class MonthView extends LocaleMixin(LitElement) { */ @property({ converter: dateConverter }) min?: Date; - /** The current month to display. */ - @property({ converter: dateConverter }) month?: Date; + get month(): Date { + return this.#month; + } + + /** + * The current month to display. + * @default new Date() + */ + @property({ converter: dateConverter }) + set month(value: Date) { + this.#month = value; + } /** * If set, will not render buttons for each day. @@ -99,7 +178,10 @@ export class MonthView extends LocaleMixin(LitElement) { /** @internal Emits when the user selects a day. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; - /** The selected date. */ + /** + * The selected date. + * @default undefined + */ @property({ converter: dateConverter }) selected?: Date; /** @@ -114,10 +196,23 @@ export class MonthView extends LocaleMixin(LitElement) { */ @property({ type: Boolean, attribute: 'show-week-numbers' }) showWeekNumbers?: boolean; + /** @internal Whether per day indicator tooltips are rendered into the DOM. */ + @state() tooltipsRendered = false; + /** @internal The translated days of the week. */ @state() weekDays: Array<{ long: string; short: string }> = []; + override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { + super.attributeChangedCallback(name, oldValue, newValue); + + if (name === 'inert') { + this.#focusGroupController.clearElementCache(); + } + } + override willUpdate(changes: PropertyValues): void { + super.willUpdate(changes); + if (changes.has('firstDayOfWeek') || changes.has('locale')) { const { locale, firstDayOfWeek } = this, longDays = getWeekdayNames({ firstDayOfWeek, locale, style: 'long' }), @@ -132,26 +227,54 @@ export class MonthView extends LocaleMixin(LitElement) { ); } - if (changes.has('max') || changes.has('min') || changes.has('month')) { - const { firstDayOfWeek, max, min, showToday } = this; - - this.calendar = createCalendar(this.month ?? new Date(), { firstDayOfWeek, max, min, showToday }); - } - - if (changes.has('month')) { - this.#rovingTabindexController.clearElementCache(); + if ( + changes.has('disabledDates') || + changes.has('indicatorDates') || + changes.has('max') || + changes.has('min') || + changes.has('month') || + changes.has('showToday') + ) { + const { disabledDates, firstDayOfWeek, indicatorDates, max, min, showToday } = this; + + this.calendar = createCalendar(this.month, { + disabledDates, + firstDayOfWeek, + indicatorDates, + max, + min, + showToday + }); + + this.#focusGroupController.clearElementCache(); } } override render(): TemplateResult { return html` - +
${this.renderHeader()} ${this.calendar?.weeks.map( week => html` - - ${this.showWeekNumbers ? html`` : nothing} + + ${this.showWeekNumbers + ? html` + + ` + : nothing} ${week.days.map(day => this.renderDay(day))} ` @@ -164,9 +287,15 @@ export class MonthView extends LocaleMixin(LitElement) { renderHeader(): TemplateResult { return html` - - ${this.showWeekNumbers ? html`` : nothing} - ${this.weekDays.map(day => html``)} + + ${this.showWeekNumbers + ? html` + + ` + : nothing} + ${this.weekDays.map(day => html``)} `; @@ -178,72 +307,200 @@ export class MonthView extends LocaleMixin(LitElement) { if (this.renderer) { template = this.renderer(day, this); } 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' })}`; + return html``; + } + + const parts = this.getDayParts(day), + selected = parts.includes('selected'); + + // If the custom renderer returned `undefined`, we fall back to the default rendering + if (!template) { + const autofocus = this.#hasAutofocus(day, selected); template = - this.readonly || !day.currentMonth || day.unselectable - ? html`${day.date.getDate()}` + this.readonly || day.disabled || day.outOfRange + ? html` + + ` : html` + ${day.indicator?.label + ? html` + ${day.indicator.label} + ` + : nothing} `; } return html` - + `; } - /** Returns an array of part names for a day. */ + /** Returns the default aria-label for a given day. */ + getDayLabel = (day: Day): string => { + return `${day.date.getDate()}, ${format(day.date, this.locale, { weekday: 'long' })} ${format(day.date, this.locale, { month: 'long', year: 'numeric' })}`; + }; + + /** Returns an array of part names for a given day. */ getDayParts = (day: Day): string[] => { return [ 'day', + day.disabled ? 'disabled' : '', + day.indicator ? 'indicator' : '', + day.indicator?.color ? `indicator-${day.indicator.color}` : '', day.nextMonth ? 'next-month' : '', + day.outOfRange ? 'out-of-range' : '', day.previousMonth ? 'previous-month' : '', day.today ? 'today' : '', - day.unselectable ? 'unselectable' : '', this.selected && isSameDate(day.date, this.selected) ? 'selected' : '' ].filter(part => part !== ''); }; - focusDay(day: Date): void { - const button = this.renderRoot.querySelector(`td[data-date="${day.toISOString()}"] button`)!; + override focus(options?: FocusOptions): void; + override focus(date: Date): void; + override focus(dateOrOptions?: Date | FocusOptions): void { + if (dateOrOptions instanceof Date) { + const button = this.renderRoot.querySelector( + `td[data-date="${dateOrOptions.toISOString()}"] button` + )!; - this.#rovingTabindexController.clearElementCache(); - this.#rovingTabindexController.focusToElement(button); + this.#focusGroupController.clearElementCache(); + this.#focusGroupController.focusToElement(button); + } else { + super.focus(dateOrOptions); + } } - #onClick(event: Event, day: Day): void { - if (event.target instanceof HTMLButtonElement && !event.target.disabled) { - this.selectEvent.emit(day.date); + #onClick(event: Event & { target: HTMLElement }, day: Day): void { + const button = event.target.closest('button'); + + if (!button?.disabled) { + const isAlreadySelected = this.selected && isSameDate(day.date, this.selected); + + if (!isAlreadySelected) { + this.selectEvent.emit(day.date); + this.selected = day.date; + } + } + + // Emit the `sl-select` event before the `sl-change` event, so the date-field + // can choose to close the popover containing the month-view before it changes month. + if (button?.part.contains('previous-month') || button?.part.contains('next-month')) { + this.changeEvent.emit(day.date); } } #onKeydown(event: KeyboardEvent, day: Day): void { - if (event.key === 'ArrowLeft' && day.currentMonth && day.date.getDate() === 1) { + if (event.key === 'ArrowLeft' && day.firstActiveDayOfMonth) { event.preventDefault(); event.stopPropagation(); this.changeEvent.emit(new Date(day.date.getFullYear(), day.date.getMonth(), 0)); - } else if (event.key === 'ArrowRight' && day.currentMonth && day.lastDayOfMonth) { + } else if (event.key === 'ArrowRight' && day.lastActiveDayOfMonth) { event.preventDefault(); event.stopPropagation(); this.changeEvent.emit(new Date(day.date.getFullYear(), day.date.getMonth() + 1, 1)); + } else if (event.key === 'ArrowUp' && day.currentMonth) { + // Whether it's possible to move to the same weekday in previous weeks (skipping disabled) + const possibleDay = this.#getEnabledSameWeekday(day.date, -1); + if (!possibleDay) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + const crossesMonth = + possibleDay.getMonth() !== day.date.getMonth() || possibleDay.getFullYear() !== day.date.getFullYear(); + + if (crossesMonth) { + event.preventDefault(); + event.stopPropagation(); + + this.changeEvent.emit(possibleDay); + } + } else if (event.key === 'ArrowDown' && day.currentMonth) { + // Whether it's possible to move to the same weekday in following weeks (skipping disabled) + const possibleDay = this.#getEnabledSameWeekday(day.date, 1); + if (!possibleDay) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + const crossesMonth = + possibleDay.getMonth() !== day.date.getMonth() || possibleDay.getFullYear() !== day.date.getFullYear(); + + if (crossesMonth) { + event.preventDefault(); + event.stopPropagation(); + + this.changeEvent.emit(possibleDay); + } } else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); this.selectEvent.emit(day.date); + this.selected = 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() + DAYS_IN_WEEK * direction + ); + + if ((this.min && possibleDay < this.min) || (this.max && possibleDay > this.max)) { + return undefined; + } + + if (!(this.disabledDates && isDateInList(possibleDay, this.disabledDates))) { + return possibleDay; + } + + return findEnabledSameWeekday(possibleDay); + }; + + return findEnabledSameWeekday(start); + } + + /** + * Determines if a button should autofocus. + * A button should autofocus when: + * - it is the selected date + * - or it is today + * - or it is the first enabled day of the month + */ + #hasAutofocus(day: Day, selected: boolean): boolean { + const isFirstEnabledDay = + day.currentMonth && + !day.disabled && + !day.outOfRange && + !this.selected && + !this.showToday && + !this.calendar?.weeks.some(week => + week.days.some(d => d.currentMonth && !d.disabled && !d.outOfRange && d.date < day.date) + ); + + return !!(selected || (day.today && !this.selected) || isFirstEnabledDay); + } } diff --git a/packages/components/calendar/src/select-day.scss b/packages/components/calendar/src/select-day.scss index 7713cb8c93..c4dc2db42c 100644 --- a/packages/components/calendar/src/select-day.scss +++ b/packages/components/calendar/src/select-day.scss @@ -1,45 +1,56 @@ :host { + --_days-in-week: 7; + display: inline-flex; flex-direction: column; } :host([show-week-numbers]) { .days-of-week { - grid-template-columns: repeat(8, var(--sl-size-450)); + grid-template-columns: var(--sl-size-600) repeat(var(--_days-in-week), var(--sl-size-450)); } .scroller { - max-inline-size: calc(8 * var(--sl-size-450) + var(--sl-size-100)); + max-inline-size: calc(var(--sl-size-600) + var(--_days-in-week) * var(--sl-size-450) + var(--sl-size-100)); + } + + sl-month-view::part(week-number) { + min-inline-size: calc(var(--sl-size-500) + 2 * var(--sl-size-025)); } } -[part='header'] { +header { align-items: center; display: flex; - padding-block-start: var(--sl-size-075); - padding-inline: var(--sl-size-075); + justify-content: space-between; + padding-block: var(--sl-size-075); + padding-inline: var(--sl-size-150) var(--sl-size-050); +} + +sl-button { + user-select: none; } .current-month, .current-year { - font-size: 1.2em; font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); - - sl-icon { - color: var(--sl-color-foreground-subtlest); - } } .current-year { - margin-inline: var(--sl-size-050) auto; + margin-inline-start: var(--sl-size-100); +} + +.previous-month { + margin-inline-start: 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); + grid-template-columns: repeat(var(--_days-in-week), var(--sl-size-450)); + line-height: calc((24 / 14) * 1em); + padding: var(--sl-size-050); } .day-of-week, @@ -48,29 +59,32 @@ } .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)); + position: relative; + + &::after { + background: var(--sl-color-border-neutral-plain); + content: ''; + inline-size: var(--sl-size-borderWidth-subtle); + inset: calc(var(--sl-size-050) * -1) 0 calc(var(--sl-size-050) * -1) auto; + position: absolute; + } } .scroller { align-items: start; display: flex; flex-grow: 1; - max-inline-size: calc(7 * var(--sl-size-450) + var(--sl-size-100)); + max-inline-size: calc(var(--_days-in-week) * var(--sl-size-450) + var(--sl-size-100)); outline: none; overflow: scroll hidden; - padding-block: var(--sl-size-050); + overscroll-behavior-x: contain; + scroll-padding-inline: 0; scroll-snap-type: x mandatory; scrollbar-width: none; } -sl-button { - user-select: none; -} - 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.spec.ts b/packages/components/calendar/src/select-day.spec.ts new file mode 100644 index 0000000000..641b04ef7d --- /dev/null +++ b/packages/components/calendar/src/select-day.spec.ts @@ -0,0 +1,546 @@ +import { announce } from '@sl-design-system/announcer'; +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { type LitElement, html } from 'lit'; +import sinon, { spy } from 'sinon'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { type MonthView } from './month-view.js'; +import { SelectDay } from './select-day.js'; + +vi.mock('@sl-design-system/announcer', { spy: true }); + +try { + customElements.define('sl-select-day', SelectDay); +} catch { + /* already defined */ +} + +describe('sl-select-day', () => { + let el: SelectDay; + + beforeEach(() => { + // March 2023 + // -------------------- + // Mo Tu We Th Fr Sa Su + // 27 28 1 2 3 4 5 + // 6 7 8 9 10 11 12 + // 13 14 15 16 17 18 19 + // 20 21 22 23 24 25 26 + // 27 28 29 30 31 1 2 + vi.setSystemTime(new Date(2023, 2, 14)); + }); + + afterEach(() => vi.useRealTimers()); + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should set displayMonth, nextMonth and previousMonth based on month', () => { + expect(el.displayMonth).to.equalDate(new Date(2023, 2, 1)); + expect(el.nextMonth).to.equalDate(new Date(2023, 3, 1)); + expect(el.previousMonth).to.equalDate(new Date(2023, 1, 1)); + }); + + it('should not be readonly', () => { + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + + expect(el.readonly).not.to.be.true; + expect(monthView?.readonly).not.to.be.true; + }); + + it('should be readonly when set', async () => { + el.readonly = true; + await el.updateComplete; + + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + expect(monthView).to.have.property('readonly', true); + }); + + it('should not have a selected date', () => { + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + + expect(el.selected).to.be.undefined; + expect(monthView?.selected).to.be.undefined; + }); + + it('should show the selected date when set', async () => { + const selectedDate = new Date(2023, 2, 10); + + el.selected = selectedDate; + await el.updateComplete; + + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + expect(monthView?.selected).to.equalDate(selectedDate); + }); + + it('should not have any disabled dates', () => { + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + + expect(el.disabledDates).to.be.undefined; + expect(monthView?.disabledDates).to.be.undefined; + }); + + it('should show disabled dates when set', async () => { + const disabledDates = [new Date(2023, 2, 5), new Date(2023, 2, 15), new Date(2023, 2, 25)]; + + el.disabledDates = disabledDates; + await el.updateComplete; + + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + expect(monthView?.disabledDates).to.have.lengthOf(3); + expect(monthView?.disabledDates?.at(0)).to.equalDate(disabledDates[0]); + expect(monthView?.disabledDates?.at(1)).to.equalDate(disabledDates[1]); + expect(monthView?.disabledDates?.at(2)).to.equalDate(disabledDates[2]); + }); + + it('should not have any indicator dates', () => { + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + + expect(el.indicatorDates).to.be.undefined; + expect(monthView?.indicatorDates).to.be.undefined; + }); + + it('should show indicator dates when set', async () => { + const indicatorDates = [ + { date: new Date(2023, 2, 8) }, + { date: new Date(2023, 2, 18) }, + { date: new Date(2023, 2, 28) } + ]; + + el.indicatorDates = indicatorDates; + await el.updateComplete; + + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + expect(monthView?.indicatorDates).to.have.lengthOf(3); + expect(monthView?.indicatorDates?.at(0)?.date).to.equalDate(indicatorDates[0].date); + expect(monthView?.indicatorDates?.at(1)?.date).to.equalDate(indicatorDates[1].date); + expect(monthView?.indicatorDates?.at(2)?.date).to.equalDate(indicatorDates[2].date); + }); + + it('should not show today indicator', () => { + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + + expect(el.showToday).not.to.be.true; + expect(monthView?.showToday).not.to.be.true; + }); + + it('should show today indicator when show-today is set', async () => { + el.showToday = true; + await el.updateComplete; + + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + expect(monthView).to.have.property('showToday', true); + }); + + it('should emit an sl-select event when a date is selected', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', onSelect); + el.renderRoot + .querySelector('sl-month-view:not([inert])') + ?.renderRoot.querySelector('button[part="day"]') + ?.click(); + await el.updateComplete; + + expect(onSelect).to.have.been.calledOnce; + }); + + it('should emit an sl-toggle "month" event when clicking current month button', async () => { + const onToggle = spy(); + + el.addEventListener('sl-toggle', onToggle); + el.renderRoot.querySelector('sl-button.current-month')?.click(); + await el.updateComplete; + + expect(onToggle).to.have.been.calledOnce; + expect(onToggle.lastCall.args[0]).to.have.property('detail', 'month'); + }); + + it('should emit an sl-toggle "year" event when clicking current year button', async () => { + const onToggle = spy(); + + el.addEventListener('sl-toggle', onToggle); + el.renderRoot.querySelector('sl-button.current-year')?.click(); + await el.updateComplete; + + expect(onToggle).to.have.been.calledOnce; + expect(onToggle.lastCall.args[0]).to.have.property('detail', 'year'); + }); + + describe('days of the week', () => { + it('should render the days of the week', () => { + const daysOfWeek = Array.from(el.renderRoot.querySelectorAll('.days-of-week .day-of-week')).map( + day => day.textContent?.trim() + ); + + expect(daysOfWeek).to.deep.equal(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + }); + + it('should have role list with listitems for days of the week', () => { + const daysOfWeek = el.renderRoot.querySelector('.days-of-week'), + dayElements = Array.from(daysOfWeek?.querySelectorAll('.day-of-week') ?? []); + + expect(daysOfWeek).to.have.attribute('role', 'list'); + expect(dayElements.every(d => d.getAttribute('role') === 'listitem')).to.be.true; + }); + + it('should not render the days of the week in the month-view', () => { + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'), + header = monthView?.renderRoot.querySelector('[part="header"]'); + + expect(header).to.exist; + expect(header).to.have.style('display', 'none'); + }); + + it('should start on Sunday when the first day of the week is 0', async () => { + el.firstDayOfWeek = 0; + await el.updateComplete; + + const daysOfWeek = Array.from(el.renderRoot.querySelectorAll('.days-of-week .day-of-week')).map( + day => day.textContent?.trim() + ); + + expect(daysOfWeek).to.deep.equal(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + }); + }); + + describe('week numbers', () => { + it('should not show the week numbers', () => { + expect(el.showWeekNumbers).not.to.be.true; + }); + + 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.have.trimmed.text('wk.'); + }); + + it('should localize the week number header', async () => { + el.showWeekNumbers = true; + el.locale = 'fi'; + await el.updateComplete; + + const weekHeader = el.renderRoot.querySelector('.days-of-week .week-number'); + expect(weekHeader).to.have.trimmed.text('vk'); + }); + + it('should show week numbers when show-week-numbers set', async () => { + el.showWeekNumbers = true; + await el.updateComplete; + + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + expect(monthView).to.have.property('showWeekNumbers', true); + }); + }); + }); + + describe('min/max', () => { + beforeEach(async () => { + el = await fixture(html``); + + // Wait for any initial scrolls to complete + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + it('should disable previous-month button when at min boundary', async () => { + el.min = new Date(2023, 2, 1); + await el.updateComplete; + + const prevButton = el.renderRoot.querySelector('sl-button.previous-month'); + + expect(prevButton).to.match(':disabled'); + }); + + it('should disable next-month button when at max boundary', async () => { + el.max = new Date(2023, 2, 31); + await el.updateComplete; + + const nextButton = el.renderRoot.querySelector('sl-button.next-month'); + + expect(nextButton).to.match(':disabled'); + }); + + it('should render only two month views when at min boundary', async () => { + el.min = new Date(2023, 2, 1); + await el.updateComplete; + + const monthViews = el.renderRoot.querySelectorAll('sl-month-view'); + + expect(monthViews).to.have.lengthOf(2); + }); + + it('should render only two month views when at max boundary', async () => { + el.max = new Date(2023, 2, 31); + await el.updateComplete; + + const monthViews = el.renderRoot.querySelectorAll('sl-month-view'); + + expect(monthViews).to.have.lengthOf(2); + }); + + it('should render only one month view when at both boundaries', async () => { + el.min = new Date(2023, 2, 1); + el.max = new Date(2023, 2, 31); + await el.updateComplete; + + const monthViews = el.renderRoot.querySelectorAll('sl-month-view'); + + expect(monthViews).to.have.lengthOf(1); + }); + + it('should not change month when at the min boundary and using the keyboard', async () => { + el.min = new Date(2023, 2, 1); + await el.updateComplete; + + const originalMonth = el.month; + el.renderRoot.querySelector('sl-month-view:not([inert])')?.focus(); + + await userEvent.keyboard('{ArrowLeft}'); + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.month).to.equalDate(originalMonth); + }); + + it('should not change month when at the max boundary and using the keyboard', async () => { + el.max = new Date(2023, 2, 31); + await el.updateComplete; + + const originalMonth = el.month; + el.renderRoot.querySelector('sl-month-view:not([inert])')?.focus(new Date(2023, 2, 31)); + + await userEvent.keyboard('{ArrowRight}'); + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.month).to.equalDate(originalMonth); + }); + }); + + describe('navigation', () => { + beforeEach(async () => { + el = await fixture(html``); + + // Wait for any initial scrolls to complete + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + describe('back', () => { + it('should show the previous month when using the keyboard', async () => { + el.renderRoot.querySelector('sl-month-view:not([inert])')?.focus(); + + await userEvent.keyboard('{ArrowLeft}'); + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.month).to.equalDate(new Date(2023, 1, 1)); + }); + + it('should scroll to the previous month when using the mouse', async () => { + const scrollendPromise = new Promise(resolve => { + el.renderRoot + .querySelector('.scroller') + ?.addEventListener('scrollend', () => resolve(), { once: true }); + }); + + el.renderRoot.querySelector('sl-button.previous-month')?.click(); + await el.updateComplete; + await scrollendPromise; + + expect(el.month).to.equalDate(new Date(2023, 1, 1)); + }); + + it('should focus the last day of the previous month when using the keyboard', async () => { + el.renderRoot.querySelector('sl-month-view:not([inert])')?.focus(); + + await userEvent.keyboard('{ArrowLeft}'); + await new Promise(resolve => requestAnimationFrame(resolve)); + + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + + expect(monthView?.shadowRoot?.activeElement).to.exist; + expect(monthView?.shadowRoot?.activeElement?.tagName).to.equal('BUTTON'); + expect(monthView?.shadowRoot?.activeElement).to.have.trimmed.text('28'); + }); + + it('should announce the previous month when using the mouse', async () => { + el.renderRoot.querySelector('sl-button.previous-month')?.click(); + + // Wait for the announcement to be made from a setTimeout + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(announce).toHaveBeenCalledWith('February 2023', 'polite'); + }); + }); + + describe('forward', () => { + it('should show the next month when using the keyboard', async () => { + el.renderRoot.querySelector('sl-month-view:not([inert])')?.focus(new Date(2023, 2, 31)); + + await userEvent.keyboard('{ArrowRight}'); + await new Promise(resolve => requestAnimationFrame(resolve)); + + expect(el.month).to.equalDate(new Date(2023, 3, 1)); + }); + + it('should scroll to the next month when using the mouse', async () => { + const scrollendPromise = new Promise(resolve => { + el.renderRoot + .querySelector('.scroller') + ?.addEventListener('scrollend', () => resolve(), { once: true }); + }); + + el.renderRoot.querySelector('sl-button.next-month')?.click(); + await scrollendPromise; + + expect(el.month).to.equalDate(new Date(2023, 3, 1)); + }); + + it('should focus the first day of the next month when using the keyboard', async () => { + el.renderRoot.querySelector('sl-month-view:not([inert])')?.focus(new Date(2023, 2, 31)); + + await userEvent.keyboard('{ArrowRight}'); + await new Promise(resolve => requestAnimationFrame(resolve)); + + const monthView = el.renderRoot.querySelector('sl-month-view:not([inert])'); + + expect(monthView?.shadowRoot?.activeElement).to.exist; + expect(monthView?.shadowRoot?.activeElement?.tagName).to.equal('BUTTON'); + expect(monthView?.shadowRoot?.activeElement).to.have.trimmed.text('1'); + }); + + it('should announce the next month when using the mouse', async () => { + el.renderRoot.querySelector('sl-button.next-month')?.click(); + + // Wait for the announcement to be made from a setTimeout + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(announce).toHaveBeenCalledWith('April 2023', 'polite'); + }); + }); + }); + + describe('scrolling', () => { + beforeEach(async () => { + el = await fixture(html``); + + // Wait for any initial scrolls to complete + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + it('should render three month views (previous, current, next) by default', () => { + const monthViews = el.renderRoot.querySelectorAll('sl-month-view'); + + expect(monthViews).to.have.lengthOf(3); + }); + + it('should set previous and next month views as inert', () => { + const monthViews = Array.from(el.renderRoot.querySelectorAll('sl-month-view')); + + expect(monthViews[0]).to.have.attribute('inert'); + expect(monthViews[1]).not.to.have.attribute('inert'); + expect(monthViews[2]).to.have.attribute('inert'); + }); + + it('should set previous and next month views as aria-hidden', () => { + const monthViews = Array.from(el.renderRoot.querySelectorAll('sl-month-view')); + + expect(monthViews[0]).to.have.attribute('aria-hidden', 'true'); + expect(monthViews[1]).not.to.have.attribute('aria-hidden'); + expect(monthViews[2]).to.have.attribute('aria-hidden', 'true'); + }); + + it('should scroll to center month on initialization', async () => { + const scroller = el.renderRoot.querySelector('.scroller'); + + // Wait for resize observer to trigger + await new Promise(resolve => setTimeout(resolve, 50)); + + // The scroll position should be at the center (width * 1) + expect(scroller?.scrollLeft).to.be.greaterThan(0); + }); + + it('should update displayMonth when scrolling to >= 50% visibility', async () => { + const initialDisplayMonth = el.displayMonth; + + const scrollendPromise = new Promise(resolve => { + el.renderRoot + .querySelector('.scroller') + ?.addEventListener('scrollend', () => resolve(), { once: true }); + }); + + el.renderRoot.querySelector('sl-button.next-month')?.click(); + await scrollendPromise; + + expect(el.displayMonth).not.to.equalDate(initialDisplayMonth!); + }); + + it('should use smooth scrolling when clicking navigation buttons', async () => { + const scrollToSpy = spy(el.scroller!, 'scrollTo'); + + el.renderRoot.querySelector('sl-button.next-month')?.click(); + await el.updateComplete; + + expect(scrollToSpy).to.have.been.calledWith( + sinon.match({ + behavior: 'smooth' + }) + ); + }); + + it('should update month views when month changes', async () => { + el.month = new Date(2023, 5, 15); // June 2023; + await el.updateComplete; + + const monthViews = Array.from(el.renderRoot.querySelectorAll('sl-month-view')); + + // Check that the internal state is correct + expect(el.displayMonth).to.equalDate(new Date(2023, 5, 1)); // June + expect(el.previousMonth).to.equalDate(new Date(2023, 4, 1)); // May + expect(el.nextMonth).to.equalDate(new Date(2023, 6, 1)); // July + + // Verify month views have correct months (they receive the actual date, not normalized to day 1) + expect(monthViews[0].month).to.equalDate(new Date(2023, 4, 1)); + expect(monthViews[1].month).to.equalDate(new Date(2023, 5, 15)); + expect(monthViews[2].month).to.equalDate(new Date(2023, 6, 1)); + }); + + it('should scroll to position 0 when at min boundary', async () => { + el.min = new Date(2023, 2, 1); + await el.updateComplete; + + const scroller = el.renderRoot.querySelector('.scroller'); + + // Wait for resize observer to trigger scroll + await new Promise(resolve => requestAnimationFrame(resolve)); + + // Current month should be at position 0 (no previous month rendered) + expect(scroller?.scrollLeft).to.equal(0); + }); + + it('should scroll to position 1 width when at max boundary', async () => { + el.max = new Date(2023, 2, 31); + await el.updateComplete; + + const scroller = el.renderRoot.querySelector('.scroller'), + { width } = scroller!.getBoundingClientRect(); + + // Wait for resize observer to trigger scroll + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(scroller?.scrollLeft).to.equal(width); + }); + + it('should keep current month view active (not inert) when only one view', async () => { + el.min = new Date(2023, 2, 1); + el.max = new Date(2023, 2, 31); + await el.updateComplete; + + const monthView = el.renderRoot.querySelector('sl-month-view'); + + expect(monthView).not.to.have.attribute('inert'); + expect(monthView).not.to.have.attribute('aria-hidden'); + }); + }); +}); diff --git a/packages/components/calendar/src/select-day.stories.ts b/packages/components/calendar/src/select-day.stories.ts new file mode 100644 index 0000000000..23a9630429 --- /dev/null +++ b/packages/components/calendar/src/select-day.stories.ts @@ -0,0 +1,216 @@ +import { type Meta, type StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import '../register.js'; +import { SelectDay } from './select-day.js'; + +type Props = Pick< + SelectDay, + | 'disabledDates' + | 'firstDayOfWeek' + | 'indicatorDates' + | 'max' + | 'min' + | 'month' + | 'readonly' + | 'selected' + | 'showToday' + | 'showWeekNumbers' +>; +type Story = StoryObj; + +try { + customElements.define('sl-select-day', SelectDay); +} catch { + /* empty */ +} + +export default { + title: 'Date & Time/Calendar/Select Day', + tags: ['draft'], + args: { + month: new Date(), + showToday: true + }, + argTypes: { + disabledDates: { + control: 'object' + }, + firstDayOfWeek: { + control: 'inline-radio', + options: [0, 1] + }, + indicatorDates: { + control: 'object' + }, + max: { + control: 'date' + }, + min: { + control: 'date' + }, + month: { + control: 'date' + }, + readonly: { + control: 'boolean' + }, + selected: { + control: 'date' + }, + showToday: { + control: 'boolean' + }, + showWeekNumbers: { + control: 'boolean' + } + }, + render: ({ + disabledDates, + firstDayOfWeek, + indicatorDates, + max, + min, + month, + readonly, + selected, + showToday, + showWeekNumbers + }) => html` + + ` +} satisfies Meta; + +export const Basic: Story = {}; + +export const DisabledDates: Story = { + args: { + disabledDates: [ + new Date(new Date().getFullYear(), new Date().getMonth(), 5), + new Date(new Date().getFullYear(), new Date().getMonth(), 12), + new Date(new Date().getFullYear(), new Date().getMonth(), 19) + ] + } +}; + +export const Indicators: Story = { + args: { + indicatorDates: [ + { date: new Date(new Date().getFullYear(), new Date().getMonth(), 3), color: 'blue', label: 'Meeting' }, + { date: new Date(new Date().getFullYear(), new Date().getMonth(), 8), color: 'green', label: 'Event' }, + { date: new Date(new Date().getFullYear(), new Date().getMonth(), 15), color: 'red', label: 'Deadline' } + ] + } +}; + +export const Max: Story = { + args: { + max: new Date(new Date().getFullYear(), new Date().getMonth(), 20), + month: new Date() + } +}; + +export const Min: Story = { + args: { + min: new Date(new Date().getFullYear(), new Date().getMonth(), 10), + month: new Date() + } +}; + +export const MinMax: Story = { + args: { + min: new Date(new Date().getFullYear(), new Date().getMonth(), 10), + max: new Date(new Date().getFullYear(), new Date().getMonth(), 20), + month: new Date() + } +}; + +export const Readonly: Story = { + args: { + readonly: true, + selected: new Date() + } +}; + +export const Selected: Story = { + args: { + selected: new Date() + } +}; + +export const SundayFirst: Story = { + args: { + firstDayOfWeek: 0 + } +}; + +export const WeekNumbers: Story = { + args: { + showWeekNumbers: true + } +}; + +export const All: Story = { + render: () => html` + +
+
+

Basic

+ +
+
+

With Week Numbers

+ +
+
+

Sunday First

+ +
+
+

With Selection

+ +
+
+

Readonly

+ +
+
+

With Indicators

+ +
+
+ ` +}; diff --git a/packages/components/calendar/src/select-day.ts b/packages/components/calendar/src/select-day.ts index a557c12fb5..52d6baa820 100644 --- a/packages/components/calendar/src/select-day.ts +++ b/packages/components/calendar/src/select-day.ts @@ -1,5 +1,6 @@ -import { msg, str } from '@lit/localize'; +import { localized, msg, str } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { announce } from '@sl-design-system/announcer'; import { Button } from '@sl-design-system/button'; import { FormatDate, format } from '@sl-design-system/format-date'; import { Icon } from '@sl-design-system/icon'; @@ -11,21 +12,17 @@ import { property, query, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { MonthView } from './month-view.js'; import styles from './select-day.scss.js'; -import { getWeekdayNames, normalizeDateTime } from './utils.js'; +import { Indicator, getWeekdayNames, indicatorConverter, isSameDate, normalizeDateTime } from './utils.js'; declare global { - // These are too new to be in every TypeScript version yet - interface Event { - snapTargetBlock?: Element; - snapTargetInline?: Element; - } - interface HTMLElementTagNameMap { 'sl-select-day': SelectDay; } } +@localized() export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { + /** @internal */ static get scopedElements(): ScopedElementsMap { return { 'sl-button': Button, @@ -41,8 +38,55 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal */ static override styles: CSSResultGroup = styles; - /** Ignore snap events before initialized. */ - #initialized = false; + /** Timeout id, to be used with `clearTimeout`. */ + #announceTimeoutId?: ReturnType; + + /** Use an intersection observer as a workaround until `scrollsnapchange` events are widely supported. */ + #intersectionObserver?: IntersectionObserver; + + /** The currently observed month views. */ + #observedMonths?: NodeListOf; + + /** + * Use a resize observer as a cross browser solution to know when to initialize the intersection observer + * and also to know when to center the current month in the scroller during initialization. + */ + #resizeObserver = new ResizeObserver(async () => { + if (!this.#intersectionObserver) { + this.#intersectionObserver = new IntersectionObserver( + entries => { + entries + .filter(entry => entry.isIntersecting) + .forEach(entry => { + if (entry.intersectionRatio >= 0.5) { + const monthView = entry.target as MonthView, + displayMonth = normalizeDateTime(monthView.month); + + // Do not trigger unnecessary renders + if (!isSameDate(this.displayMonth, displayMonth)) { + this.displayMonth = displayMonth; + } + } + }); + }, + { root: this.scroller, threshold: [0, 0.5, 1] } + ); + + // Firefox only: wait for the scroller and month views to be rendered + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + // Center the current month initially + this.#scrollToMonth(0); + + // Start observing month views + this.#observedMonths = this.renderRoot.querySelectorAll('sl-month-view'); + this.#observedMonths.forEach(mv => this.#intersectionObserver?.observe(mv)); + } + }); + + /** The list of dates that should be set as disabled. */ + @property({ attribute: 'disabled-dates', converter: dateConverter }) disabledDates?: Date[]; /** @internal The month/year that will be displayed in the header. */ @state() displayMonth?: Date; @@ -50,6 +94,17 @@ 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-dates', + converter: indicatorConverter + }) + indicatorDates?: Indicator[]; + /** @internal The localized "week of year" label. */ @state() localizedWeekOfYear?: string; @@ -66,7 +121,7 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { @property({ converter: dateConverter }) min?: Date; /** The month that is shown. */ - @property({ converter: dateConverter }) month?: Date; + @property({ converter: dateConverter }) month = new Date(); /** @internal The next month in the calendar. */ @state() nextMonth?: Date; @@ -98,10 +153,24 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { /** @internal The translated days of the week. */ @state() weekDays: Array<{ long: string; short: string }> = []; - override firstUpdated(changes: PropertyValues): void { - super.firstUpdated(changes); + override connectedCallback(): void { + super.connectedCallback(); + + this.#resizeObserver.observe(this); + } + + override disconnectedCallback(): void { + this.#resizeObserver.disconnect(); + + this.#intersectionObserver?.disconnect(); + this.#intersectionObserver = undefined; + + if (this.#announceTimeoutId) { + clearTimeout(this.#announceTimeoutId); + this.#announceTimeoutId = undefined; + } - requestAnimationFrame(() => this.#scrollToMonth(0)); + super.disconnectedCallback(); } override willUpdate(changes: PropertyValues): void { @@ -122,142 +191,241 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { } if (changes.has('month') && this.month) { - this.displayMonth = this.month; + this.displayMonth = new Date(this.month.getFullYear(), this.month.getMonth()); this.nextMonth = new Date(this.month.getFullYear(), this.month.getMonth() + 1); this.previousMonth = new Date(this.month.getFullYear(), this.month.getMonth() - 1); } + + // if (changes.has('max') || changes.has('min') || changes.has('month')) { + // this.#observedMonths?.forEach(mv => this.#intersectionObserver?.unobserve(mv)); + // this.#observedMonths = undefined; + // } + } + + override updated(changes: PropertyValues): void { + super.updated(changes); + + // if (changes.has('max') || changes.has('min') || changes.has('month')) { + // this.#scrollToMonth(0); + + // this.#observedMonths = this.renderRoot.querySelectorAll('sl-month-view'); + // this.#observedMonths.forEach(mv => this.#intersectionObserver?.observe(mv)); + // } } override render(): TemplateResult { + const canSelectNextMonth = this.#canSelectNextMonth(), + canSelectPreviousMonth = this.#canSelectPreviousMonth(), + 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; + return html` -
- - - - - - - - +
+ ${canSelectPreviousMonth || canSelectNextMonth + ? html` + + + + + ` + : html` + + + + `} + ${canSelectPreviousYear || canSelectNextYear + ? html` + + + + + ` + : html` + + + + `} + -
-
+ + +
${this.showWeekNumbers ? html` - ${this.localizedWeekOfYear} + + ${this.localizedWeekOfYear} + ` : nothing} - ${this.weekDays.map(day => html`${day.short}`)} + ${this.weekDays.map( + day => html`${day.short}` + )}
-
- + +
+ ${canSelectPreviousMonth + ? html` + + ` + : nothing} - + ${canSelectNextMonth + ? html` + + ` + : nothing}
`; } - async #onChange(event: SlChangeEvent): Promise { - event.preventDefault(); - event.stopPropagation(); + #onChange(event: SlChangeEvent): void { + const newMonth = new Date(event.detail.getFullYear(), event.detail.getMonth()); - this.month = new Date(event.detail.getFullYear(), event.detail.getMonth()); + // Check if the new month is within min/max boundaries + if (this.min) { + const minMonth = new Date(this.min.getFullYear(), this.min.getMonth()); + if (newMonth < minMonth) { + return; // Don't change month if it would go below min + } + } + + if (this.max) { + const maxMonth = new Date(this.max.getFullYear(), this.max.getMonth()); + if (newMonth > maxMonth) { + return; // Don't change month if it would go above max + } + } - // Wait for the month views to rerender before focusing the day - await this.updateComplete; + this.month = newMonth; requestAnimationFrame(() => { - this.renderRoot.querySelector('sl-month-view:nth-child(2)')?.focusDay(event.detail); + this.renderRoot.querySelector('sl-month-view:not([inert])')?.focus(event.detail); }); } #onPrevious(): void { this.#scrollToMonth(-1, true); + this.#announce(this.previousMonth); } #onNext(): void { this.#scrollToMonth(1, true); + this.#announce(this.nextMonth); } - #onScrollEnd(): void { - this.#initialized = true; - } + async #onScrollEnd(): Promise { + if (!this.displayMonth || isSameDate(this.month, this.displayMonth)) { + return; + } - #onScrollSnapChange(event: Event): void { - if (!this.#initialized) return; + // Stop observing month views while we adjust the scroll position + this.#observedMonths?.forEach(mv => this.#intersectionObserver?.unobserve(mv)); + this.#observedMonths = undefined; - this.month = normalizeDateTime((event.snapTargetInline as MonthView).month!); - this.#scrollToMonth(0); - } + // Update the month, so it rerenders the month-views + this.month = normalizeDateTime(this.displayMonth); + + if ('onscrollend' in this.scroller!) { + await this.updateComplete; + } else { + // Safari <= 26 only: wait for the scroller and month views to be rendered + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + } - #onScrollSnapChanging(event: Event): void { - if (!this.#initialized) return; + // Now instantly scroll back to the center month (so the user doesn't notice) + this.#scrollToMonth(0); - this.displayMonth = normalizeDateTime((event.snapTargetInline as MonthView).month!); + // Start observing month views again + this.#observedMonths = this.renderRoot.querySelectorAll('sl-month-view'); + this.#observedMonths.forEach(mv => this.#intersectionObserver?.observe(mv)); } #onSelect(event: SlSelectEvent): void { @@ -275,10 +443,92 @@ export class SelectDay extends LocaleMixin(ScopedElementsMixin(LitElement)) { this.toggleEvent.emit('year'); } + #announce(month?: Date): void { + if (!month) { + return; + } + + // Clear any pending announcement + if (this.#announceTimeoutId) { + clearTimeout(this.#announceTimeoutId); + } + + // Announce if needed, we don't want to have the same message announced twice + this.#announceTimeoutId = setTimeout(() => { + const monthFormatted = format(month, this.locale, { month: 'long', year: 'numeric' }); + + announce(`${monthFormatted}`, 'polite'); + + this.#announceTimeoutId = undefined; + }, 50); + } + + #canSelectNextMonth(): boolean { + if (!this.nextMonth) { + return false; + } + + if (!this.max) { + return true; + } + + const nextMonthNormalized = new Date(this.nextMonth.getFullYear(), this.nextMonth.getMonth()), + maxMonthNormalized = new Date(this.max.getFullYear(), this.max.getMonth()); + + return nextMonthNormalized <= maxMonthNormalized; + } + + #canSelectPreviousMonth(): boolean { + if (!this.previousMonth) { + return false; + } + + if (!this.min) { + return true; + } + + const previousMonthNormalized = new Date(this.previousMonth.getFullYear(), this.previousMonth.getMonth()), + minMonthNormalized = new Date(this.min.getFullYear(), this.min.getMonth()); + + return previousMonthNormalized >= minMonthNormalized; + } + #scrollToMonth(month: -1 | 0 | 1, smooth = false): void { - const width = parseInt(getComputedStyle(this).width), - left = width * month + width; + if (!this.scroller) { + return; + } - this.scroller?.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); + const width = parseInt(getComputedStyle(this).width) || 0, + canSelectPrevious = this.#canSelectPreviousMonth(), + canSelectNext = this.#canSelectNextMonth(); + + // Calculate scroll position based on which month views are rendered + // If previous month is not rendered, current month is at position 0 + // If previous month is rendered, current month is at position 1 + const currentMonthPosition = canSelectPrevious ? 1 : 0; + let left: number; + + if (month === -1) { + // Scroll to previous month (position 0 if it exists) + left = 0; + } else if (month === 1) { + // Scroll to next month + if (canSelectPrevious && canSelectNext) { + left = width * 2; // position 2 + } else if (canSelectNext) { + left = width; // position 1 + } else { + left = width * currentMonthPosition; // stay at current + } + } else { + // month === 0, scroll to current month + left = width * currentMonthPosition; + } + + if (smooth) { + this.scroller.scrollTo({ left, behavior: 'smooth' }); + } else if (this.scroller.scrollLeft !== left) { + this.scroller.scrollLeft = left; + } } } diff --git a/packages/components/calendar/src/select-month.scss b/packages/components/calendar/src/select-month.scss index b72f2f51df..60c286faf6 100644 --- a/packages/components/calendar/src/select-month.scss +++ b/packages/components/calendar/src/select-month.scss @@ -3,39 +3,109 @@ display: inline-flex; flex-direction: column; gap: var(--sl-size-100); - padding: var(--sl-size-075); + padding: var(--sl-size-025); } -[part='header'] { +:host([show-current]) .current span { + border-color: var(--sl-color-border-bold); +} + +:host([show-current]) .current.selected span { + 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); +} + +header { align-items: center; display: flex; + gap: var(--sl-size-100); + padding-inline: var(--sl-size-150) var(--sl-size-050); + + .current-year { + font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); + margin-inline-end: auto; + } +} + +sl-button { + user-select: none; } .current-year { - font-size: 1.2em; font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); margin-inline-end: auto; } -sl-button { - user-select: none; +table { + block-size: 100%; + border-collapse: separate; + border-spacing: var(--sl-size-100); + box-sizing: content-box; + inline-size: 100%; + margin: 0; } -ol { - display: grid; - gap: var(--sl-size-050); - grid-template-columns: repeat(3, auto); - list-style: none; +td { margin: 0; padding: 0; + text-align: center; } -li { +button { + --_bg-color: transparent; + --_bg-mix-color: var(--sl-color-background-info-interactive-plain); + --_bg-opacity: var(--sl-opacity-interactive-plain-idle); + + appearance: none; + background: transparent; + border: 0; + border-radius: var(--sl-size-borderRadius-default); + box-sizing: border-box; + color: var(--sl-color-foreground-plain); + cursor: pointer; display: inline-flex; - margin: 0; - padding: 0; + font: inherit; + outline: none; + padding: var(--sl-size-050); + place-items: center; + + &:disabled { + color: var(--sl-color-foreground-disabled); + cursor: default; + pointer-events: none; + } + + &:hover { + --_bg-opacity: var(--sl-opacity-interactive-plain-hover); + } - sl-button { - flex: 1; + &:active { + --_bg-opacity: var(--sl-opacity-interactive-plain-active); } + + &:focus-visible span { + outline-color: var(--sl-color-border-focused); + } + + span { + 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); + outline: transparent solid var(--sl-size-outlineWidth-default); + outline-offset: var(--sl-size-outlineOffset-default); + padding: calc(var(--sl-size-025) - var(--sl-size-borderWidth-subtle)) + calc(var(--sl-size-100) - var(--sl-size-borderWidth-action)); + + @media (prefers-reduced-motion: no-preference) { + transition: 0.2s ease-in-out; + transition-property: background, border-radius, color; + } + } +} + +.selected { + --_bg-color: var(--sl-color-background-selected-bold); + --_bg-mix-color: var(--sl-color-background-selected-interactive-bold); + + color: var(--sl-color-foreground-selected-onBold); } 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..3c2c9c4f91 --- /dev/null +++ b/packages/components/calendar/src/select-month.spec.ts @@ -0,0 +1,235 @@ +import { Button } from '@sl-design-system/button'; +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { spy } from 'sinon'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { SelectMonth } from './select-month.js'; + +try { + customElements.define('sl-select-month', SelectMonth); +} catch { + /* empty */ +} + +// Make sure the tests don't break when a new year starts +const currentYear = new Date().getFullYear(); + +describe('sl-select-month', () => { + let el: SelectMonth; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render 12 months', () => { + const months = el.renderRoot.querySelectorAll('table button'); + + expect(months).to.have.lengthOf(12); + expect(el.months).to.have.lengthOf(12); + }); + + it('should show a year toggle button', () => { + const toggleBtn = el.renderRoot.querySelector('sl-button.current-year'); + + expect(toggleBtn).to.exist; + }); + + it('should not show the current month', () => { + expect(el.showCurrent).not.to.be.true; + }); + + it('should highlight the current month when show-current is set', async () => { + el.showCurrent = true; + await el.updateComplete; + + const currentMonthButton = el.renderRoot.querySelector('button.current'); + + expect(currentMonthButton).to.exist; + expect(currentMonthButton).to.have.trimmed.text(new Date().toLocaleString('default', { month: 'long' })); + }); + + it('should not have a selected month', () => { + const selectedMonthButton = el.renderRoot.querySelector('button.selected'); + + expect(selectedMonthButton).to.not.exist; + expect(el.selected).to.be.undefined; + }); + + it('should show the selected month when set', async () => { + el.selected = new Date(currentYear, 5, 1); // June + await el.updateComplete; + + const selectedMonthButton = el.renderRoot.querySelector('button.selected'); + + expect(selectedMonthButton).to.exist; + expect(selectedMonthButton).to.have.trimmed.text('June'); + }); + + it('should have enabled previous and next year buttons', () => { + const prev = el.renderRoot.querySelector('sl-button[aria-label^="Previous year"]'), + 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'); + }); + + it('should emit sl-select with selected month when clicked', () => { + const onSelect = spy(); + + el.addEventListener('sl-select', (e: CustomEvent) => { + onSelect(e.detail); + }); + el.renderRoot.querySelector('button')?.click(); + + expect(onSelect).to.have.been.calledOnce; + expect(onSelect.lastCall.args[0]).to.equalDate(new Date(currentYear, 0, 1)); + }); + + it('should emit sl-select with selected month on enter', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', (e: CustomEvent) => { + onSelect(e.detail); + }); + + el.renderRoot.querySelector('button')?.focus(); + await userEvent.keyboard('{Enter}'); + + expect(onSelect).to.have.been.calledOnce; + expect(onSelect.lastCall.args[0]).to.equalDate(new Date(currentYear, 0, 1)); + }); + + it('should emit sl-select with selected month on space', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', (e: CustomEvent) => { + onSelect(e.detail); + }); + + el.renderRoot.querySelector('button')?.focus(); + await userEvent.keyboard(' '); + + expect(onSelect).to.have.been.calledOnce; + expect(onSelect.lastCall.args[0]).to.equalDate(new Date(currentYear, 0, 1)); + }); + }); + + describe('navigation', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should increment year when next is clicked', async () => { + el.renderRoot.querySelector
${week.number}
+ ${week.number} +
${this.localizedWeekOfYear}${day.short}
+ ${this.localizedWeekOfYear} + ${day.short}
this.#onClick(event, day)} data-date=${day.date.toISOString()}>${template}${template}
+ + ${rows.map( + (row, rowIndex) => html` + + ${row.map((month, colIndex) => this.renderMonth(month, rowIndex, colIndex))} + + ` + )} + +
+ `; + } + + renderMonth(month: Month, rowIndex: number, colIndex: number): TemplateResult { + const current = month.value === new Date().getMonth() && this.month.getFullYear() === new Date().getFullYear(), + selected = !!( + this.selected && + this.selected.getMonth() === month.value && + this.selected.getFullYear() === month.date.getFullYear() + ); + + return html` + + + `; } #onClick(month: number): void { this.selectEvent.emit(new Date(this.month.getFullYear(), month)); + this.selected = new Date(this.month.getFullYear(), month); } - #onKeydown(event: KeyboardEvent): void { - if (event.key === 'Escape') { + /** + * For arrow keys, we need to detect if we're at a visual boundary (first/last button position) + * and trying to navigate beyond it AND navigation is not blocked by min/max constraints. + * If we can load a new range, do so. Otherwise, let the focus group controller handle it. + */ + async #onKeydown(event: KeyboardEvent & { target: HTMLButtonElement }): Promise { + const buttons = Array.from(this.buttons), + currentIndex = buttons.indexOf(event.target); + + if (currentIndex === -1) { + return; + } + + const canGoEarlier = !this.#allYearDisabled(-1), + canGoLater = !this.#allYearDisabled(1); + + let shouldLoadNewRange = false; + + // Check if we're at a visual boundary position, trying to navigate beyond it, + // and not blocked by min/max constraints + if (event.key === 'ArrowLeft' && currentIndex === 0 && canGoEarlier) { + shouldLoadNewRange = true; event.preventDefault(); - event.stopPropagation(); + this.#onPrevious(); + } else if (event.key === 'ArrowRight' && currentIndex === buttons.length - 1 && canGoLater) { + shouldLoadNewRange = true; + event.preventDefault(); + this.#onNext(); + } else if (event.key === 'ArrowUp' && currentIndex < this.#cols && canGoEarlier) { + shouldLoadNewRange = true; + event.preventDefault(); + this.#onPrevious(); + } else if (event.key === 'ArrowDown' && currentIndex >= buttons.length - this.#cols && canGoLater) { + shouldLoadNewRange = true; + event.preventDefault(); + this.#onNext(); + } + + if (shouldLoadNewRange) { + await this.updateComplete; + + const newButtons = Array.from(this.buttons), + newEnabledButtons = newButtons.filter(b => !b.disabled); + + let targetButton: HTMLButtonElement | undefined; + + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + targetButton = newEnabledButtons.at(-1); + } else { + targetButton = newEnabledButtons.at(0); + } - this.selectEvent.emit(this.month); + targetButton?.focus(); } + + // Otherwise, let the event bubble to the focus group controller } #onNext(): void { this.month = new Date(this.month.getFullYear() + 1, this.month.getMonth(), this.month.getDate()); + + this.#announce(this.month); } #onPrevious(): void { this.month = new Date(this.month.getFullYear() - 1, this.month.getMonth(), this.month.getDate()); + + this.#announce(this.month); + } + + #onToggleYearSelect(): void { + this.toggleEvent.emit('year'); + } + + #isDisabled(year: number, month: number): boolean { + const date = new Date(year, month, 1); + + if (this.min && date < new Date(this.min.getFullYear(), this.min.getMonth(), 1)) { + return true; + } + + return !!(this.max && date > new Date(this.max.getFullYear(), this.max.getMonth(), 1)); + } + + #allYearDisabled(offset: number): boolean { + const year = this.month.getFullYear() + offset; + + return this.months.every(m => this.#isDisabled(year, m.value)); + } + + #canSelectYear(offset: number): boolean { + const targetYear = this.month.getFullYear() + offset; + + if (offset > 0) { + return !this.max || targetYear <= this.max.getFullYear(); + } else { + return !this.min || targetYear >= this.min.getFullYear(); + } + } + + // Announce if needed, we don't want to have the same message announced twice + #announce(month: Date): void { + // Clear any pending announcement + if (this.#announceTimeoutId) { + clearTimeout(this.#announceTimeoutId); + } + + // Set a short timeout to debounce multiple calls + this.#announceTimeoutId = setTimeout(() => { + announce( + msg(str`Months of the year ${Intl.DateTimeFormat(this.locale, { year: 'numeric' }).format(month)}`, { + id: 'sl.calendar.announceMonthsOfYear' + }), + 'polite' + ); + + this.#announceTimeoutId = undefined; + }, 50); } } diff --git a/packages/components/calendar/src/select-year.scss b/packages/components/calendar/src/select-year.scss index bfa8f43849..7d11f0f29d 100644 --- a/packages/components/calendar/src/select-year.scss +++ b/packages/components/calendar/src/select-year.scss @@ -3,39 +3,107 @@ display: inline-flex; flex-direction: column; gap: var(--sl-size-100); - padding: var(--sl-size-075); + padding: var(--sl-size-025); } -[part='header'] { +:host([show-current]) .current span { + border-color: var(--sl-color-border-bold); +} + +:host([show-current]) .current.selected span { + 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); +} + +header { align-items: center; display: flex; -} + padding-inline: var(--sl-size-150) var(--sl-size-050); + + span { + font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); + margin-inline-end: auto; + } -.current-range { - font-size: 1.2em; - font-weight: var(--sl-text-new-typeset-fontWeight-semiBold); - margin-inline-end: auto; + sl-button + sl-button { + margin-inline-start: var(--sl-size-100); + } } sl-button { user-select: none; } -ol { - display: grid; - gap: var(--sl-size-050); - grid-template-columns: repeat(3, auto); - list-style: none; +table { + block-size: 100%; + border-collapse: separate; + border-spacing: var(--sl-size-100); + box-sizing: content-box; + inline-size: 100%; margin: 0; - padding: 0; } -li { - display: inline-flex; +td { margin: 0; padding: 0; + text-align: center; +} - sl-button { - flex: 1; +button { + --_bg-color: transparent; + --_bg-mix-color: var(--sl-color-background-info-interactive-plain); + --_bg-opacity: var(--sl-opacity-interactive-plain-idle); + + appearance: none; + background: transparent; + border: 0; + border-radius: var(--sl-size-borderRadius-default); + box-sizing: border-box; + color: var(--sl-color-foreground-plain); + cursor: pointer; + display: inline-flex; + font: inherit; + outline: none; + padding: var(--sl-size-050); + place-items: center; + + &:disabled { + color: var(--sl-color-foreground-disabled); + cursor: default; + pointer-events: none; + } + + &:hover { + --_bg-opacity: var(--sl-opacity-interactive-plain-hover); + } + + &:active { + --_bg-opacity: var(--sl-opacity-interactive-plain-active); + } + + &:focus-visible span { + outline-color: var(--sl-color-border-focused); } + + span { + 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); + outline: transparent solid var(--sl-size-outlineWidth-default); + outline-offset: var(--sl-size-outlineOffset-default); + padding: calc(var(--sl-size-025) - var(--sl-size-borderWidth-subtle)) + calc(var(--sl-size-100) - var(--sl-size-borderWidth-action)); + + @media (prefers-reduced-motion: no-preference) { + transition: 0.2s ease-in-out; + transition-property: background, border-radius, color; + } + } +} + +.selected { + --_bg-color: var(--sl-color-background-selected-bold); + --_bg-mix-color: var(--sl-color-background-selected-interactive-bold); + + color: var(--sl-color-foreground-selected-onBold); } 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..487b57abdb --- /dev/null +++ b/packages/components/calendar/src/select-year.spec.ts @@ -0,0 +1,311 @@ +import { Button } from '@sl-design-system/button'; +import { type SlSelectEvent } from '@sl-design-system/shared/events.js'; +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { html } from 'lit'; +import { spy } from 'sinon'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import '../register.js'; +import { SelectYear } from './select-year.js'; + +try { + customElements.define('sl-select-year', SelectYear); +} catch { + /* empty */ +} + +// Make sure the tests don't break when a new year starts +const currentYear = new Date().getFullYear(); + +describe('sl-select-year', () => { + let el: SelectYear; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should render 12 years', () => { + const years = Array.from(el.renderRoot.querySelectorAll('table button')); + + expect(years).to.have.lengthOf(12); + expect(el.years.at(0)).to.equal(currentYear - 5); + expect(el.years.at(-1)).to.equal(currentYear + 6); + }); + + it('should not show the current year', () => { + expect(el.showCurrent).not.to.be.true; + }); + + it('should highlight the current year when show-current is set', async () => { + el.showCurrent = true; + await el.updateComplete; + + const current = el.renderRoot.querySelector('button.current'); + + expect(current).to.exist; + expect(current).to.have.trimmed.text(currentYear.toString()); + }); + + it('should not have a selected year', () => { + const selectedMonthButton = el.renderRoot.querySelector('button.selected'); + + expect(selectedMonthButton).to.not.exist; + expect(el.selected).to.be.undefined; + }); + + it('should show the selected year when set', async () => { + el.selected = new Date(currentYear, 0, 1); + await el.updateComplete; + + const selectedMonthButton = el.renderRoot.querySelector('button.selected'); + + expect(selectedMonthButton).to.exist; + expect(selectedMonthButton).to.have.trimmed.text(currentYear.toString()); + }); + + it('should have enabled back and forward buttons', () => { + const prev = el.renderRoot.querySelector('sl-button[aria-label^="Go back 12 years"]'), + next = el.renderRoot.querySelector('sl-button[aria-label^="Go forward 12 years"]'); + + expect(prev).to.exist.and.not.match(':disabled'); + expect(next).to.exist.and.not.match(':disabled'); + }); + + it('should emit sl-select with selected year on click', () => { + const onSelect = spy(); + + el.addEventListener('sl-select', (e: SlSelectEvent) => { + onSelect(e.detail); + }); + el.renderRoot.querySelector('table button')?.click(); + + expect(onSelect).to.have.been.calledOnce; + expect(onSelect.lastCall.args[0]).to.equalDate(new Date(currentYear - 5, 0, 1)); + }); + + it('should emit sl-select with selected year on enter', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', (e: SlSelectEvent) => { + onSelect(e.detail); + }); + + const button = el.renderRoot.querySelector('table button'); + button?.focus(); + + await userEvent.keyboard('{Enter}'); + + expect(onSelect).to.have.been.calledOnce; + expect(onSelect.lastCall.args[0]).to.equalDate(new Date(currentYear - 5, 0, 1)); + }); + + it('should emit sl-select with selected year on space', async () => { + const onSelect = spy(); + + el.addEventListener('sl-select', (e: SlSelectEvent) => { + onSelect(e.detail); + }); + + const button = el.renderRoot.querySelector('table button'); + button?.focus(); + + await userEvent.keyboard(' '); + + expect(onSelect).to.have.been.calledOnce; + expect(onSelect.lastCall.args[0]).to.equalDate(new Date(currentYear - 5, 0, 1)); + }); + }); + + 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]; + + el.renderRoot.querySelector + `; } #onClick(year: number): void { - this.selectEvent.emit(new Date(year, 0)); + const date = new Date(year, 0, 1); + + this.selectEvent.emit(date); + this.selected = date; } - #onKeydown(event: KeyboardEvent): void { - if (event.key === 'Escape') { + /** + * For arrow keys, we need to detect if we're at a visual boundary (first/last button position) + * and trying to navigate beyond it AND navigation is not blocked by min/max constraints. + * If we can load a new range, do so. Otherwise, let the focus group controller handle it. + */ + async #onKeydown(event: KeyboardEvent & { target: HTMLButtonElement }): Promise { + const buttons = Array.from(this.buttons); + + const currentIndex = buttons.indexOf(event.target); + if (currentIndex === -1) { + return; + } + + const firstYear = this.years.at(0)!, + lastYear = this.years.at(-1)!, + canGoEarlier = !this.min || firstYear > this.min.getFullYear(), + canGoLater = !this.max || lastYear < this.max.getFullYear(); + + let shouldLoadNewRange = false; + + // Check if we're at a visual boundary position, trying to navigate beyond it, + // and not blocked by min/max constraints + if (event.key === 'ArrowLeft' && currentIndex === 0 && canGoEarlier) { + shouldLoadNewRange = true; event.preventDefault(); - event.stopPropagation(); + this.#onPrevious(); + } else if (event.key === 'ArrowRight' && currentIndex === buttons.length - 1 && canGoLater) { + shouldLoadNewRange = true; + event.preventDefault(); + this.#onNext(); + } else if (event.key === 'ArrowUp' && currentIndex < this.#cols && canGoEarlier) { + shouldLoadNewRange = true; + event.preventDefault(); + this.#onPrevious(); + } else if (event.key === 'ArrowDown' && currentIndex >= buttons.length - this.#cols && canGoLater) { + shouldLoadNewRange = true; + event.preventDefault(); + this.#onNext(); + } + + if (shouldLoadNewRange) { + await this.updateComplete; + + const newButtons = Array.from(this.buttons), + newEnabledButtons = newButtons.filter(b => !b.disabled); - this.selectEvent.emit(new Date(this.year, 0)); + let targetButton: HTMLButtonElement | undefined; + + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + targetButton = newEnabledButtons.at(-1); + } else { + targetButton = newEnabledButtons.at(0); + } + + targetButton?.focus(); } - } - #onPrevious(): void { - this.#setYears(this.years[0] - 12, this.years[0] - 1); + // Otherwise, let the event bubble to the focus group controller } #onNext(): void { - this.#setYears(this.years[this.years.length - 1] + 1, this.years[this.years.length - 1] + 12); + this.#setYears(this.years.at(-1)! + 1, this.years.at(-1)! + 12); + this.#announce(this.years); + } + + #onPrevious(): void { + this.#setYears(this.years.at(0)! - 12, this.years.at(0)! - 1); + this.#announce(this.years); } #setYears(start: number, end: number): void { this.years = Array.from({ length: end - start + 1 }, (_, i) => start + i); } + + // Announce if needed, we don't want to have the same message announced twice + #announce(yearsRange: number[]): void { + // Clear any pending announcement + if (this.#announceTimeoutId) { + clearTimeout(this.#announceTimeoutId); + } + + // Set a short timeout to debounce multiple calls + this.#announceTimeoutId = setTimeout(() => { + announce( + msg(str`Years between ${yearsRange.at(0) ?? ''} and ${yearsRange.at(-1) ?? ''}`, { + id: 'sl.calendar.announceYears' + }), + 'polite' + ); + + this.#announceTimeoutId = undefined; + }, 50); + } } diff --git a/packages/components/calendar/src/utils.ts b/packages/components/calendar/src/utils.ts index ca5a1b7705..865425c91c 100644 --- a/packages/components/calendar/src/utils.ts +++ b/packages/components/calendar/src/utils.ts @@ -1,22 +1,59 @@ +import { dateConverter } from '@sl-design-system/shared/converters.js'; + +export type IndicatorColor = 'blue' | 'red' | 'yellow' | 'green' | 'grey'; + +export type Indicator = { date: Date; color?: IndicatorColor; label?: string }; + export interface Day { - ariaCurrent?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'; - ariaPressed?: 'true' | 'false' | 'mixed'; - autosuggest?: boolean; + /** Whether this day is in the current month. */ currentMonth?: boolean; + + /** The date of the day. */ date: Date; + + /** Whether this day is disabled. */ disabled?: boolean; - focused?: boolean; + + /** + * Whether this day is the first enabled day of the month. You cannot navigate + * past this day using keyboard navigation. + */ + firstActiveDayOfMonth?: boolean; + + /** Whether this day is in the future. */ future?: boolean; - highlight?: boolean; - lastDayOfMonth?: boolean; + + /** + * Whether this day has an indicator. + * @default { color: 'blue', label: undefined } + */ + indicator?: { color?: IndicatorColor; label?: string }; + + /** + * Whether this day is the last enabled day of the month. You cannot navigate + * past this date using keyboard navigation. + */ + lastActiveDayOfMonth?: boolean; + + /** Whether this day is in the next month. */ nextMonth?: boolean; + + /** Whether this day is out of range (before min, after max). */ + outOfRange?: boolean; + + /** Whether this day is in the past. */ past?: boolean; + + /** Whether this day is in the previous month. */ previousMonth?: boolean; - range?: boolean; + + /** Whether this day is the first day of the week. */ startOfWeek?: boolean; - tabindex?: string; + + /** Whether this day is today. */ today?: boolean; - unselectable?: boolean; + + /** The index of the day within the week (0..6). */ weekOrder?: number; } @@ -25,6 +62,14 @@ export interface Week { days: Day[]; } +export interface Month { + short: string; + long: string; + value: number; + date: Date; + disabled?: boolean; +} + export type WeekDayNamesStyle = 'long' | 'short' | 'narrow'; export type WeekDayNames = { @@ -121,13 +166,27 @@ 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)); + } + + return list.some(item => isSameDate(item, date)); +} + export function normalizeDateTime(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } export interface CreateCalendarOptions { + disabledDates?: Date[]; end?: Date; - firstDayOfWeek?: number; + firstDayOfWeek: number; + indicatorDates?: Indicator[]; max?: Date; min?: Date; showToday?: boolean; @@ -135,15 +194,17 @@ export interface CreateCalendarOptions { export function createCalendar( date: Date, - { end, firstDayOfWeek = 0, max, min, showToday = false }: CreateCalendarOptions + { disabledDates, end, firstDayOfWeek, indicatorDates, max, min, showToday = false }: CreateCalendarOptions ): Calendar { - const weekOptions = { firstDayOfWeek, max, min, showToday }; + const weekOptions = { disabledDates, firstDayOfWeek, indicatorDates, max, min, showToday }; return end ? createPeriod(date, end, weekOptions) : createMonth(date, weekOptions); } export interface CreatePeriodOptions { + disabledDates?: Date[]; firstDayOfWeek: number; + indicatorDates?: Indicator[]; max?: Date; min?: Date; showToday: boolean; @@ -152,10 +213,10 @@ export interface CreatePeriodOptions { export function createPeriod( start: Date, end: Date, - { firstDayOfWeek, max, min, showToday }: CreatePeriodOptions + { disabledDates, firstDayOfWeek, indicatorDates, max, min, showToday }: CreatePeriodOptions ): Calendar { const calendar: Calendar = { weeks: [] }, - weekOptions = { firstDayOfWeek, max, min, relativeMonth: start, showToday }; + weekOptions = { disabledDates, firstDayOfWeek, indicatorDates, max, min, relativeMonth: start, showToday }; let nextWeek = createWeek(start, weekOptions); do { @@ -165,21 +226,43 @@ export function createPeriod( nextWeek = createWeek(firstDayOfNextWeek, weekOptions); } while (nextWeek.days[0].date <= end); + // Mark the first and last selectable days in the period + const allDays = calendar.weeks.flatMap(week => week.days), + selectableDays = allDays.filter(day => !day.disabled && !day.outOfRange); + + if (selectableDays.length > 0) { + selectableDays[0].firstActiveDayOfMonth = true; + selectableDays[selectableDays.length - 1].lastActiveDayOfMonth = true; + } + return calendar; } export interface CreateMonthOptions { + disabledDates?: Date[]; firstDayOfWeek: number; + indicatorDates?: Indicator[]; max?: Date; min?: Date; showToday: boolean; } -export function createMonth(date: Date, { firstDayOfWeek, max, min, showToday }: CreateMonthOptions): Calendar { +export function createMonth( + date: Date, + { disabledDates, firstDayOfWeek, indicatorDates, max, min, showToday }: CreateMonthOptions +): Calendar { const firstDayOfMonth = new Date(date); firstDayOfMonth.setDate(1); const monthNumber = firstDayOfMonth.getMonth(); - const weekOptions = { firstDayOfWeek, max, min, relativeMonth: firstDayOfMonth, showToday }; + const weekOptions = { + disabledDates, + firstDayOfWeek, + indicatorDates, + max, + min, + relativeMonth: firstDayOfMonth, + showToday + }; const month: Calendar = { weeks: [] }; @@ -191,11 +274,23 @@ export function createMonth(date: Date, { firstDayOfWeek, max, min, showToday }: nextWeek = createWeek(firstDayOfNextWeek, weekOptions); } while (nextWeek.days[0].date.getMonth() === monthNumber); + // Find and mark the first and last active (selectable) days of the current month + const currentMonthDays = month.weeks + .flatMap(week => week.days) + .filter(day => day.currentMonth && !day.disabled && !day.outOfRange); + + if (currentMonthDays.length > 0) { + currentMonthDays[0].firstActiveDayOfMonth = true; + currentMonthDays[currentMonthDays.length - 1].lastActiveDayOfMonth = true; + } + return month; } export interface CreateWeekOptions { + disabledDates?: Date[]; firstDayOfWeek: number; + indicatorDates?: Indicator[]; max?: Date; min?: Date; relativeMonth: Date; @@ -204,7 +299,7 @@ export interface CreateWeekOptions { export function createWeek( date: Date, - { firstDayOfWeek, max, min, relativeMonth, showToday }: CreateWeekOptions + { disabledDates, firstDayOfWeek, indicatorDates, max, min, relativeMonth, showToday }: CreateWeekOptions ): Week { let weekStartDate = new Date(date); @@ -222,12 +317,14 @@ export function createWeek( week.days.push( createDay(new Date(weekStartDate), { - relativeMonth, + disabledDates, + indicatorDates, max, min, - weekOrder: i, + relativeMonth, showToday, - startOfWeek: i === 0 + startOfWeek: i === 0, + weekOrder: i }) ); } @@ -235,9 +332,11 @@ export function createWeek( } export interface CreateDayOptions { - relativeMonth: Date; + disabledDates?: Date[]; + indicatorDates?: Indicator[]; max?: Date; min?: Date; + relativeMonth: Date; showToday: boolean; startOfWeek: boolean; weekOrder: number; @@ -245,26 +344,44 @@ export interface CreateDayOptions { export function createDay( date: Date, - { relativeMonth, max, min, showToday, startOfWeek, weekOrder }: CreateDayOptions + { disabledDates, indicatorDates, max, min, relativeMonth, showToday, startOfWeek, weekOrder }: CreateDayOptions ): Day { const today = normalizeDateTime(new Date()), + indicator = indicatorDates?.find(i => isSameDate(i.date, date)), currentMonth = relativeMonth.getMonth(), - isToday = showToday && isSameDate(date, today), - lastDayOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + isToday = showToday && isSameDate(date, today); return { - ariaCurrent: isToday ? 'date' : undefined, currentMonth: date.getMonth() === currentMonth, date, + disabled: isDateInList(date, disabledDates), future: date > today, - lastDayOfMonth: date.getDate() === lastDayOfMonth, + indicator: indicator ? { color: indicator.color, label: indicator.label } : undefined, nextMonth: date.getMonth() > currentMonth, + outOfRange: (min && date < min) || (max && date > max), past: date < today, previousMonth: date.getMonth() < currentMonth, startOfWeek, - tabindex: '-1', today: isToday, - unselectable: (min && date < min) || (max && date > max), 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 +}; diff --git a/packages/components/checkbox/src/checkbox-group.spec.ts b/packages/components/checkbox/src/checkbox-group.spec.ts index d246f2d971..c851221192 100644 --- a/packages/components/checkbox/src/checkbox-group.spec.ts +++ b/packages/components/checkbox/src/checkbox-group.spec.ts @@ -1,10 +1,10 @@ import { type SlFormControlEvent } from '@sl-design-system/form'; import '@sl-design-system/form/register.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type TemplateResult, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { CheckboxGroup } from './checkbox-group.js'; diff --git a/packages/components/checkbox/src/checkbox.spec.ts b/packages/components/checkbox/src/checkbox.spec.ts index 22db031bc5..ffd7c7e591 100644 --- a/packages/components/checkbox/src/checkbox.spec.ts +++ b/packages/components/checkbox/src/checkbox.spec.ts @@ -1,10 +1,10 @@ import { type SlFormControlEvent } from '@sl-design-system/form'; import '@sl-design-system/form/register.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type TemplateResult, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { Checkbox } from './checkbox.js'; diff --git a/packages/components/combobox/src/combobox.spec.ts b/packages/components/combobox/src/combobox.spec.ts index 430e11ebf9..a06fe4c69a 100644 --- a/packages/components/combobox/src/combobox.spec.ts +++ b/packages/components/combobox/src/combobox.spec.ts @@ -3,10 +3,10 @@ import '@sl-design-system/form/register.js'; import '@sl-design-system/listbox/register.js'; import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type TemplateResult, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { type Combobox } from './combobox.js'; import { type CustomOption } from './custom-option.js'; diff --git a/packages/components/date-field/src/date-field.ts b/packages/components/date-field/src/date-field.ts index ca4ad3af0b..c84049b72b 100644 --- a/packages/components/date-field/src/date-field.ts +++ b/packages/components/date-field/src/date-field.ts @@ -277,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(); @@ -284,10 +285,8 @@ export class DateField extends LocaleMixin(FormControlMixin(ScopedElementsMixin( this.updateState({ dirty: true }); this.updateValidity(); - setTimeout(() => { - this.wrapper?.hidePopover(); - this.input.focus(); - }, 500); + this.wrapper?.hidePopover(); + this.input.focus(); } #onTextFieldBlur(event: SlBlurEvent): void { diff --git a/packages/components/dialog/src/dialog.spec.ts b/packages/components/dialog/src/dialog.spec.ts index 7816e922c5..1d540bde9c 100644 --- a/packages/components/dialog/src/dialog.spec.ts +++ b/packages/components/dialog/src/dialog.spec.ts @@ -1,10 +1,10 @@ import { type Button } from '@sl-design-system/button'; import '@sl-design-system/button/register.js'; import { fixture, oneEvent } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { type LitElement, type TemplateResult, html } from 'lit'; import { spy, stub } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { Dialog } from './dialog.js'; diff --git a/packages/components/form/src/form.spec.ts b/packages/components/form/src/form.spec.ts index f2ae3a016f..2d63f89fdd 100644 --- a/packages/components/form/src/form.spec.ts +++ b/packages/components/form/src/form.spec.ts @@ -2,10 +2,10 @@ import { ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements import { TextField } from '@sl-design-system/text-field'; import '@sl-design-system/text-field/register.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { FormField } from './form-field.js'; import { Form } from './form.js'; diff --git a/packages/components/grid/src/column-group.spec.ts b/packages/components/grid/src/column-group.spec.ts index 8cd059efc2..40b883cc2e 100644 --- a/packages/components/grid/src/column-group.spec.ts +++ b/packages/components/grid/src/column-group.spec.ts @@ -1,7 +1,7 @@ import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { page } from '@vitest/browser/context'; import { html } from 'lit'; import { beforeEach, describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; import '../register.js'; import { type Grid } from './grid.js'; diff --git a/packages/components/grid/src/filter.spec.ts b/packages/components/grid/src/filter.spec.ts index dfdddef537..0549117ea5 100644 --- a/packages/components/grid/src/filter.spec.ts +++ b/packages/components/grid/src/filter.spec.ts @@ -1,8 +1,8 @@ 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 { userEvent } from 'vitest/browser'; import { GridFilter } from './filter.js'; try { diff --git a/packages/components/menu/src/menu-button.spec.ts b/packages/components/menu/src/menu-button.spec.ts index b21ba5ac23..1d7b04cf1a 100644 --- a/packages/components/menu/src/menu-button.spec.ts +++ b/packages/components/menu/src/menu-button.spec.ts @@ -2,9 +2,9 @@ import { faGear } from '@fortawesome/pro-regular-svg-icons'; import { type Button } from '@sl-design-system/button'; import { Icon } from '@sl-design-system/icon'; 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { type MenuButton } from './menu-button.js'; import { type Menu } from './menu.js'; diff --git a/packages/components/menu/src/menu-item.spec.ts b/packages/components/menu/src/menu-item.spec.ts index 77f6f6871b..0c11f39ac6 100644 --- a/packages/components/menu/src/menu-item.spec.ts +++ b/packages/components/menu/src/menu-item.spec.ts @@ -1,8 +1,8 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { type MenuItem } from './menu-item.js'; import { Menu } from './menu.js'; diff --git a/packages/components/message-dialog/src/message-dialog.spec.ts b/packages/components/message-dialog/src/message-dialog.spec.ts index 75e8b3755e..fb3c0e188c 100644 --- a/packages/components/message-dialog/src/message-dialog.spec.ts +++ b/packages/components/message-dialog/src/message-dialog.spec.ts @@ -1,7 +1,7 @@ -import { userEvent } from '@vitest/browser/context'; import { html } from 'lit'; import { spy } from 'sinon'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { MessageDialog } from './message-dialog.js'; diff --git a/packages/components/number-field/src/number-field.spec.ts b/packages/components/number-field/src/number-field.spec.ts index a4ffde1213..2b4912c1c2 100644 --- a/packages/components/number-field/src/number-field.spec.ts +++ b/packages/components/number-field/src/number-field.spec.ts @@ -1,8 +1,8 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { type NumberField } from './number-field.js'; diff --git a/packages/components/panel/src/panel.spec.ts b/packages/components/panel/src/panel.spec.ts index 5ea6d0093d..a474bceeca 100644 --- a/packages/components/panel/src/panel.spec.ts +++ b/packages/components/panel/src/panel.spec.ts @@ -1,10 +1,10 @@ import '@sl-design-system/button/register.js'; import { type SlToggleEvent } from '@sl-design-system/shared/events.js'; 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { type Panel } from './panel.js'; diff --git a/packages/components/popover/src/popover.spec.ts b/packages/components/popover/src/popover.spec.ts index 8ba726e407..50bcaee7ad 100644 --- a/packages/components/popover/src/popover.spec.ts +++ b/packages/components/popover/src/popover.spec.ts @@ -1,9 +1,9 @@ import { type Button } from '@sl-design-system/button'; import '@sl-design-system/button/register.js'; 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { Popover } from './popover.js'; diff --git a/packages/components/radio-group/src/radio-group.spec.ts b/packages/components/radio-group/src/radio-group.spec.ts index 6f7c7879f1..bfbab99f85 100644 --- a/packages/components/radio-group/src/radio-group.spec.ts +++ b/packages/components/radio-group/src/radio-group.spec.ts @@ -1,10 +1,10 @@ import { type SlFormControlEvent } from '@sl-design-system/form'; import '@sl-design-system/form/register.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type TemplateResult, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { RadioGroup } from './radio-group.js'; diff --git a/packages/components/radio-group/src/radio.spec.ts b/packages/components/radio-group/src/radio.spec.ts index 222d0afdad..fd35c12c01 100644 --- a/packages/components/radio-group/src/radio.spec.ts +++ b/packages/components/radio-group/src/radio.spec.ts @@ -1,7 +1,7 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { Radio } from './radio.js'; diff --git a/packages/components/search-field/src/search-field.spec.ts b/packages/components/search-field/src/search-field.spec.ts index d97eefa718..274c8b7551 100644 --- a/packages/components/search-field/src/search-field.spec.ts +++ b/packages/components/search-field/src/search-field.spec.ts @@ -1,8 +1,8 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { type SearchField, type SlSearchEvent } from './search-field.js'; diff --git a/packages/components/select/src/select.spec.ts b/packages/components/select/src/select.spec.ts index 9bf3f2547d..25165c0f60 100644 --- a/packages/components/select/src/select.spec.ts +++ b/packages/components/select/src/select.spec.ts @@ -2,10 +2,10 @@ import { type SlFormControlEvent } from '@sl-design-system/form'; import '@sl-design-system/form/register.js'; import '@sl-design-system/listbox/register.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type TemplateResult, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { SelectButton } from './select-button.js'; import { Select } from './select.js'; diff --git a/packages/components/shared/index.ts b/packages/components/shared/index.ts index ed5d5079bd..6d13a24bd1 100644 --- a/packages/components/shared/index.ts +++ b/packages/components/shared/index.ts @@ -4,6 +4,7 @@ export * from './src/controllers/anchor.js'; export * from './src/controllers/events.js'; export * from './src/controllers/focus-group.js'; export * from './src/controllers/media.js'; +export * from './src/controllers/new-focus-group.js'; export * from './src/controllers/roving-tabindex.js'; export * from './src/controllers/shortcut.js'; export * from './src/css.js'; diff --git a/packages/components/shared/package.json b/packages/components/shared/package.json index a99d4f23d7..724a04e92e 100644 --- a/packages/components/shared/package.json +++ b/packages/components/shared/package.json @@ -20,6 +20,10 @@ "import": "./index.js", "types": "./index.d.ts" }, + "./controllers/focus-group.js": { + "import": "./src/controllers/new-focus-group.js", + "types": "./src/controllers/new-focus-group.d.ts" + }, "./converters.js": { "import": "./src/converters.js", "types": "./src/converters.d.ts" diff --git a/packages/components/shared/src/controllers/new-focus-group.spec.ts b/packages/components/shared/src/controllers/new-focus-group.spec.ts new file mode 100644 index 0000000000..63dc01970e --- /dev/null +++ b/packages/components/shared/src/controllers/new-focus-group.spec.ts @@ -0,0 +1,620 @@ +import { fixture } from '@sl-design-system/vitest-browser-lit'; +import { LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { fake } from 'sinon'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { type NewFocusGroupConfig, NewFocusGroupController } from './new-focus-group.js'; + +class NewFocusGroupFixture extends LitElement { + controller?: NewFocusGroupController; + + @property({ attribute: false }) config?: NewFocusGroupConfig; + + override updated(changes: PropertyValues): void { + super.updated(changes); + + if (changes.has('config')) { + this.controller = new NewFocusGroupController(this, this.config); + } + } + + override render(): TemplateResult { + return html` +
+ + + + +
+ + `; + } +} + +try { + customElements.define('new-focus-group', NewFocusGroupFixture); +} catch { + /* empty */ +} + +describe('NewFocusGroupController', () => { + let el: NewFocusGroupFixture, config: NewFocusGroupConfig | undefined; + + describe('defaults', () => { + beforeEach(async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + wrap: true + }; + + el = await fixture(html``); + + // Since we're delaying the creation of the controller, we need to manually call manage + el.controller!.manage(); + }); + + it('should have a NewFocusGroupController instance', () => { + expect(el.controller).to.be.instanceOf(NewFocusGroupController); + }); + + it('should have all elements in the controller', () => { + const buttons = Array.from(el.renderRoot.querySelectorAll('button')); + + expect(el.controller!.elements).to.deep.equal(buttons); + }); + + it('should add the tabindex attribute to all elements', () => { + el.controller!.elements.forEach(e => { + expect(e).to.have.attribute('tabindex'); + }); + }); + + it('should have set a tabindex of 0 on the first element', () => { + expect(el.controller!.elements[0]).to.have.attribute('tabindex', '0'); + expect(el.controller!.elements[1]).to.have.attribute('tabindex', '-1'); + expect(el.controller!.elements[2]).to.have.attribute('tabindex', '-1'); + expect(el.controller!.elements[3]).to.have.attribute('tabindex', '-1'); + }); + + it('should not have autofocus attribute by default', () => { + el.controller!.elements.forEach(e => { + expect(e).not.to.have.attribute('autofocus'); + }); + }); + + it('should focus the first element when focusing the host', async () => { + await userEvent.tab(); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements[0]); + }); + + it('should set tabindex to 0 on the first element when leaving the host', () => { + el.controller!.elements[1].focus(); + el.blur(); + + expect(el.controller!.elements[0]).to.have.attribute('tabindex', '0'); + expect(el.controller!.elements[1]).to.have.attribute('tabindex', '-1'); + expect(el.controller!.elements[2]).to.have.attribute('tabindex', '-1'); + expect(el.controller!.elements[3]).to.have.attribute('tabindex', '-1'); + }); + + describe('after focusing the first element', () => { + beforeEach(() => { + el.controller?.elements.at(0)?.focus(); + }); + + it('should set the tabindex to -1 on the focused element', () => { + el.controller!.elements.forEach(e => { + expect(e).to.have.attribute('tabindex', '-1'); + }); + }); + + it('should focus the next element when pressing the ArrowRight key', async () => { + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + }); + + it('should focus the next element when pressing the ArrowDown key', async () => { + await userEvent.keyboard('{ArrowDown}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + }); + + it('should focus the last element when pressing the ArrowLeft key (wrap enabled)', async () => { + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(-1)); + }); + + it('should focus the last element when pressing the ArrowUp key (wrap enabled)', async () => { + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(-1)); + }); + + it('should focus the last element when pressing the End key', async () => { + await userEvent.keyboard('{End}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(-1)); + }); + }); + + describe('after focusing the last element', () => { + beforeEach(() => { + el.controller?.elements.at(-1)?.focus(); + }); + + it('should set the tabindex to -1 on the focused element', () => { + el.controller!.elements.forEach(e => { + expect(e).to.have.attribute('tabindex', '-1'); + }); + }); + + it('should focus the first element when pressing the ArrowRight key (wrap enabled)', async () => { + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + + it('should focus the first element when pressing the ArrowDown key (wrap enabled)', async () => { + await userEvent.keyboard('{ArrowDown}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + + it('should skip the disabled element when pressing the ArrowLeft key', async () => { + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + }); + + it('should skip the disabled element when pressing the ArrowUp key', async () => { + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + }); + + it('should focus the first element when pressing the Home key', async () => { + await userEvent.keyboard('{Home}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + }); + }); + + describe('autofocus', () => { + beforeEach(async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + autofocus: true, + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + }); + + it('should add autofocus attribute to the element with tabindex 0', () => { + expect(el.controller!.elements[0]).to.have.attribute('autofocus'); + expect(el.controller!.elements[0]).to.have.attribute('tabindex', '0'); + }); + + it('should not add autofocus to other elements', () => { + expect(el.controller!.elements[1]).not.to.have.attribute('autofocus'); + expect(el.controller!.elements[3]).not.to.have.attribute('autofocus'); + }); + + it('should move autofocus when navigating', async () => { + el.controller!.elements[0].focus(); + await userEvent.keyboard('{ArrowRight}'); + + expect(el.controller!.elements[0]).not.to.have.attribute('autofocus'); + expect(el.controller!.elements[1]).to.have.attribute('autofocus'); + expect(el.controller!.elements[1]).to.have.attribute('tabindex', '0'); + }); + + it('should restore autofocus to initial element when leaving and returning', () => { + el.controller!.elements[1].focus(); + el.blur(); + + expect(el.controller!.elements[0]).to.have.attribute('autofocus'); + expect(el.controller!.elements[0]).to.have.attribute('tabindex', '0'); + expect(el.controller!.elements[1]).not.to.have.attribute('autofocus'); + }); + }); + + describe('wrap', () => { + beforeEach(async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + wrap: false + }; + + el = await fixture(html``); + el.controller!.manage(); + }); + + describe('after focusing the first element', () => { + beforeEach(() => { + el.controller?.elements.at(0)?.focus(); + }); + + it('should stay on the first element when pressing the ArrowLeft key', async () => { + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + + it('should stay on the first element when pressing the ArrowUp key', async () => { + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + + it('should focus the next element when pressing the ArrowRight key', async () => { + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + }); + }); + + describe('after focusing the last element', () => { + beforeEach(() => { + el.controller?.elements.at(-1)?.focus(); + }); + + it('should stay on the last element when pressing the ArrowRight key', async () => { + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(-1)); + }); + + it('should stay on the last element when pressing the ArrowDown key', async () => { + await userEvent.keyboard('{ArrowDown}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(-1)); + }); + + it('should skip the disabled element when pressing the ArrowLeft key', async () => { + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + }); + }); + }); + + describe('scope', () => { + beforeEach(async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + scope: () => el.renderRoot.querySelector('#scope') as HTMLElement, + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + }); + + it('should attach listeners to the scope element', () => { + // This is tested indirectly - events within scope should trigger focus management + const scopeEl = el.renderRoot.querySelector('#scope') as HTMLElement; + expect(scopeEl).to.exist; + }); + + it('should handle keyboard navigation within scope', async () => { + el.controller!.elements[0].focus(); + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements[1]); + }); + }); + + describe('horizontal direction', () => { + beforeEach(async () => { + config = { + direction: 'horizontal', + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + el.controller?.elements.at(0)?.focus(); + }); + + it('should focus the next element when pressing the ArrowRight key', async () => { + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + }); + + it('should not move when pressing the ArrowDown key', async () => { + await userEvent.keyboard('{ArrowDown}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + + it('should focus the last element when pressing the ArrowLeft key', async () => { + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(-1)); + }); + + it('should not move when pressing the ArrowUp key', async () => { + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + }); + + describe('vertical direction', () => { + beforeEach(async () => { + config = { + direction: 'vertical', + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + el.controller?.elements.at(0)?.focus(); + }); + + it('should not move when pressing the ArrowRight key', async () => { + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + + it('should focus the next element when pressing the ArrowDown key', async () => { + await userEvent.keyboard('{ArrowDown}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + }); + + it('should not move when pressing the ArrowLeft key', async () => { + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + }); + + it('should focus the last element when pressing the ArrowUp key', async () => { + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(-1)); + }); + }); + + describe('grid navigation', () => { + beforeEach(async () => { + config = { + direction: 'grid', + directionLength: 2, // 2 columns + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + }); + + it('should navigate right in grid', async () => { + el.controller!.elements[0].focus(); + await userEvent.keyboard('{ArrowRight}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements[1]); + }); + + it('should navigate down in grid', async () => { + el.controller!.elements[0].focus(); + await userEvent.keyboard('{ArrowDown}'); + + // Element 2 is disabled, so grid navigation searches horizontally in the same row and finds element 3 + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements[3]); + expect(el.controller!.currentIndex).to.equal(3); + }); + + it('should navigate left in grid', async () => { + el.controller!.elements[1].focus(); + await userEvent.keyboard('{ArrowLeft}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements[0]); + }); + + it('should navigate up in grid', async () => { + el.controller!.elements[3].focus(); + await userEvent.keyboard('{ArrowUp}'); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements[1]); + }); + }); + + describe('focus in index', () => { + it('should call focusInIndex with the elements array', async () => { + const focusInIndex = fake.returns(1); + + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + focusInIndex, + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + + expect(focusInIndex).to.have.been.called; + expect(focusInIndex.lastCall.firstArg).to.deep.equal(el.controller!.elements); + }); + + it('should have set a tabindex of 0 on the second element', async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + focusInIndex: () => 1, + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + + expect(el.controller!.elements[0]).to.have.attribute('tabindex', '-1'); + expect(el.controller!.elements[1]).to.have.attribute('tabindex', '0'); + expect(el.controller!.elements[2]).to.have.attribute('tabindex', '-1'); + expect(el.controller!.elements[3]).to.have.attribute('tabindex', '-1'); + }); + + it('should set autofocus on the correct element when autofocus is enabled', async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + focusInIndex: () => 1, + autofocus: true, + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + + expect(el.controller!.elements[1]).to.have.attribute('autofocus'); + expect(el.controller!.elements[0]).not.to.have.attribute('autofocus'); + }); + }); + + describe('is focusable element', () => { + beforeEach(async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + isFocusableElement: el => el.textContent !== 'Button 1' && !el.disabled, + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + }); + + it('should not focus the first element', () => { + el.controller!.focus(); + + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements[1]); + }); + + it('should set tabindex 0 on the first focusable element', () => { + expect(el.controller!.elements[0]).to.have.attribute('tabindex', '-1'); + expect(el.controller!.elements[1]).to.have.attribute('tabindex', '0'); + expect(el.controller!.elements[2]).to.have.attribute('tabindex', '-1'); + expect(el.controller!.elements[3]).to.have.attribute('tabindex', '-1'); + }); + }); + + describe('element enter action', () => { + it('should call elementEnterAction when navigating', async () => { + const elementEnterAction = fake(); + + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + elementEnterAction, + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + + el.controller!.elements[0].focus(); + await userEvent.keyboard('{ArrowRight}'); + + expect(elementEnterAction).to.have.been.calledWith(el.controller!.elements[1]); + }); + }); + + describe('mixed context elements', () => { + beforeEach(async () => { + config = { + elements: () => [...Array.from(el.renderRoot.querySelectorAll('button')), el.querySelector('button')!], + wrap: true + }; + + el = await fixture(html` + + + + `); + + el.controller!.manage(); + }); + + it('should add the tabindex attribute to all elements', () => { + el.controller!.elements.forEach(e => { + expect(e).to.have.attribute('tabindex'); + }); + }); + + it('should switch between light & shadow DOM elements', async () => { + el.controller!.focus(); + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + + await userEvent.keyboard('{ArrowLeft}'); + expect(document.activeElement).to.equal(el.controller!.elements.at(-1)); + + await userEvent.keyboard('{ArrowLeft}'); + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(3)); + + await userEvent.keyboard('{ArrowLeft}'); + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(1)); + + await userEvent.keyboard('{ArrowLeft}'); + expect(el.shadowRoot!.activeElement).to.equal(el.controller!.elements.at(0)); + + await userEvent.keyboard('{ArrowLeft}'); + expect(document.activeElement).to.equal(el.controller!.elements.at(-1)); + }); + }); + + describe('clear element cache', () => { + beforeEach(async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + }); + + it('should invalidate the cached elements when calling clearElementCache', () => { + const firstElements = el.controller!.elements; + el.controller!.clearElementCache(); + const secondElements = el.controller!.elements; + + // They should be different array instances after clearing cache + expect(firstElements).to.not.equal(secondElements); + }); + + it('should invalidate the cached elements when updating', () => { + const firstElements = el.controller!.elements; + el.controller!.update({ elements: config!.elements }); + const secondElements = el.controller!.elements; + + // They should be different array instances after update + expect(firstElements).to.not.equal(secondElements); + }); + }); + + describe('update config', () => { + beforeEach(async () => { + config = { + elements: () => Array.from(el.renderRoot.querySelectorAll('button')), + wrap: true + }; + + el = await fixture(html``); + el.controller!.manage(); + }); + + it('should update the configuration', () => { + el.controller!.update({ elements: config!.elements, wrap: false }); + + // After update, wrap should be false + el.controller!.elements[0].focus(); + }); + }); +}); diff --git a/packages/components/shared/src/controllers/new-focus-group.ts b/packages/components/shared/src/controllers/new-focus-group.ts new file mode 100644 index 0000000000..f0795b285f --- /dev/null +++ b/packages/components/shared/src/controllers/new-focus-group.ts @@ -0,0 +1,579 @@ +import { type ReactiveController, type ReactiveElement } from 'lit'; + +type DirectionTypes = 'horizontal' | 'vertical' | 'both' | 'grid'; + +export type NewFocusGroupConfig = { + /** Whether to manage the autofocus attribute (defaults to false). */ + autofocus?: boolean; + + /** Navigation direction: 'horizontal', 'vertical', 'both', or 'grid'. */ + direction?: DirectionTypes | (() => DirectionTypes); + + /** Number of columns (for grid navigation). */ + directionLength?: number; + + /** Callback invoked when an element is focused via keyboard navigation. */ + elementEnterAction?(el: T): void; + + /** Returns the array of elements to manage. */ + elements(): T[]; + + /** Returns the index of the element to receive tabindex="0" when not focused. */ + focusInIndex?(elements: T[]): number; + + /** Determines if an element can receive focus. */ + isFocusableElement?(el: T): boolean; + + /** Returns the element to attach event listeners to (defaults to host). */ + scope?(): HTMLElement; + + /** Whether focus should wrap around at boundaries (defaults to false). */ + wrap?: boolean; +}; + +interface UpdateTabIndexes { + tabIndex: number; + removeTabIndex?: boolean; +} + +export class NewFocusGroupController implements ReactiveController { + // Configuration + #autofocus = false; + #direction = (): DirectionTypes => 'both'; + #directionLength = (): number => 1; + #elements: () => T[]; + #focusInIndex = (_elements: T[]): number => 0; + #host: ReactiveElement & HTMLElement; + #scope = (): HTMLElement => this.#host; + #wrap = false; + + // State + #cachedElements?: T[]; + #currentIndex = -1; + #focused = false; + #listenersAdded = false; + #managed = true; + #manageIndexesAnimationFrame = 0; + + // Public properties + elementEnterAction = (_el: T): void => { + return; + }; + + isFocusableElement = (el: T): boolean => !el.hasAttribute('disabled'); + + // Getters and setters + get currentIndex(): number { + if (this.#currentIndex === -1) { + this.#currentIndex = this.focusInIndex; + } + + return this.#currentIndex; + } + + set currentIndex(currentIndex) { + this.#currentIndex = currentIndex; + } + + get direction(): DirectionTypes { + return this.#direction(); + } + + set directionLength(directionLength: number) { + this.#directionLength = () => directionLength; + } + + get elements(): T[] { + if (!this.#cachedElements) { + this.#cachedElements = this.#elements(); + } + + return this.#cachedElements; + } + + get focused(): boolean { + return this.#focused; + } + + set focused(focused: boolean) { + if (focused === this.focused) return; + this.#focused = focused; + this.#manageTabindexes(); + } + + get focusInElement(): T { + return this.elements[this.focusInIndex]; + } + + get focusInIndex(): number { + return this.#focusInIndex(this.elements); + } + + constructor( + host: ReactiveElement & HTMLElement, + { + autofocus, + direction, + directionLength, + elementEnterAction, + elements, + focusInIndex, + isFocusableElement, + scope, + wrap + }: NewFocusGroupConfig = { elements: () => [] } + ) { + this.#host = host; + this.#host.addController(this); + + if (typeof direction === 'string') { + this.#direction = () => direction; + } else if (typeof direction === 'function') { + this.#direction = direction; + } + + if (typeof directionLength === 'number') { + this.#directionLength = () => directionLength; + } else if (typeof directionLength === 'function') { + this.#directionLength = directionLength; + } + + this.#elements = elements; + this.elementEnterAction = elementEnterAction || this.elementEnterAction; + + if (typeof focusInIndex === 'number') { + this.#focusInIndex = () => focusInIndex; + } else if (typeof focusInIndex === 'function') { + this.#focusInIndex = focusInIndex; + } + + this.isFocusableElement = isFocusableElement || this.isFocusableElement; + + this.#autofocus = autofocus ?? false; + + if (scope) { + this.#scope = scope; + } + + this.#wrap = wrap ?? false; + } + + // Lifecycle methods + hostConnected(): void { + // Event listeners are added in hostUpdated to ensure scope element exists + } + + hostDisconnected(): void { + this.#removeEventListeners(); + } + + hostUpdated(): void { + if (!this.#listenersAdded) { + this.#addEventListeners(); + this.#listenersAdded = true; + } + + if (!this.#host.hasUpdated) { + this.#manageTabindexes(); + } + } + + // Public API + update({ elements, wrap }: NewFocusGroupConfig = { elements: () => [] }): void { + this.unmanage(); + this.#elements = elements; + if (wrap !== undefined) { + this.#wrap = wrap ?? false; + } + + this.clearElementCache(); + this.manage(); + } + + focus(options?: FocusOptions): void { + let focusElement = this.elements[this.currentIndex]; + if (!focusElement) { + return; + } + + // For grid navigation, always attempt to focus the target element + // The browser will naturally handle disabled state + // For non-grid navigation, skip to next focusable element + if (this.direction !== 'grid' && !this.isFocusableElement(focusElement)) { + this.#setCurrentIndexCircularly(1); + focusElement = this.elements[this.currentIndex]; + } + + if (focusElement) { + focusElement.focus(options); + } + } + + focusToElement(element: T): void; + focusToElement(elementIndex: number): void; + + focusToElement(elementOrIndex: T | number): void { + this.currentIndex = typeof elementOrIndex === 'number' ? elementOrIndex : this.elements.indexOf(elementOrIndex); + this.elementEnterAction(this.elements[this.currentIndex]); + this.focus({ preventScroll: false }); + + // Update tabindex and autofocus when navigating while focused + if (this.focused) { + this.elements.forEach((el, idx) => { + if (idx === this.currentIndex) { + el.tabIndex = 0; + if (this.#autofocus) { + el.setAttribute('autofocus', ''); + } + } else { + el.tabIndex = -1; + if (this.#autofocus) { + el.removeAttribute('autofocus'); + } + } + }); + } + } + + clearElementCache(): void { + cancelAnimationFrame(this.#manageIndexesAnimationFrame); + this.#cachedElements = undefined; + if (!this.#managed) return; + + this.#manageIndexesAnimationFrame = requestAnimationFrame(() => this.#manageTabindexes()); + } + + manage(): void { + this.#managed = true; + this.#manageTabindexes(); + this.#addEventListeners(); + + if (this.focused) { + this.#hostContainsFocus(); + } + } + + unmanage(): void { + this.#managed = false; + this.#updateTabindexes(() => ({ tabIndex: 0 })); + this.#removeEventListeners(); + } + + // Navigation and focus management + #setCurrentIndexCircularly(diff: number): void { + const { length } = this.elements; + + // If wrap is disabled, check boundaries + if (!this.#wrap) { + const nextIndex = this.currentIndex + diff; + + // Handle boundary conditions without wrapping + if (nextIndex < 0 || nextIndex >= length) { + return; // Don't move focus + } + + // Find next focusable element without wrapping + let steps = Math.abs(diff); + let currentPos = this.currentIndex; + + while (steps > 0) { + const testIndex = currentPos + (diff > 0 ? 1 : -1); + + // Stop if we hit the boundary + if (testIndex < 0 || testIndex >= length) { + return; + } + + currentPos = testIndex; + + // Only count down if element is focusable + if (this.elements[currentPos] && this.isFocusableElement(this.elements[currentPos])) { + steps -= 1; + } + } + + this.focusToElement(currentPos); + return; + } + + // Original wrapping behavior + let steps = length; + let nextIndex = (length + this.currentIndex + diff) % length; + while (steps && this.elements[nextIndex] && !this.isFocusableElement(this.elements[nextIndex])) { + nextIndex = (length + nextIndex + diff) % length; + steps -= 1; + } + this.focusToElement(nextIndex); + } + + // Internal focus state management + #hostContainsFocus(): void { + const scope = this.#scope(); + scope.addEventListener('focusout', this.#onFocusout); + scope.addEventListener('keydown', this.#onKeydown); + this.focused = true; + } + + #hostNoLongerContainsFocus(): void { + const scope = this.#scope(); + scope.addEventListener('focusin', this.#onFocusin); + scope.removeEventListener('focusout', this.#onFocusout); + scope.removeEventListener('keydown', this.#onKeydown); + this.currentIndex = this.focusInIndex; + this.focused = false; + } + + #isFocusMovingOutOfScope(event: FocusEvent): boolean { + const relatedTarget = event.relatedTarget as null | Element; + + if (event.type === 'focusin') { + return false; + } else if (event.type === 'focusout' && relatedTarget === null) { + return true; + } else { + return !this.elements.includes(relatedTarget as T) || !this.elements.includes(event.composedPath()[0] as T); + } + } + + // Event handlers + #onFocusin = (event: FocusEvent): void => { + if (!this.#isFocusMovingOutOfScope(event)) { + this.#hostContainsFocus(); + } + const path = event.composedPath() as T[]; + let targetIndex = -1; + path.find(el => { + targetIndex = this.elements.indexOf(el); + return targetIndex !== -1; + }); + this.currentIndex = targetIndex > -1 ? targetIndex : this.currentIndex; + }; + + #onFocusout = (event: FocusEvent): void => { + if (this.#isFocusMovingOutOfScope(event)) { + this.#hostNoLongerContainsFocus(); + } + }; + + #onKeydown = (event: KeyboardEvent): void => { + if (!this.#acceptsEventKey(event.key) || event.defaultPrevented) { + return; + } + + let diff = 0; + switch (event.key) { + case 'ArrowRight': + diff += 1; + break; + case 'ArrowDown': + diff += this.direction === 'grid' ? this.#directionLength() : 1; + break; + case 'ArrowLeft': + diff -= 1; + break; + case 'ArrowUp': + diff -= this.direction === 'grid' ? this.#directionLength() : 1; + break; + case 'End': + this.currentIndex = 0; + diff -= 1; + break; + case 'Home': + this.currentIndex = this.elements.length - 1; + diff += 1; + break; + } + event.preventDefault(); + + // For grid navigation, calculate target position + if (this.direction === 'grid') { + const targetIndex = this.currentIndex + diff; + + if (targetIndex < 0 || targetIndex >= this.elements.length) { + // Beyond boundaries + if (this.#wrap) { + // Wrap around - use circular logic + this.#setCurrentIndexCircularly(diff); + } + // Otherwise stay at current position (don't move) + } else { + // Within boundaries - try to focus the target element + const targetElement = this.elements[targetIndex]; + + if (this.isFocusableElement(targetElement)) { + // Target is focusable, move to it + this.focusToElement(targetIndex); + } else { + // Target is not focusable - search for next focusable element + const isVerticalMove = Math.abs(diff) > 1; + + if (isVerticalMove) { + // Vertical move - if target is disabled, first try to continue vertically in the same column + const direction = diff > 0 ? this.#directionLength() : -this.#directionLength(); + const targetColumn = targetIndex % this.#directionLength(); + let searchIndex = targetIndex + direction; + let found = false; + + // Continue searching vertically in the same column + while (searchIndex >= 0 && searchIndex < this.elements.length) { + const searchColumn = searchIndex % this.#directionLength(); + + // Check if we're still in the same column + if (searchColumn === targetColumn && this.isFocusableElement(this.elements[searchIndex])) { + this.focusToElement(searchIndex); + found = true; + break; + } + + // If we've gone past the same column or reached the end, stop + if (searchColumn !== targetColumn) { + break; + } + + searchIndex += direction; + } + + // If not found vertically, search horizontally in the target row + if (!found) { + const targetRow = Math.floor(targetIndex / this.#directionLength()); + const rowStart = targetRow * this.#directionLength(); + const rowEnd = Math.min(rowStart + this.#directionLength(), this.elements.length); + + // Search right from target in the same row + for (let i = targetIndex + 1; i < rowEnd; i++) { + if (this.isFocusableElement(this.elements[i])) { + this.focusToElement(i); + found = true; + break; + } + } + + // If not found, search left from target in the same row + if (!found) { + for (let i = targetIndex - 1; i >= rowStart; i--) { + if (this.isFocusableElement(this.elements[i])) { + this.focusToElement(i); + found = true; + break; + } + } + } + } + + // If not found horizontally and wrap is enabled, use circular logic + if (!found && this.#wrap) { + this.#setCurrentIndexCircularly(diff); + } + } else { + // Horizontal move - skip to next focusable element + if (this.#wrap) { + this.#setCurrentIndexCircularly(diff); + } else { + // Search for next focusable element in the same direction without wrapping + const direction = diff > 0 ? 1 : -1; + let searchIndex = targetIndex; + + while (searchIndex >= 0 && searchIndex < this.elements.length) { + searchIndex += direction; + if (searchIndex < 0 || searchIndex >= this.elements.length) break; + + if (this.isFocusableElement(this.elements[searchIndex])) { + this.focusToElement(searchIndex); + break; + } + } + } + } + } + } + } else { + this.#setCurrentIndexCircularly(diff); + } + }; + + #acceptsEventKey(key: string): boolean { + if (key === 'End' || key === 'Home') { + return true; + } + switch (this.direction) { + case 'horizontal': + return key === 'ArrowLeft' || key === 'ArrowRight'; + case 'vertical': + return key === 'ArrowUp' || key === 'ArrowDown'; + case 'both': + case 'grid': + return key.startsWith('Arrow'); + } + } + + // Tabindex management + #manageTabindexes(): void { + if (this.focused) { + this.#updateTabindexes(() => ({ tabIndex: -1 })); + } else { + // Find the first focusable element for tabindex=0 + let focusableElement = this.focusInElement; + if (!this.isFocusableElement(focusableElement)) { + const focusableIndex = this.elements.findIndex(el => this.isFocusableElement(el)); + if (focusableIndex !== -1) { + focusableElement = this.elements[focusableIndex]; + } + } + + this.#updateTabindexes((el: HTMLElement): UpdateTabIndexes => { + return { + removeTabIndex: el.contains(focusableElement) && el !== focusableElement, + tabIndex: el === focusableElement ? 0 : -1 + }; + }); + } + } + + #updateTabindexes(getTabIndex: (el: HTMLElement) => UpdateTabIndexes): void { + this.elements.forEach(el => { + const { tabIndex, removeTabIndex } = getTabIndex(el); + if (!removeTabIndex) { + el.tabIndex = tabIndex; + + // Manage autofocus attribute + if (this.#autofocus) { + if (tabIndex === 0 && !this.focused) { + el.setAttribute('autofocus', ''); + } else { + el.removeAttribute('autofocus'); + } + } + + return; + } + el.removeAttribute('tabindex'); + + // Remove autofocus when removing tabindex + if (this.#autofocus) { + el.removeAttribute('autofocus'); + } + + const updatable = el as unknown as { + requestUpdate?(): void; + }; + if (updatable.requestUpdate) { + updatable.requestUpdate(); + } + }); + } + + // Event listener management + #addEventListeners(): void { + this.#scope().addEventListener('focusin', this.#onFocusin); + } + + #removeEventListeners(): void { + const scope = this.#scope(); + + scope.removeEventListener('focusin', this.#onFocusin); + scope.removeEventListener('focusout', this.#onFocusout); + scope.removeEventListener('keydown', this.#onKeydown); + + this.#listenersAdded = false; + } +} diff --git a/packages/components/shared/src/controllers/roving-tabindex.spec.ts b/packages/components/shared/src/controllers/roving-tabindex.spec.ts index 659f5a9a95..ea66e19e0b 100644 --- a/packages/components/shared/src/controllers/roving-tabindex.spec.ts +++ b/packages/components/shared/src/controllers/roving-tabindex.spec.ts @@ -1,9 +1,9 @@ import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; import { property } from 'lit/decorators.js'; import { fake } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import { type RovingTabindexConfig, RovingTabindexController } from './roving-tabindex.js'; class RovingTabindexFixture extends LitElement { 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/switch/src/switch.spec.ts b/packages/components/switch/src/switch.spec.ts index 28fff0c5cf..535fbd2195 100644 --- a/packages/components/switch/src/switch.spec.ts +++ b/packages/components/switch/src/switch.spec.ts @@ -2,10 +2,10 @@ import { type SlFormControlEvent } from '@sl-design-system/form'; import '@sl-design-system/form/register.js'; import { Icon } from '@sl-design-system/icon'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type TemplateResult, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { Switch } from './switch.js'; diff --git a/packages/components/tabs/src/tab-group.spec.ts b/packages/components/tabs/src/tab-group.spec.ts index 08e53bf6a8..ecba61a9a9 100644 --- a/packages/components/tabs/src/tab-group.spec.ts +++ b/packages/components/tabs/src/tab-group.spec.ts @@ -1,8 +1,8 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { TabGroup, type TabsAlignment } from './tab-group.js'; import { type Tab } from './tab.js'; diff --git a/packages/components/tabs/src/tab.spec.ts b/packages/components/tabs/src/tab.spec.ts index 1ed8d50a84..323210592a 100644 --- a/packages/components/tabs/src/tab.spec.ts +++ b/packages/components/tabs/src/tab.spec.ts @@ -1,8 +1,8 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { Tab } from './tab.js'; diff --git a/packages/components/tag/src/tag-list.spec.ts b/packages/components/tag/src/tag-list.spec.ts index 2cbc55b6c3..c12110bef2 100644 --- a/packages/components/tag/src/tag-list.spec.ts +++ b/packages/components/tag/src/tag-list.spec.ts @@ -1,7 +1,7 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { type TagList } from './tag-list.js'; diff --git a/packages/components/tag/src/tag.spec.ts b/packages/components/tag/src/tag.spec.ts index 5fa79fed8d..3bbd9de867 100644 --- a/packages/components/tag/src/tag.spec.ts +++ b/packages/components/tag/src/tag.spec.ts @@ -1,8 +1,8 @@ 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { type Tag } from './tag.js'; diff --git a/packages/components/text-area/src/text-area.spec.ts b/packages/components/text-area/src/text-area.spec.ts index 6bc3a68116..0ce279efd3 100644 --- a/packages/components/text-area/src/text-area.spec.ts +++ b/packages/components/text-area/src/text-area.spec.ts @@ -1,10 +1,10 @@ import { type SlFormControlEvent } from '@sl-design-system/form'; import '@sl-design-system/form/register.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type TemplateResult, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { TextArea } from './text-area.js'; diff --git a/packages/components/text-field/src/text-field.spec.ts b/packages/components/text-field/src/text-field.spec.ts index 8c782234e9..0e51f10f94 100644 --- a/packages/components/text-field/src/text-field.spec.ts +++ b/packages/components/text-field/src/text-field.spec.ts @@ -1,10 +1,10 @@ import { type SlFormControlEvent } from '@sl-design-system/form'; import '@sl-design-system/form/register.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { LitElement, type TemplateResult, html } from 'lit'; import { spy } from 'sinon'; import { beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { TextField } from './text-field.js'; diff --git a/packages/components/time-field/src/time-field.spec.ts b/packages/components/time-field/src/time-field.spec.ts index 76c2ad8a6e..224fda1683 100644 --- a/packages/components/time-field/src/time-field.spec.ts +++ b/packages/components/time-field/src/time-field.spec.ts @@ -1,9 +1,9 @@ import { type TextField } from '@sl-design-system/text-field'; 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { TimeField } from './time-field.js'; diff --git a/packages/components/toggle-button/src/toggle-button.spec.ts b/packages/components/toggle-button/src/toggle-button.spec.ts index 9e18df0066..0397f570a5 100644 --- a/packages/components/toggle-button/src/toggle-button.spec.ts +++ b/packages/components/toggle-button/src/toggle-button.spec.ts @@ -1,10 +1,10 @@ import '@sl-design-system/icon/register.js'; import { type SlToggleEvent } from '@sl-design-system/shared/events.js'; import { fixture } from '@sl-design-system/vitest-browser-lit'; -import { userEvent } from '@vitest/browser/context'; import { html } from 'lit'; import { spy, stub } from 'sinon'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; import '../register.js'; import { type ToggleButton } from './toggle-button.js'; diff --git a/packages/components/tooltip/src/tooltip.spec.ts b/packages/components/tooltip/src/tooltip.spec.ts index 1a0be8c8da..f5d5c0e7d5 100644 --- a/packages/components/tooltip/src/tooltip.spec.ts +++ b/packages/components/tooltip/src/tooltip.spec.ts @@ -1,9 +1,9 @@ import { type Button } from '@sl-design-system/button'; import '@sl-design-system/button/register.js'; 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { Tooltip } from './tooltip.js'; diff --git a/packages/components/tooltip/src/tooltip.ts b/packages/components/tooltip/src/tooltip.ts index 43040f51bb..fdc32efebb 100644 --- a/packages/components/tooltip/src/tooltip.ts +++ b/packages/components/tooltip/src/tooltip.ts @@ -215,6 +215,12 @@ export class Tooltip extends LitElement { /** The maximum width of the tooltip. */ @property({ type: Number, attribute: 'max-width' }) maxWidth?: number; + /** + * The offset distance of the tooltip from its anchor. + * @default Tooltip.offset (12px) + */ + @property({ type: Number }) offset?: number; + /** * Position of the tooltip relative to its anchor. * @type {'top' | 'right' | 'bottom' | 'left' | 'top-start' | 'top-end' | 'right-start' | 'right-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end'} @@ -245,6 +251,10 @@ export class Tooltip extends LitElement { this.#anchor.maxWidth = this.maxWidth; } + if (changes.has('offset')) { + this.#anchor.offset = this.offset ?? Tooltip.offset; + } + if (changes.has('position')) { this.#anchor.position = this.position; } diff --git a/packages/components/tree/src/tree-node.spec.ts b/packages/components/tree/src/tree-node.spec.ts index 1589b596f9..44d9b28786 100644 --- a/packages/components/tree/src/tree-node.spec.ts +++ b/packages/components/tree/src/tree-node.spec.ts @@ -1,9 +1,9 @@ import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; 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 { userEvent } from 'vitest/browser'; import { TreeNode } from './tree-node.js'; // We need to define sl-tree-node ourselves, since it's not diff --git a/packages/components/tree/src/tree.spec.ts b/packages/components/tree/src/tree.spec.ts index 1d7f91e421..3f2d02b73c 100644 --- a/packages/components/tree/src/tree.spec.ts +++ b/packages/components/tree/src/tree.spec.ts @@ -1,8 +1,8 @@ import { type Icon } from '@sl-design-system/icon'; 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 { userEvent } from 'vitest/browser'; import '../register.js'; import { FlatTreeDataSource } from './flat-tree-data-source.js'; import { NestedTreeDataSource } from './nested-tree-data-source.js'; diff --git a/packages/locales/src/nl.ts b/packages/locales/src/nl.ts index c955620260..ef7f3a19a8 100644 --- a/packages/locales/src/nl.ts +++ b/packages/locales/src/nl.ts @@ -7,6 +7,10 @@ export const templates = { 'sl.breadcrumbs.breadcrumbTrail': 'Kruimelpad', 'sl.breadcrumbs.home': 'Home', 'sl.breadcrumbs.moreBreadcrumbs': 'Meer links', + 'sl.calendar.announceMonthsOfYear': str`Maanden van het jaar ${0}`, + 'sl.calendar.announceYears': str`Jaren tussen ${0} en ${1}`, + 'sl.calendar.daysLabel': str`Dagen van ${0}`, + 'sl.calendar.monthsLabel': str`Maanden van ${0}`, 'sl.calendar.nextMonth': str`Volgende maand, ${0}`, 'sl.calendar.nextYear': str`Volgend jaar, ${0}`, 'sl.calendar.nextYears': 'Ga 12 jaar terug', @@ -14,6 +18,7 @@ export const templates = { 'sl.calendar.previousYear': str`Vorig jaar, ${0}`, 'sl.calendar.previousYears': 'Ga 12 jaar vooruit', 'sl.calendar.week': 'Week', + 'sl.calendar.yearsLabel': str`Jaren van ${0} tot ${1}`, 'sl.checkbox.validation.valueMissing': 'Vink dit vakje aan.', 'sl.checkbox.validation.valueMissingMultiple': 'Vink tenminste één optie aan.', 'sl.combobox.createCustomOption': str`Maak "${0}" aan`, @@ -52,6 +57,7 @@ export const templates = { 'sl.messageDialog.cancelButton': 'Annuleren', 'sl.messageDialog.confirmTitle': 'Bevestig', 'sl.messageDialog.okButton': 'Ok', + 'sl.monthView.week': str`Week ${0}`, 'sl.numberField.stepDown': 'Stap omlaag', 'sl.numberField.stepUp': 'Stap omhoog', 'sl.numberField.validation.belowMinimum': str`De waarde moet groter dan of gelijk aan ${0} zijn.`, diff --git a/packages/locales/src/nl.xlf b/packages/locales/src/nl.xlf index 0c1a588385..ece5adef4f 100644 --- a/packages/locales/src/nl.xlf +++ b/packages/locales/src/nl.xlf @@ -350,6 +350,30 @@ Select minutes Selecteer minuten + + Days of + Dagen van + + + Week + Week + + + Months of + Maanden van + + + Months of the year + Maanden van het jaar + + + Years from to + Jaren van tot + + + Years between and + Jaren tussen en + Please enter a valid time in HHMM. Voer een geldige tijd in als HHMM. diff --git a/tools/eslint-config/package.json b/tools/eslint-config/package.json index 0b4608736a..e6813fdb8b 100644 --- a/tools/eslint-config/package.json +++ b/tools/eslint-config/package.json @@ -33,7 +33,7 @@ "eslint-plugin-lit-a11y": "^4.1.4", "eslint-plugin-mocha": "~11.0.0", "eslint-plugin-prettier": "^5.4.1", - "eslint-plugin-storybook": "^9.0.1", + "eslint-plugin-storybook": "^10.0.7", "eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-wc": "^3.0.1", "prettier": "^3.6.2", @@ -51,7 +51,7 @@ }, "devDependencies": { "eslint": "^9.27.0", - "storybook": "^9.1.13", + "storybook": "^10.0.7", "typescript": "^5.5.4" } } diff --git a/tools/eslint-plugin-slds/package.json b/tools/eslint-plugin-slds/package.json index 8bd2ecf1c7..888f21f915 100644 --- a/tools/eslint-plugin-slds/package.json +++ b/tools/eslint-plugin-slds/package.json @@ -34,6 +34,6 @@ "devDependencies": { "eslint": "^9.27.0", "eslint-plugin-lit-a11y": "^4.1.4", - "mocha": "11.7.4" + "mocha": "11.7.5" } } diff --git a/tools/vitest-browser-lit/index.js b/tools/vitest-browser-lit/index.js index 9245b0ee0f..765b282b2f 100644 --- a/tools/vitest-browser-lit/index.js +++ b/tools/vitest-browser-lit/index.js @@ -1,5 +1,5 @@ -import { page } from '@vitest/browser/context'; import { beforeEach } from 'vitest'; +import { page } from 'vitest/browser'; import { fixture, cleanup, oneEvent } from './helpers.js'; page.extend({ fixture, [Symbol.for('vitest:component-cleanup')]: cleanup }); diff --git a/tools/vitest-browser-lit/package.json b/tools/vitest-browser-lit/package.json index 55c3c4097f..10cc673b63 100644 --- a/tools/vitest-browser-lit/package.json +++ b/tools/vitest-browser-lit/package.json @@ -28,8 +28,8 @@ "vitest": ">=2.1.0" }, "devDependencies": { - "@vitest/browser": "^3.2.4", + "@vitest/browser": "^4.0.9", "lit": "^3.3.1", - "vitest": "^3.2.4" + "vitest": "^4.0.9" } } diff --git a/vitest.config.ts b/vitest.config.ts index 2a5e40a429..18ed741ae6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,5 @@ -/// import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; +import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; export default defineConfig({ @@ -18,7 +18,7 @@ export default defineConfig({ browser: { enabled: true, headless: true, - provider: 'playwright', + provider: playwright(), instances: [{ browser: 'chromium' }] }, setupFiles: ['.storybook/vitest.setup.ts'] @@ -32,14 +32,15 @@ export default defineConfig({ browser: { enabled: true, headless: true, - provider: 'playwright', + provider: playwright({ + contextOptions: { + locale: 'en', + reducedMotion: 'reduce' + } + }), instances: [ { - browser: 'chromium', - context: { - locale: 'en', - reducedMotion: 'reduce' - } + browser: 'chromium' } ], viewport: { width: 1024, height: 768 } diff --git a/vitest.setup.ts b/vitest.setup.ts index a4bd75a801..c7c696cf04 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,9 +1,11 @@ -import { commands } from '@vitest/browser/context' import '@webcomponents/scoped-custom-element-registry/scoped-custom-element-registry.min.js'; +import chaiDatetime from 'chai-datetime'; import chaiDom from 'chai-dom'; import sinonChai from 'sinon-chai'; import { chai } from 'vitest'; +import { commands } from 'vitest/browser' +chai.use(chaiDatetime); chai.use(chaiDom); chai.use(sinonChai); diff --git a/yarn.lock b/yarn.lock index bb034d5953..a3068d838a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -166,7 +166,14 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:2.3.0, @ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0": +"@af-utils/scrollend-polyfill@npm:^0.0.14": + version: 0.0.14 + resolution: "@af-utils/scrollend-polyfill@npm:0.0.14" + checksum: 10c0/3068348aa39b3866dacfd137b51440e7f524e3fafed3ac37ea97431469090ed56e8ec49ca9aaa2682514fb04bf4dd82da344354e55d9631c4172e870f126e042 + languageName: node + linkType: hard + +"@ampproject/remapping@npm:2.3.0, @ampproject/remapping@npm:^2.2.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" dependencies: @@ -597,7 +604,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.11, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.11, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" dependencies: @@ -855,10 +862,10 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-validator-identifier@npm:7.27.1" - checksum: 10c0/c558f11c4871d526498e49d07a84752d1800bf72ac0d3dad100309a2eaba24efbf56ea59af5137ff15e3a00280ebe588560534b0e894a4750f8b1411d8f78b84 +"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.27.1, @babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 languageName: node linkType: hard @@ -890,14 +897,14 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.14.7, @babel/parser@npm:^7.16.2, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.9, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.4, @babel/parser@npm:^7.27.5, @babel/parser@npm:^7.6.0, @babel/parser@npm:^7.9.6": - version: 7.28.4 - resolution: "@babel/parser@npm:7.28.4" +"@babel/parser@npm:^7.14.7, @babel/parser@npm:^7.16.2, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.9, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.4, @babel/parser@npm:^7.27.5, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.6.0, @babel/parser@npm:^7.9.6": + version: 7.28.5 + resolution: "@babel/parser@npm:7.28.5" dependencies: - "@babel/types": "npm:^7.28.4" + "@babel/types": "npm:^7.28.5" bin: parser: ./bin/babel-parser.js - checksum: 10c0/58b239a5b1477ac7ed7e29d86d675cc81075ca055424eba6485872626db2dc556ce63c45043e5a679cd925e999471dba8a3ed4864e7ab1dbf64306ab72c52707 + checksum: 10c0/5bbe48bf2c79594ac02b490a41ffde7ef5aa22a9a88ad6bcc78432a6ba8a9d638d531d868bd1f104633f1f6bba9905746e15185b8276a3756c42b765d131b1ef languageName: node linkType: hard @@ -1707,7 +1714,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4": +"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4": version: 7.28.4 resolution: "@babel/runtime@npm:7.28.4" checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7 @@ -1740,13 +1747,13 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.24.7, @babel/types@npm:^7.25.4, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.26.9, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.6, @babel/types@npm:^7.28.4, @babel/types@npm:^7.4.4, @babel/types@npm:^7.6.1, @babel/types@npm:^7.9.6": - version: 7.28.4 - resolution: "@babel/types@npm:7.28.4" +"@babel/types@npm:^7.24.7, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.26.9, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.6, @babel/types@npm:^7.28.5, @babel/types@npm:^7.4.4, @babel/types@npm:^7.6.1, @babel/types@npm:^7.9.6": + version: 7.28.5 + resolution: "@babel/types@npm:7.28.5" dependencies: "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/ac6f909d6191319e08c80efbfac7bd9a25f80cc83b43cd6d82e7233f7a6b9d6e7b90236f3af7400a3f83b576895bcab9188a22b584eb0f224e80e6d4e95f4517 + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/a5a483d2100befbf125793640dec26b90b95fd233a94c19573325898a5ce1e52cdfa96e495c7dcc31b5eca5b66ce3e6d4a0f5a4a62daec271455959f208ab08a languageName: node linkType: hard @@ -3192,6 +3199,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + languageName: node + linkType: hard + "@jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.1 resolution: "@jridgewell/resolve-uri@npm:3.1.1" @@ -3223,7 +3240,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.30, @jridgewell/trace-mapping@npm:^0.3.9": +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.31, @jridgewell/trace-mapping@npm:^0.3.9": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -4502,7 +4519,7 @@ __metadata: "@sl-design-system/text-area": "npm:^1.1.4" "@sl-design-system/text-field": "npm:^1.6.6" "@sl-design-system/time-field": "npm:^0.0.1" - "@storybook/angular": "npm:^9.1.13" + "@storybook/angular": "npm:^10.0.7" "@types/jasmine": "npm:~5.1.12" jasmine-core: "npm:~5.12.0" karma: "npm:~6.4.4" @@ -4512,7 +4529,7 @@ __metadata: karma-jasmine-html-reporter: "npm:~2.1.0" ng-packagr: "npm:^19.2.2" rxjs: "npm:~7.8.2" - storybook: "npm:^9.1.13" + storybook: "npm:^10.0.7" tslib: "npm:^2.8.1" typescript: "npm:~5.5.4" wireit: "npm:^0.14.12" @@ -4620,6 +4637,7 @@ __metadata: "@sl-design-system/button": "npm:^1.2.5" "@sl-design-system/format-date": "npm:^0.1.3" "@sl-design-system/icon": "npm:^1.3.0" + "@sl-design-system/tooltip": "npm:^1.3.0" lit: "npm:^3.3.1" peerDependencies: "@lit/localize": ^0.12.1 @@ -4681,13 +4699,13 @@ __metadata: version: 0.0.0-use.local resolution: "@sl-design-system/chromatic@workspace:chromatic" dependencies: - "@storybook/addon-a11y": "npm:^9.1.13" - "@storybook/addon-docs": "npm:^9.1.13" - "@storybook/addon-themes": "npm:^9.1.13" - "@storybook/web-components-vite": "npm:^9.1.13" + "@storybook/addon-a11y": "npm:^10.0.7" + "@storybook/addon-docs": "npm:^10.0.7" + "@storybook/addon-themes": "npm:^10.0.7" + "@storybook/web-components-vite": "npm:^10.0.7" lit: "npm:^3.3.1" - storybook: "npm:^9.1.13" - storybook-addon-pseudo-states: "npm:^9.1.13" + storybook: "npm:^10.0.7" + storybook-addon-pseudo-states: "npm:^10.0.7" tslib: "npm:^2.8.1" typescript: "npm:^5.5.4" wireit: "npm:^0.14.12" @@ -4848,11 +4866,11 @@ __metadata: eslint-plugin-lit-a11y: "npm:^4.1.4" eslint-plugin-mocha: "npm:~11.0.0" eslint-plugin-prettier: "npm:^5.4.1" - eslint-plugin-storybook: "npm:^9.0.1" + eslint-plugin-storybook: "npm:^10.0.7" eslint-plugin-unused-imports: "npm:^4.1.4" eslint-plugin-wc: "npm:^3.0.1" prettier: "npm:^3.6.2" - storybook: "npm:^9.1.13" + storybook: "npm:^10.0.7" typescript: "npm:^5.5.4" typescript-eslint: "npm:^8.33.0" peerDependencies: @@ -4871,7 +4889,7 @@ __metadata: dependencies: eslint: "npm:^9.27.0" eslint-plugin-lit-a11y: "npm:^4.1.4" - mocha: "npm:11.7.4" + mocha: "npm:11.7.5" peerDependencies: eslint: ^9.25.1 eslint-plugin-lit-a11y: ^4.1.4 @@ -5083,24 +5101,27 @@ __metadata: version: 0.0.0-use.local resolution: "@sl-design-system/monorepo@workspace:." dependencies: + "@af-utils/scrollend-polyfill": "npm:^0.0.14" "@changesets/cli": "npm:^2.29.7" "@changesets/get-github-info": "npm:^0.6.0" "@custom-elements-manifest/analyzer": "npm:^0.10.10" "@faker-js/faker": "npm:^10.1.0" "@lit/localize-tools": "npm:^0.8.0" - "@storybook/addon-a11y": "npm:^9.1.13" - "@storybook/addon-docs": "npm:^9.1.13" - "@storybook/addon-vitest": "npm:^9.1.13" - "@storybook/web-components": "npm:^9.1.13" - "@storybook/web-components-vite": "npm:^9.1.13" + "@storybook/addon-a11y": "npm:^10.0.7" + "@storybook/addon-docs": "npm:^10.0.7" + "@storybook/addon-vitest": "npm:^10.0.7" + "@storybook/web-components": "npm:^10.0.7" + "@storybook/web-components-vite": "npm:^10.0.7" + "@types/chai-datetime": "npm:^1.0.0" "@types/chai-dom": "npm:^1.11.3" - "@types/sinon": "npm:^17.0.4" + "@types/sinon": "npm:^21.0.0" "@types/sinon-chai": "npm:^4.0.0" - "@vitest/browser": "npm:^3.2.4" - "@vitest/coverage-v8": "npm:^3.2.4" - "@vitest/ui": "npm:^3.2.4" + "@vitest/browser-playwright": "npm:^4.0.9" + "@vitest/coverage-v8": "npm:^4.0.9" + "@vitest/ui": "npm:^4.0.9" "@webcomponents/scoped-custom-element-registry": "npm:^0.0.10" - chai: "npm:^6.2.0" + chai: "npm:^6.2.1" + chai-datetime: "npm:^1.8.1" chai-dom: "npm:^1.12.1" chromatic: "npm:^13.3.0" eslint: "npm:^9.27.0" @@ -5111,11 +5132,11 @@ __metadata: playwright: "npm:^1.56.1" sinon: "npm:^21.0.0" sinon-chai: "npm:^4.0.1" - storybook: "npm:^9.1.13" + storybook: "npm:^10.0.7" stylelint: "npm:^16.19.1" typescript: "npm:^5.5.4" - vite: "npm:^7.1.11" - vitest: "npm:^3.2.4" + vite: "npm:^7.2.2" + vitest: "npm:^4.0.9" wireit: "npm:^0.14.12" languageName: unknown linkType: soft @@ -5562,9 +5583,9 @@ __metadata: version: 0.0.0-use.local resolution: "@sl-design-system/vitest-browser-lit@workspace:tools/vitest-browser-lit" dependencies: - "@vitest/browser": "npm:^3.2.4" + "@vitest/browser": "npm:^4.0.9" lit: "npm:^3.3.1" - vitest: "npm:^3.2.4" + vitest: "npm:^4.0.9" peerDependencies: "@vitest/browser": ">=2.1.0" lit: ">3.0.0" @@ -5611,75 +5632,85 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-a11y@npm:^9.1.13": - version: 9.1.13 - resolution: "@storybook/addon-a11y@npm:9.1.13" +"@standard-schema/spec@npm:^1.0.0": + version: 1.0.0 + resolution: "@standard-schema/spec@npm:1.0.0" + checksum: 10c0/a1ab9a8bdc09b5b47aa8365d0e0ec40cc2df6437be02853696a0e377321653b0d3ac6f079a8c67d5ddbe9821025584b1fb71d9cc041a6666a96f1fadf2ece15f + languageName: node + linkType: hard + +"@storybook/addon-a11y@npm:^10.0.7": + version: 10.0.7 + resolution: "@storybook/addon-a11y@npm:10.0.7" dependencies: "@storybook/global": "npm:^5.0.0" axe-core: "npm:^4.2.0" peerDependencies: - storybook: ^9.1.13 - checksum: 10c0/5c44f5996ef9f5dfc7de889309cf58e61e6a776bd6d6021ddc91a0b2f68fd29ed4fe255ed764340949497c68fc7fa4bc4913f0eeae48d41019e3e35def3e0248 + storybook: ^10.0.7 + checksum: 10c0/51cacf99a9f2293b201383b6aeb31078ab6938256a658cfafe7d2080da6df0684c14f654a87b513e81873d9bdecc7c9d7f596f537caf385e4d474ef7f6c3afc3 languageName: node linkType: hard -"@storybook/addon-docs@npm:^9.1.13": - version: 9.1.13 - resolution: "@storybook/addon-docs@npm:9.1.13" +"@storybook/addon-docs@npm:^10.0.7": + version: 10.0.7 + resolution: "@storybook/addon-docs@npm:10.0.7" dependencies: "@mdx-js/react": "npm:^3.0.0" - "@storybook/csf-plugin": "npm:9.1.13" - "@storybook/icons": "npm:^1.4.0" - "@storybook/react-dom-shim": "npm:9.1.13" + "@storybook/csf-plugin": "npm:10.0.7" + "@storybook/icons": "npm:^1.6.0" + "@storybook/react-dom-shim": "npm:10.0.7" react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^9.1.13 - checksum: 10c0/99bf0636034876d672617294db1d9c49e60526d2732da2127df990ea19955bc24a26f9f83ee71197ae9a94b1fc2cffe3b6c17fc9fad51392ebdfa45e88d891f6 + storybook: ^10.0.7 + checksum: 10c0/3af94d8df56f8f8a49481731c93b60af8aa17f14b2c6dbd71493c3aa47ce16143d225271d1311cea7b6d2ce7e1b914a479cc7b3091170a06486949ac79c2dac3 languageName: node linkType: hard -"@storybook/addon-themes@npm:^9.1.13": - version: 9.1.13 - resolution: "@storybook/addon-themes@npm:9.1.13" +"@storybook/addon-themes@npm:^10.0.7": + version: 10.0.7 + resolution: "@storybook/addon-themes@npm:10.0.7" dependencies: ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^9.1.13 - checksum: 10c0/a1911f61b1ad8b0d0b36b2505fd9c1b54aebc738d09dac702e8161a6d95ebad8194216011035129d3c4c3174dd4a15c2b858d0c94ded984ae71813dde831d7f1 + storybook: ^10.0.7 + checksum: 10c0/ddb58bbb2f09954493c70ff6b2231fe67659bb53b472d54bab9ae82daea2f521367459520ba6ffa4ee0db0dfb8fb10406e44e14bad725e507940d7c61044c0d7 languageName: node linkType: hard -"@storybook/addon-vitest@npm:^9.1.13": - version: 9.1.13 - resolution: "@storybook/addon-vitest@npm:9.1.13" +"@storybook/addon-vitest@npm:^10.0.7": + version: 10.0.7 + resolution: "@storybook/addon-vitest@npm:10.0.7" dependencies: "@storybook/global": "npm:^5.0.0" - "@storybook/icons": "npm:^1.4.0" + "@storybook/icons": "npm:^1.6.0" prompts: "npm:^2.4.0" ts-dedent: "npm:^2.2.0" peerDependencies: - "@vitest/browser": ^3.0.0 - "@vitest/runner": ^3.0.0 - storybook: ^9.1.13 - vitest: ^3.0.0 + "@vitest/browser": ^3.0.0 || ^4.0.0 + "@vitest/browser-playwright": ^4.0.0 + "@vitest/runner": ^3.0.0 || ^4.0.0 + storybook: ^10.0.7 + vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: "@vitest/browser": optional: true + "@vitest/browser-playwright": + optional: true "@vitest/runner": optional: true vitest: optional: true - checksum: 10c0/370ef6c7f0ec174b14a5e350e187dba1bd20fbed20afa445eb03eda50b51ead80a97736d09d36c9c3d4b9d11ad35dfc5949fe647e33a2c916a9c20dd841e7ad2 + checksum: 10c0/310a186a8fe24f12061a0a721a18a94b9510e3c7b2a766af18f1ebf9c550136d5fcc19aa6983338ceb97086be9e488c44859d86d8abd64ef1f8091c73dfe1c6e languageName: node linkType: hard -"@storybook/angular@npm:^9.1.13": - version: 9.1.13 - resolution: "@storybook/angular@npm:9.1.13" +"@storybook/angular@npm:^10.0.7": + version: 10.0.7 + resolution: "@storybook/angular@npm:10.0.7" dependencies: - "@storybook/builder-webpack5": "npm:9.1.13" + "@storybook/builder-webpack5": "npm:10.0.7" "@storybook/global": "npm:^5.0.0" telejson: "npm:8.0.0" ts-dedent: "npm:^2.0.0" @@ -5699,7 +5730,7 @@ __metadata: "@angular/platform-browser": ">=18.0.0 < 21.0.0" "@angular/platform-browser-dynamic": ">=18.0.0 < 21.0.0" rxjs: ^6.5.3 || ^7.4.0 - storybook: ^9.1.13 + storybook: ^10.0.7 typescript: ^4.9.0 || ^5.0.0 zone.js: ">=0.14.0" peerDependenciesMeta: @@ -5709,70 +5740,83 @@ __metadata: optional: true zone.js: optional: true - checksum: 10c0/4251d0ce4dd01b191b5fa3aeafae7118cc4e3d1104236efac1b9af4ea5213547735c19874e1cd3569c2a3761f635b7c53377d777c63cff264d1fff76a6e1c6ec + checksum: 10c0/3ff7046c2a54e2fd2c0c823952c626009e6a6eeb0c7dea826c0361625499aaef446427cf9cc2d8e6fac19e6c04a626a27fc2e86392d081e691f36d873a278c16 languageName: node linkType: hard -"@storybook/builder-vite@npm:9.1.13": - version: 9.1.13 - resolution: "@storybook/builder-vite@npm:9.1.13" +"@storybook/builder-vite@npm:10.0.7": + version: 10.0.7 + resolution: "@storybook/builder-vite@npm:10.0.7" dependencies: - "@storybook/csf-plugin": "npm:9.1.13" + "@storybook/csf-plugin": "npm:10.0.7" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^9.1.13 + storybook: ^10.0.7 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/299d9c7c3ea3e46e679384d3e828d314061ded229654292d0c03c3d0d9f2d429522bbd24a143c671d3567b9589ed989425532ed0ae4ec402d95278eb32e42113 + checksum: 10c0/c4e5b9f6bc5d29b9facda00d5349d9d42b462f84457ec1c5b85db80183579e2b86a7e844a68689a80e4801b49fea56376e5aed6f05eb484fd04f20059e0ebc4c languageName: node linkType: hard -"@storybook/builder-webpack5@npm:9.1.13": - version: 9.1.13 - resolution: "@storybook/builder-webpack5@npm:9.1.13" +"@storybook/builder-webpack5@npm:10.0.7": + version: 10.0.7 + resolution: "@storybook/builder-webpack5@npm:10.0.7" dependencies: - "@storybook/core-webpack": "npm:9.1.13" + "@storybook/core-webpack": "npm:10.0.7" case-sensitive-paths-webpack-plugin: "npm:^2.4.0" cjs-module-lexer: "npm:^1.2.3" - css-loader: "npm:^6.7.1" + css-loader: "npm:^7.1.2" es-module-lexer: "npm:^1.5.0" - fork-ts-checker-webpack-plugin: "npm:^8.0.0" + fork-ts-checker-webpack-plugin: "npm:^9.1.0" html-webpack-plugin: "npm:^5.5.0" magic-string: "npm:^0.30.5" - style-loader: "npm:^3.3.1" - terser-webpack-plugin: "npm:^5.3.1" + style-loader: "npm:^4.0.0" + terser-webpack-plugin: "npm:^5.3.14" ts-dedent: "npm:^2.0.0" webpack: "npm:5" webpack-dev-middleware: "npm:^6.1.2" webpack-hot-middleware: "npm:^2.25.1" webpack-virtual-modules: "npm:^0.6.0" peerDependencies: - storybook: ^9.1.13 + storybook: ^10.0.7 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/ef25886dd50ab505b02f163048e6a5dcc6ca3bdd01a5fd2d52101bb23a8832b0b0457228feb07fdf41d0a481afb48cf6c5c21ccc2ea7be6e6e34ce48394bed32 + checksum: 10c0/b9801cb06ffaf1eadf28b1ae6e3e11173dcfabc95b2368fda4c32f23555685b9fb9e3a85224b82ba7279e9cce2b9b7e7b12a557de6472ec008ee7cf341aa04da languageName: node linkType: hard -"@storybook/core-webpack@npm:9.1.13": - version: 9.1.13 - resolution: "@storybook/core-webpack@npm:9.1.13" +"@storybook/core-webpack@npm:10.0.7": + version: 10.0.7 + resolution: "@storybook/core-webpack@npm:10.0.7" dependencies: ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^9.1.13 - checksum: 10c0/2bd0485df504fa32c2ffc762d601dbe72371031601578e723cfc8597d37af47d53d7b1febba3e5ae66e24db7b9821cef489c93211acc4ae66dee64cc72d67fb1 + storybook: ^10.0.7 + checksum: 10c0/96c9e3996d9576ea2fa9b95852fea9b01d745bdfea7f7ed485296aa370244b4658c737077246078b30c44f3574e692dfc72a81f168d9cda9b5b1a1c4d187a7ac languageName: node linkType: hard -"@storybook/csf-plugin@npm:9.1.13": - version: 9.1.13 - resolution: "@storybook/csf-plugin@npm:9.1.13" +"@storybook/csf-plugin@npm:10.0.7": + version: 10.0.7 + resolution: "@storybook/csf-plugin@npm:10.0.7" dependencies: - unplugin: "npm:^1.3.1" + unplugin: "npm:^2.3.5" peerDependencies: - storybook: ^9.1.13 - checksum: 10c0/7ebb7b8456b7f9cfbfaf63a1addf3dd29a7209ae8f3fca00254939c0b5e30492374cca8f32822831a43eb730db4e8cab26a954a322add81396d37bb980a98a51 + esbuild: "*" + rollup: "*" + storybook: ^10.0.7 + vite: "*" + webpack: "*" + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + checksum: 10c0/1a6e350526dc3d221f287af42d2e9110d0fd462f8ce38e7e1f7edf48ea63acec08dacf8e55cc334da67db8364eda9fd8092e94c405511b4d076957be228b4512 languageName: node linkType: hard @@ -5783,50 +5827,50 @@ __metadata: languageName: node linkType: hard -"@storybook/icons@npm:^1.4.0": - version: 1.4.0 - resolution: "@storybook/icons@npm:1.4.0" +"@storybook/icons@npm:^1.6.0": + version: 1.6.0 + resolution: "@storybook/icons@npm:1.6.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - checksum: 10c0/fd0514fb3fa431a8b5939fe1d9fc336b253ef2c25b34792d2d4ee59e13321108d34f8bf223a0981482f54f83c5ef47ffd1a98c376ca9071011c1b8afe2b01d43 + checksum: 10c0/bbec9201a78a730195f9cf377b15856dc414a54d04e30d16c379d062425cc617bfd0d6586ba1716012cfbdab461f0c9693a6a52920f9bd09c7b4291fb116f59c languageName: node linkType: hard -"@storybook/react-dom-shim@npm:9.1.13": - version: 9.1.13 - resolution: "@storybook/react-dom-shim@npm:9.1.13" +"@storybook/react-dom-shim@npm:10.0.7": + version: 10.0.7 + resolution: "@storybook/react-dom-shim@npm:10.0.7" peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^9.1.13 - checksum: 10c0/88150aaf79b543e83ac0f1602544a1aa6e58d869cf262e008cbe100f36d9c6f0c033316486c836533a283b49bca62ee18cd8e36ed8bba4fb7298a479928b2172 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.0.7 + checksum: 10c0/f7d498e4e5224dfc876589e3f20a9df6215396f3c242f131cdde5407310e3eb45a57aa7db9442cf7fc2fe586cdd13fd686828680d52d7585d405b2abc9f4ca61 languageName: node linkType: hard -"@storybook/web-components-vite@npm:^9.1.13": - version: 9.1.13 - resolution: "@storybook/web-components-vite@npm:9.1.13" +"@storybook/web-components-vite@npm:^10.0.7": + version: 10.0.7 + resolution: "@storybook/web-components-vite@npm:10.0.7" dependencies: - "@storybook/builder-vite": "npm:9.1.13" - "@storybook/web-components": "npm:9.1.13" + "@storybook/builder-vite": "npm:10.0.7" + "@storybook/web-components": "npm:10.0.7" peerDependencies: - storybook: ^9.1.13 - checksum: 10c0/81eebe14a9ee913ff1826b04bad1501cc0145ab6a4890baa0e3977d0eec02c79956240b829ae0f86bcb2141d1166575d46f6f9f2f549456a596e95d405aa38e2 + storybook: ^10.0.7 + checksum: 10c0/c72be568f4190fe3db49698150beef8265f5123cd8aaaba362fe4f838e72de27d6ad36ac36db1c51aec5fbb7eeab5cbb2ee54f6bc3879bde64314a6df50370ed languageName: node linkType: hard -"@storybook/web-components@npm:9.1.13, @storybook/web-components@npm:^9.1.13": - version: 9.1.13 - resolution: "@storybook/web-components@npm:9.1.13" +"@storybook/web-components@npm:10.0.7, @storybook/web-components@npm:^10.0.7": + version: 10.0.7 + resolution: "@storybook/web-components@npm:10.0.7" dependencies: "@storybook/global": "npm:^5.0.0" tiny-invariant: "npm:^1.3.1" ts-dedent: "npm:^2.0.0" peerDependencies: lit: ^2.0.0 || ^3.0.0 - storybook: ^9.1.13 - checksum: 10c0/46012d4ee8be5139f65ff40b1df1ca93d46aa12912423f391be69e1947715d98ba5bbcef76200e92aade26d6b79355233999a13ace6e86074dab54f776ad749f + storybook: ^10.0.7 + checksum: 10c0/630b1b6a25dfa621aae6f7b88c2f6e364eaed3b1be32d7c25938d47132f0af2ee0c9fc1101d236c95399b1534607d3a403aaada77385fa02acf69759ba2f9f5b languageName: node linkType: hard @@ -5852,22 +5896,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/dom@npm:^10.4.0": - version: 10.4.1 - resolution: "@testing-library/dom@npm:10.4.1" - dependencies: - "@babel/code-frame": "npm:^7.10.4" - "@babel/runtime": "npm:^7.12.5" - "@types/aria-query": "npm:^5.0.1" - aria-query: "npm:5.3.0" - dom-accessibility-api: "npm:^0.5.9" - lz-string: "npm:^1.5.0" - picocolors: "npm:1.1.1" - pretty-format: "npm:^27.0.2" - checksum: 10c0/19ce048012d395ad0468b0dbcc4d0911f6f9e39464d7a8464a587b29707eed5482000dad728f5acc4ed314d2f4d54f34982999a114d2404f36d048278db815b1 - languageName: node - linkType: hard - "@testing-library/jest-dom@npm:^6.6.3": version: 6.6.3 resolution: "@testing-library/jest-dom@npm:6.6.3" @@ -6037,13 +6065,6 @@ __metadata: languageName: node linkType: hard -"@types/aria-query@npm:^5.0.1": - version: 5.0.4 - resolution: "@types/aria-query@npm:5.0.4" - checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08 - languageName: node - linkType: hard - "@types/body-parser@npm:*": version: 1.19.3 resolution: "@types/body-parser@npm:1.19.3" @@ -6063,6 +6084,15 @@ __metadata: languageName: node linkType: hard +"@types/chai-datetime@npm:^1.0.0": + version: 1.0.0 + resolution: "@types/chai-datetime@npm:1.0.0" + dependencies: + "@types/chai": "npm:*" + checksum: 10c0/a2f741e407fcbdb5ddbf7dfd9880ea7393679b628ad01838b1721149f1396d3700ca606a23ea3ce2b5fa8fde51e7a3195e32f7ec2c2a684333098ef425a266ba + languageName: node + linkType: hard + "@types/chai-dom@npm:^1.11.3": version: 1.11.3 resolution: "@types/chai-dom@npm:1.11.3" @@ -6413,13 +6443,6 @@ __metadata: languageName: node linkType: hard -"@types/parse-json@npm:^4.0.0": - version: 4.0.0 - resolution: "@types/parse-json@npm:4.0.0" - checksum: 10c0/1d3012ab2fcdad1ba313e1d065b737578f6506c8958e2a7a5bdbdef517c7e930796cb1599ee067d5dee942fb3a764df64b5eef7e9ae98548d776e86dcffba985 - languageName: node - linkType: hard - "@types/parse5@npm:^2.2.34": version: 2.2.34 resolution: "@types/parse5@npm:2.2.34" @@ -6535,12 +6558,12 @@ __metadata: languageName: node linkType: hard -"@types/sinon@npm:*, @types/sinon@npm:^17.0.4": - version: 17.0.4 - resolution: "@types/sinon@npm:17.0.4" +"@types/sinon@npm:*, @types/sinon@npm:^21.0.0": + version: 21.0.0 + resolution: "@types/sinon@npm:21.0.0" dependencies: "@types/sinonjs__fake-timers": "npm:*" - checksum: 10c0/7c67ae1050d98a86d8dd771f0a764e97adb9d54812bf3b001195f8cfaa1e2bdfc725d5b970b91e7b0bb6b7c1ca209f47993f2c6f84f1f868313c37441313ca5b + checksum: 10c0/276d610b5f975eba875c207075d031389d9321d407ff2210106301471893e1198fe83798c507b241620f498735806ab729aaca59b51a340d31875e5d61e18d3a languageName: node linkType: hard @@ -6810,57 +6833,63 @@ __metadata: languageName: node linkType: hard -"@vitest/browser@npm:^3.2.4": - version: 3.2.4 - resolution: "@vitest/browser@npm:3.2.4" +"@vitest/browser-playwright@npm:^4.0.9": + version: 4.0.9 + resolution: "@vitest/browser-playwright@npm:4.0.9" dependencies: - "@testing-library/dom": "npm:^10.4.0" - "@testing-library/user-event": "npm:^14.6.1" - "@vitest/mocker": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - magic-string: "npm:^0.30.17" - sirv: "npm:^3.0.1" - tinyrainbow: "npm:^2.0.0" - ws: "npm:^8.18.2" + "@vitest/browser": "npm:4.0.9" + "@vitest/mocker": "npm:4.0.9" + tinyrainbow: "npm:^3.0.3" peerDependencies: playwright: "*" - vitest: 3.2.4 - webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 + vitest: 4.0.9 peerDependenciesMeta: playwright: - optional: true - safaridriver: - optional: true - webdriverio: - optional: true - checksum: 10c0/0db39daad675aad187eff27d5a7f17a9f533d7abc7476ee1a0b83a9c62a7227b24395f4814e034ecb2ebe39f1a2dec0a8c6a7f79b8d5680c3ac79e408727d742 + optional: false + checksum: 10c0/35461eb2d46aa63b56a2cb511d1395125c118c7444103a6e382bc100568326238daa10d07eae0f1cceefb54f9c4e336eeaef3b66b96b915b79148b4ff0ac0bf9 languageName: node linkType: hard -"@vitest/coverage-v8@npm:^3.2.4": - version: 3.2.4 - resolution: "@vitest/coverage-v8@npm:3.2.4" +"@vitest/browser@npm:4.0.9, @vitest/browser@npm:^4.0.9": + version: 4.0.9 + resolution: "@vitest/browser@npm:4.0.9" + dependencies: + "@vitest/mocker": "npm:4.0.9" + "@vitest/utils": "npm:4.0.9" + magic-string: "npm:^0.30.21" + pixelmatch: "npm:7.1.0" + pngjs: "npm:^7.0.0" + sirv: "npm:^3.0.2" + tinyrainbow: "npm:^3.0.3" + ws: "npm:^8.18.3" + peerDependencies: + vitest: 4.0.9 + checksum: 10c0/dd4efcf0634c68e678ad2658ae2defc5ce47b8b499523e4e29cf61d8408518f087f42f2f9952afad683657f4f4f4cc7b4756e6a5a155c657e5a8a70ca2699491 + languageName: node + linkType: hard + +"@vitest/coverage-v8@npm:^4.0.9": + version: 4.0.9 + resolution: "@vitest/coverage-v8@npm:4.0.9" dependencies: - "@ampproject/remapping": "npm:^2.3.0" "@bcoe/v8-coverage": "npm:^1.0.2" - ast-v8-to-istanbul: "npm:^0.3.3" - debug: "npm:^4.4.1" + "@vitest/utils": "npm:4.0.9" + ast-v8-to-istanbul: "npm:^0.3.8" + debug: "npm:^4.4.3" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" istanbul-lib-source-maps: "npm:^5.0.6" - istanbul-reports: "npm:^3.1.7" - magic-string: "npm:^0.30.17" - magicast: "npm:^0.3.5" - std-env: "npm:^3.9.0" - test-exclude: "npm:^7.0.1" - tinyrainbow: "npm:^2.0.0" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.1" + std-env: "npm:^3.10.0" + tinyrainbow: "npm:^3.0.3" peerDependencies: - "@vitest/browser": 3.2.4 - vitest: 3.2.4 + "@vitest/browser": 4.0.9 + vitest: 4.0.9 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10c0/cae3e58d81d56e7e1cdecd7b5baab7edd0ad9dee8dec9353c52796e390e452377d3f04174d40b6986b17c73241a5e773e422931eaa8102dcba0605ff24b25193 + checksum: 10c0/1af0f017400d3234867fa3937a6719724c59a340d3c471b2767abf1141de37a5686e7c0011c545fa597d7c0d15305f7fd139a3466064101443fae33af711fc65 languageName: node linkType: hard @@ -6877,6 +6906,20 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.0.9": + version: 4.0.9 + resolution: "@vitest/expect@npm:4.0.9" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.0.9" + "@vitest/utils": "npm:4.0.9" + chai: "npm:^6.2.0" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/2a250c7e9ba5f9d5b439dca04acd6a9770dcbf819f50ce4e116dc399cc48886568ccf990ce6c757a77ffc1feaf9b4d198db2c635bb612f1f47dcb134e5fb599d + languageName: node + linkType: hard + "@vitest/mocker@npm:3.2.4": version: 3.2.4 resolution: "@vitest/mocker@npm:3.2.4" @@ -6896,7 +6939,26 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": +"@vitest/mocker@npm:4.0.9": + version: 4.0.9 + resolution: "@vitest/mocker@npm:4.0.9" + dependencies: + "@vitest/spy": "npm:4.0.9" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/b2bcbc3501a74174fb7ee6aa18ff4b22205f03333865a8fd47012a6cf924dd37cd3881f780c861dd6607008b02f5154665ce00259c14a4149d6698ecc88ed8db + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:3.2.4": version: 3.2.4 resolution: "@vitest/pretty-format@npm:3.2.4" dependencies: @@ -6905,25 +6967,33 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/runner@npm:3.2.4" +"@vitest/pretty-format@npm:4.0.9": + version: 4.0.9 + resolution: "@vitest/pretty-format@npm:4.0.9" dependencies: - "@vitest/utils": "npm:3.2.4" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/1d169ac7166174087ac779ff892b929f0ab85d23cac5f440f486588a67e451e17f2346a42331646937fdf67c6f6293d627ac7a2b4c7ba3c4f6be47bb8842d7cb + languageName: node + linkType: hard + +"@vitest/runner@npm:4.0.9": + version: 4.0.9 + resolution: "@vitest/runner@npm:4.0.9" + dependencies: + "@vitest/utils": "npm:4.0.9" pathe: "npm:^2.0.3" - strip-literal: "npm:^3.0.0" - checksum: 10c0/e8be51666c72b3668ae3ea348b0196656a4a5adb836cb5e270720885d9517421815b0d6c98bfdf1795ed02b994b7bfb2b21566ee356a40021f5bf4f6ed4e418a + checksum: 10c0/cd4ce8294e44a1776ba3741d2cb54e48303bc0aef514a592a5a31e47f3cdba978e4786d4b79424717adf02bbdeaae2c47e2023a1744c87053068b4f20c195181 languageName: node linkType: hard -"@vitest/snapshot@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/snapshot@npm:3.2.4" +"@vitest/snapshot@npm:4.0.9": + version: 4.0.9 + resolution: "@vitest/snapshot@npm:4.0.9" dependencies: - "@vitest/pretty-format": "npm:3.2.4" - magic-string: "npm:^0.30.17" + "@vitest/pretty-format": "npm:4.0.9" + magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/f8301a3d7d1559fd3d59ed51176dd52e1ed5c2d23aa6d8d6aa18787ef46e295056bc726a021698d8454c16ed825ecba163362f42fa90258bb4a98cfd2c9424fc + checksum: 10c0/772d68cebb2e8d2d402f78685990dca262252e08cdf808599e1bcba48c0e541c77a40fa864fe548729d4330277a9fae026d69876b4198a1658ca08f67213031f languageName: node linkType: hard @@ -6936,20 +7006,27 @@ __metadata: languageName: node linkType: hard -"@vitest/ui@npm:^3.2.4": - version: 3.2.4 - resolution: "@vitest/ui@npm:3.2.4" +"@vitest/spy@npm:4.0.9": + version: 4.0.9 + resolution: "@vitest/spy@npm:4.0.9" + checksum: 10c0/a38d474ee8512ec4ba74ef5dcefc8e51a0cd6b80f94201b429e0f861689d8b106fd1d5f993ca3ac4277d63e0aace888fa281a64472fbf985b27e4890765e4d68 + languageName: node + linkType: hard + +"@vitest/ui@npm:^4.0.9": + version: 4.0.9 + resolution: "@vitest/ui@npm:4.0.9" dependencies: - "@vitest/utils": "npm:3.2.4" + "@vitest/utils": "npm:4.0.9" fflate: "npm:^0.8.2" flatted: "npm:^3.3.3" pathe: "npm:^2.0.3" - sirv: "npm:^3.0.1" - tinyglobby: "npm:^0.2.14" - tinyrainbow: "npm:^2.0.0" + sirv: "npm:^3.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.0.3" peerDependencies: - vitest: 3.2.4 - checksum: 10c0/c3de1b757905d050706c7ab0199185dd8c7e115f2f348b8d5a7468528c6bf90c2c46096e8901602349ac04f5ba83ac23cd98c38827b104d5151cf8ba21739a0c + vitest: 4.0.9 + checksum: 10c0/d9466eea1a27ecbbbca691a379b3f8baf8b5ee89231224c91603e889744e9d6054d04c48ea84671c5d7233615aa352af53c5f22c7210da163e406f8698c94b48 languageName: node linkType: hard @@ -6964,6 +7041,16 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.0.9": + version: 4.0.9 + resolution: "@vitest/utils@npm:4.0.9" + dependencies: + "@vitest/pretty-format": "npm:4.0.9" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/a5557aec1a0d8835580ea93dd64f5507496d8f742da21c11eb4d4e5bd49e9d03bf951541f45eb2bd2c9288b4bcc623a5b132ec989253c9b3931f06b048a677ed + languageName: node + linkType: hard + "@web/config-loader@npm:0.1.3": version: 0.1.3 resolution: "@web/config-loader@npm:0.1.3" @@ -7396,12 +7483,12 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.10.0, acorn@npm:^8.14.0, acorn@npm:^8.14.1, acorn@npm:^8.8.2": - version: 8.14.1 - resolution: "acorn@npm:8.14.1" +"acorn@npm:^8.14.0, acorn@npm:^8.14.1, acorn@npm:^8.15.0, acorn@npm:^8.8.2": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" bin: acorn: bin/acorn - checksum: 10c0/dbd36c1ed1d2fa3550140000371fcf721578095b18777b85a79df231ca093b08edc6858d75d6e48c73e431c174dcf9214edbd7e6fa5911b93bd8abfa54e47123 + checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec languageName: node linkType: hard @@ -7579,13 +7666,6 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": - version: 5.2.0 - resolution: "ansi-styles@npm:5.2.0" - checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df - languageName: node - linkType: hard - "ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" @@ -7660,7 +7740,7 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:5.3.0, aria-query@npm:^5.0.0, aria-query@npm:^5.1.3": +"aria-query@npm:^5.0.0, aria-query@npm:^5.1.3": version: 5.3.0 resolution: "aria-query@npm:5.3.0" dependencies: @@ -7941,14 +8021,14 @@ __metadata: languageName: node linkType: hard -"ast-v8-to-istanbul@npm:^0.3.3": - version: 0.3.5 - resolution: "ast-v8-to-istanbul@npm:0.3.5" +"ast-v8-to-istanbul@npm:^0.3.8": + version: 0.3.8 + resolution: "ast-v8-to-istanbul@npm:0.3.8" dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.30" + "@jridgewell/trace-mapping": "npm:^0.3.31" estree-walker: "npm:^3.0.3" js-tokens: "npm:^9.0.1" - checksum: 10c0/6796d2e79dc82302543f8109a6d75944278903cee6269b46df4a7d923c289754f1c97390df48536657741d387046e11dbedcda8ce2e6441bcbe26f8586a6d715 + checksum: 10c0/6f7d74fc36011699af6d4ad88ecd8efc7d74bd90b8e8dbb1c69d43c8f4bec0ed361fb62a5b5bd98bbee02ee87c62cd8bcc25a39634964e45476bf5489dfa327f languageName: node linkType: hard @@ -8273,15 +8353,6 @@ __metadata: languageName: node linkType: hard -"better-opn@npm:^3.0.2": - version: 3.0.2 - resolution: "better-opn@npm:3.0.2" - dependencies: - open: "npm:^8.0.4" - checksum: 10c0/911ef25d44da75aabfd2444ce7a4294a8000ebcac73068c04a60298b0f7c7506b60421aa4cd02ac82502fb42baaff7e4892234b51e6923eded44c5a11185f2f5 - languageName: node - linkType: hard - "better-path-resolve@npm:1.0.0": version: 1.0.0 resolution: "better-path-resolve@npm:1.0.0" @@ -8522,13 +8593,6 @@ __metadata: languageName: node linkType: hard -"cac@npm:^6.7.14": - version: 6.7.14 - resolution: "cac@npm:6.7.14" - checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 - languageName: node - linkType: hard - "cacache@npm:^17.0.0": version: 17.1.4 resolution: "cacache@npm:17.1.4" @@ -8719,6 +8783,15 @@ __metadata: languageName: node linkType: hard +"chai-datetime@npm:^1.8.1": + version: 1.8.1 + resolution: "chai-datetime@npm:1.8.1" + dependencies: + chai: "npm:>1.9.0" + checksum: 10c0/3bb2e342a517027fbef4d8ab7907dfb478a6260b77263ea2359cecbac4bf4c429e8ccbf9371bfc6b08dc00c61fe49ee0ce28e0cd8573f8e29859c429be26b95e + languageName: node + linkType: hard + "chai-dom@npm:^1.12.1": version: 1.12.1 resolution: "chai-dom@npm:1.12.1" @@ -8728,6 +8801,13 @@ __metadata: languageName: node linkType: hard +"chai@npm:>1.9.0, chai@npm:^6.2.0, chai@npm:^6.2.1": + version: 6.2.1 + resolution: "chai@npm:6.2.1" + checksum: 10c0/0c2d84392d7c6d44ca5d14d94204f1760e22af68b83d1f4278b5c4d301dabfc0242da70954dd86b1eda01e438f42950de6cf9d569df2103678538e4014abe50b + languageName: node + linkType: hard + "chai@npm:^5.2.0": version: 5.2.0 resolution: "chai@npm:5.2.0" @@ -8741,13 +8821,6 @@ __metadata: languageName: node linkType: hard -"chai@npm:^6.2.0": - version: 6.2.0 - resolution: "chai@npm:6.2.0" - checksum: 10c0/a4b7d7f5907187e09f1847afa838d6d1608adc7d822031b7900813c4ed5d9702911ac2468bf290676f22fddb3d727b1be90b57c1d0a69b902534ee29cdc6ff8a - languageName: node - linkType: hard - "chalk-template@npm:^0.4.0": version: 0.4.0 resolution: "chalk-template@npm:0.4.0" @@ -9530,16 +9603,20 @@ __metadata: languageName: node linkType: hard -"cosmiconfig@npm:^7.0.1": - version: 7.1.0 - resolution: "cosmiconfig@npm:7.1.0" +"cosmiconfig@npm:^8.2.0": + version: 8.3.6 + resolution: "cosmiconfig@npm:8.3.6" dependencies: - "@types/parse-json": "npm:^4.0.0" - import-fresh: "npm:^3.2.1" - parse-json: "npm:^5.0.0" + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" path-type: "npm:^4.0.0" - yaml: "npm:^1.10.0" - checksum: 10c0/b923ff6af581638128e5f074a5450ba12c0300b71302398ea38dbeabd33bbcaa0245ca9adbedfcf284a07da50f99ede5658c80bb3e39e2ce770a99d28a21ef03 + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/0382a9ed13208f8bfc22ca2f62b364855207dffdb73dc26e150ade78c3093f1cf56172df2dd460c8caf2afa91c0ed4ec8a88c62f8f9cd1cf423d26506aa8797a languageName: node linkType: hard @@ -9608,7 +9685,7 @@ __metadata: languageName: node linkType: hard -"css-loader@npm:7.1.2": +"css-loader@npm:7.1.2, css-loader@npm:^7.1.2": version: 7.1.2 resolution: "css-loader@npm:7.1.2" dependencies: @@ -9632,30 +9709,6 @@ __metadata: languageName: node linkType: hard -"css-loader@npm:^6.7.1": - version: 6.10.0 - resolution: "css-loader@npm:6.10.0" - dependencies: - icss-utils: "npm:^5.1.0" - postcss: "npm:^8.4.33" - postcss-modules-extract-imports: "npm:^3.0.0" - postcss-modules-local-by-default: "npm:^4.0.4" - postcss-modules-scope: "npm:^3.1.1" - postcss-modules-values: "npm:^4.0.0" - postcss-value-parser: "npm:^4.2.0" - semver: "npm:^7.5.4" - peerDependencies: - "@rspack/core": 0.x || 1.x - webpack: ^5.0.0 - peerDependenciesMeta: - "@rspack/core": - optional: true - webpack: - optional: true - checksum: 10c0/acadd2a93f505bf8a8d1c6912a476ef953585f195412b6aa1f2581053bcce8563b833f2a6666c1e1521f4b35fb315176563495a38933becc89e3143cfa7dce45 - languageName: node - linkType: hard - "css-select@npm:^4.1.3": version: 4.3.0 resolution: "css-select@npm:4.3.0" @@ -9889,7 +9942,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.1": +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -10231,13 +10284,6 @@ __metadata: languageName: node linkType: hard -"dom-accessibility-api@npm:^0.5.9": - version: 0.5.16 - resolution: "dom-accessibility-api@npm:0.5.16" - checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053 - languageName: node - linkType: hard - "dom-accessibility-api@npm:^0.6.3": version: 0.6.3 resolution: "dom-accessibility-api@npm:0.6.3" @@ -10776,17 +10822,6 @@ __metadata: languageName: node linkType: hard -"esbuild-register@npm:^3.5.0": - version: 3.5.0 - resolution: "esbuild-register@npm:3.5.0" - dependencies: - debug: "npm:^4.3.4" - peerDependencies: - esbuild: ">=0.12 <1" - checksum: 10c0/9ccd0573cb66018e4cce3c1416eed0f5f3794c7026ce469a94e2f8761335abed8e363fc8e8bb036ab9ad7e579bb4296b8568a04ae5626596c123576b0d9c9bde - languageName: node - linkType: hard - "esbuild-wasm@npm:0.25.4": version: 0.25.4 resolution: "esbuild-wasm@npm:0.25.4" @@ -11200,15 +11235,15 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-storybook@npm:^9.0.1": - version: 9.0.1 - resolution: "eslint-plugin-storybook@npm:9.0.1" +"eslint-plugin-storybook@npm:^10.0.7": + version: 10.0.7 + resolution: "eslint-plugin-storybook@npm:10.0.7" dependencies: "@typescript-eslint/utils": "npm:^8.8.1" peerDependencies: eslint: ">=8" - storybook: ^9.0.1 - checksum: 10c0/be794f5c9eb2e270fe3f450108b2ed0348767d2f751e666bcb85f3b632f9372cdec13348afb68449ad62a56813a836195ca0052fe9599755b0907037a92c9cd0 + storybook: ^10.0.7 + checksum: 10c0/18a779e6d00d8aa3d977414feefb28f447a3f0370d0608faa76e501d19dbd4ab38be3c81b386f90bcd58bf7e1f3feb7944b17c2d2a2dc456a8ae59391f169434 languageName: node linkType: hard @@ -11489,7 +11524,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.1": +"expect-type@npm:^1.2.2": version: 1.2.2 resolution: "expect-type@npm:1.2.2" checksum: 10c0/6019019566063bbc7a690d9281d920b1a91284a4a093c2d55d71ffade5ac890cf37a51e1da4602546c4b56569d2ad2fc175a2ccee77d1ae06cb3af91ef84f44b @@ -12005,14 +12040,14 @@ __metadata: languageName: node linkType: hard -"fork-ts-checker-webpack-plugin@npm:^8.0.0": - version: 8.0.0 - resolution: "fork-ts-checker-webpack-plugin@npm:8.0.0" +"fork-ts-checker-webpack-plugin@npm:^9.1.0": + version: 9.1.0 + resolution: "fork-ts-checker-webpack-plugin@npm:9.1.0" dependencies: "@babel/code-frame": "npm:^7.16.7" chalk: "npm:^4.1.2" - chokidar: "npm:^3.5.3" - cosmiconfig: "npm:^7.0.1" + chokidar: "npm:^4.0.1" + cosmiconfig: "npm:^8.2.0" deepmerge: "npm:^4.2.2" fs-extra: "npm:^10.0.0" memfs: "npm:^3.4.1" @@ -12024,7 +12059,7 @@ __metadata: peerDependencies: typescript: ">3.6.0" webpack: ^5.11.0 - checksum: 10c0/1a2bb9bbd3e943e3b3a45d7fa9e8383698f5fea1ba28f7d18c8372c804460c2f13af53f791360b973fddafd3e88de7af59082c3cb3375f4e7c3365cd85accedc + checksum: 10c0/b4acdf400862af5f57d3e159b3a444e7f9f73e9f4609d54604c3810f75f8adcea0165a8b17ee856ed3c65591d058ffd73cd08d273e289d4952844e75f6efa85d languageName: node linkType: hard @@ -12377,7 +12412,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.4.1, glob@npm:^10.4.5": +"glob@npm:^10.2.2, glob@npm:^10.4.5": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -14179,7 +14214,7 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.0.5, istanbul-reports@npm:^3.1.7": +"istanbul-reports@npm:^3.0.5, istanbul-reports@npm:^3.2.0": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" dependencies: @@ -15283,15 +15318,6 @@ __metadata: languageName: node linkType: hard -"lz-string@npm:^1.5.0": - version: 1.5.0 - resolution: "lz-string@npm:1.5.0" - bin: - lz-string: bin/bin.js - checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b - languageName: node - linkType: hard - "magic-string@npm:0.30.17": version: 0.30.17 resolution: "magic-string@npm:0.30.17" @@ -15310,23 +15336,23 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.17, magic-string@npm:^0.30.5": - version: 0.30.18 - resolution: "magic-string@npm:0.30.18" +"magic-string@npm:^0.30.17, magic-string@npm:^0.30.21, magic-string@npm:^0.30.5": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" dependencies: "@jridgewell/sourcemap-codec": "npm:^1.5.5" - checksum: 10c0/80fba01e13ce1f5c474a0498a5aa462fa158eb56567310747089a0033e432d83a2021ee2c109ac116010cd9dcf90a5231d89fbe3858165f73c00a50a74dbefcd + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a languageName: node linkType: hard -"magicast@npm:^0.3.5": - version: 0.3.5 - resolution: "magicast@npm:0.3.5" +"magicast@npm:^0.5.1": + version: 0.5.1 + resolution: "magicast@npm:0.5.1" dependencies: - "@babel/parser": "npm:^7.25.4" - "@babel/types": "npm:^7.25.4" - source-map-js: "npm:^1.2.0" - checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64 + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + source-map-js: "npm:^1.2.1" + checksum: 10c0/a00bbf3688b9b3e83c10b3bfe3f106cc2ccbf20c4f2dc1c9020a10556dfe0a6a6605a445ee8e86a6e2b484ec519a657b5e405532684f72678c62e4c0d32f962c languageName: node linkType: hard @@ -16509,9 +16535,9 @@ __metadata: languageName: node linkType: hard -"mocha@npm:11.7.4": - version: 11.7.4 - resolution: "mocha@npm:11.7.4" +"mocha@npm:11.7.5": + version: 11.7.5 + resolution: "mocha@npm:11.7.5" dependencies: browser-stdout: "npm:^1.3.1" chokidar: "npm:^4.0.1" @@ -16537,7 +16563,7 @@ __metadata: bin: _mocha: bin/_mocha mocha: bin/mocha.js - checksum: 10c0/f84252dd93b7d67e20e3ca09c6be0da77d43795d502132a976450c5c19025de632bbaab6751dd1d8fd6311a3063d2f3647e61db84feb17900995d52e01cfb3b8 + checksum: 10c0/e6150cba85345aaabbc5b2e7341b1e6706b878f5a9782960cad57fd4cc458730a76d08c5f1a3e05d3ebb49cad93b455764bb00727bd148dcaf0c6dd4fa665b3d languageName: node linkType: hard @@ -17333,7 +17359,7 @@ __metadata: languageName: node linkType: hard -"open@npm:^8.0.2, open@npm:^8.0.4": +"open@npm:^8.0.2": version: 8.4.2 resolution: "open@npm:8.4.2" dependencies: @@ -17693,7 +17719,7 @@ __metadata: languageName: node linkType: hard -"parse-json@npm:^5.0.0, parse-json@npm:^5.2.0": +"parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" dependencies: @@ -17918,7 +17944,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": +"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -17993,6 +18019,17 @@ __metadata: languageName: node linkType: hard +"pixelmatch@npm:7.1.0": + version: 7.1.0 + resolution: "pixelmatch@npm:7.1.0" + dependencies: + pngjs: "npm:^7.0.0" + bin: + pixelmatch: bin/pixelmatch + checksum: 10c0/ff069f92edaa841ac9b58b0ab74e1afa1f3b5e770eea0218c96bac1da4e752f5f6b79a0f9c4ba6b02afb955d39b8c78bcc3cc884f8122b67a1f2efbbccbe1a73 + languageName: node + linkType: hard + "pkg-dir@npm:^4.1.0": version: 4.2.0 resolution: "pkg-dir@npm:4.2.0" @@ -18044,6 +18081,13 @@ __metadata: languageName: node linkType: hard +"pngjs@npm:^7.0.0": + version: 7.0.0 + resolution: "pngjs@npm:7.0.0" + checksum: 10c0/0d4c7a0fd476a9c33df7d0a2a73e1d56537628a668841f6995c2bca070cf30819f9254a64363266bc14ef2fee47659dd3b4f2b18eec7ab65143015139f497b38 + languageName: node + linkType: hard + "portfinder@npm:^1.0.32": version: 1.0.32 resolution: "portfinder@npm:1.0.32" @@ -18294,7 +18338,7 @@ __metadata: languageName: node linkType: hard -"postcss-modules-extract-imports@npm:^3.0.0, postcss-modules-extract-imports@npm:^3.1.0": +"postcss-modules-extract-imports@npm:^3.1.0": version: 3.1.0 resolution: "postcss-modules-extract-imports@npm:3.1.0" peerDependencies: @@ -18303,7 +18347,7 @@ __metadata: languageName: node linkType: hard -"postcss-modules-local-by-default@npm:^4.0.4, postcss-modules-local-by-default@npm:^4.0.5": +"postcss-modules-local-by-default@npm:^4.0.5": version: 4.2.0 resolution: "postcss-modules-local-by-default@npm:4.2.0" dependencies: @@ -18316,7 +18360,7 @@ __metadata: languageName: node linkType: hard -"postcss-modules-scope@npm:^3.1.1, postcss-modules-scope@npm:^3.2.0": +"postcss-modules-scope@npm:^3.2.0": version: 3.2.1 resolution: "postcss-modules-scope@npm:3.2.1" dependencies: @@ -18687,17 +18731,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^27.0.2": - version: 27.5.1 - resolution: "pretty-format@npm:27.5.1" - dependencies: - ansi-regex: "npm:^5.0.1" - ansi-styles: "npm:^5.0.0" - react-is: "npm:^17.0.1" - checksum: 10c0/0cbda1031aa30c659e10921fa94e0dd3f903ecbbbe7184a729ad66f2b6e7f17891e8c7d7654c458fa4ccb1a411ffb695b4f17bbcd3fe075fabe181027c4040ed - languageName: node - linkType: hard - "prismjs@npm:^1.29.0": version: 1.29.0 resolution: "prismjs@npm:1.29.0" @@ -19158,13 +19191,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^17.0.1": - version: 17.0.2 - resolution: "react-is@npm:17.0.2" - checksum: 10c0/2bdb6b93fbb1820b024b496042cce405c57e2f85e777c9aabd55f9b26d145408f9f74f5934676ffdc46f3dcff656d78413a6e43968e7b3f92eea35b3052e9053 - languageName: node - linkType: hard - "react@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": version: 19.0.0 resolution: "react@npm:19.0.0" @@ -20471,7 +20497,7 @@ __metadata: languageName: node linkType: hard -"sirv@npm:^3.0.1": +"sirv@npm:^3.0.2": version: 3.0.2 resolution: "sirv@npm:3.0.2" dependencies: @@ -20668,7 +20694,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -20897,10 +20923,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.9.0": - version: 3.9.0 - resolution: "std-env@npm:3.9.0" - checksum: 10c0/4a6f9218aef3f41046c3c7ecf1f98df00b30a07f4f35c6d47b28329bc2531eef820828951c7d7b39a1c5eb19ad8a46e3ddfc7deb28f0a2f3ceebee11bab7ba50 +"std-env@npm:^3.10.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f languageName: node linkType: hard @@ -20921,28 +20947,27 @@ __metadata: languageName: node linkType: hard -"storybook-addon-pseudo-states@npm:^9.1.13": - version: 9.1.13 - resolution: "storybook-addon-pseudo-states@npm:9.1.13" +"storybook-addon-pseudo-states@npm:^10.0.7": + version: 10.0.7 + resolution: "storybook-addon-pseudo-states@npm:10.0.7" peerDependencies: - storybook: ^9.1.13 - checksum: 10c0/e2cbe4a075f2d58649e845fa91e3acb0a5c3c5cb3911c6604a7fbc7572996515fc1f65565716c4c774367f2b848ab231fe7cd0791ec9bc9411d7b03ebcfddc6b + storybook: ^10.0.7 + checksum: 10c0/0749e12274f15e8b905e890d342025662d43d95e080810dba842ea67766643cc4fb7ed1e130877c0bb3c296178e1361fca426d945bfe7dd34e9b16f039476961 languageName: node linkType: hard -"storybook@npm:^9.1.13": - version: 9.1.13 - resolution: "storybook@npm:9.1.13" +"storybook@npm:^10.0.7": + version: 10.0.7 + resolution: "storybook@npm:10.0.7" dependencies: "@storybook/global": "npm:^5.0.0" + "@storybook/icons": "npm:^1.6.0" "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/user-event": "npm:^14.6.1" "@vitest/expect": "npm:3.2.4" "@vitest/mocker": "npm:3.2.4" "@vitest/spy": "npm:3.2.4" - better-opn: "npm:^3.0.2" esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0" - esbuild-register: "npm:^3.5.0" recast: "npm:^0.23.5" semver: "npm:^7.6.2" ws: "npm:^8.18.0" @@ -20952,8 +20977,8 @@ __metadata: prettier: optional: true bin: - storybook: ./bin/index.cjs - checksum: 10c0/0f5cd0fa3af164a312148bbe38555fe171050be75ce9136e9148ea78a2c8ea243ea0c8fa3f15028a4952676cdda2d0c83883dadf17cb5bae895571ca7746e8a4 + storybook: ./dist/bin/dispatcher.js + checksum: 10c0/a4ba61ac10cb87a4ff2845f35772f0dacd765572059c195596b50d78c2b2b515ae8c3c6d4724ad328f71edef5e2db3a4c363fc7bf1e15e347104077663916a9f languageName: node linkType: hard @@ -21197,15 +21222,6 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-literal@npm:3.0.0" - dependencies: - js-tokens: "npm:^9.0.1" - checksum: 10c0/d81657f84aba42d4bbaf2a677f7e7f34c1f3de5a6726db8bc1797f9c0b303ba54d4660383a74bde43df401cf37cce1dff2c842c55b077a4ceee11f9e31fba828 - languageName: node - linkType: hard - "style-dictionary@npm:^5.0.0-rc.1, style-dictionary@npm:^5.1.1": version: 5.1.1 resolution: "style-dictionary@npm:5.1.1" @@ -21228,12 +21244,12 @@ __metadata: languageName: node linkType: hard -"style-loader@npm:^3.3.1": - version: 3.3.3 - resolution: "style-loader@npm:3.3.3" +"style-loader@npm:^4.0.0": + version: 4.0.0 + resolution: "style-loader@npm:4.0.0" peerDependencies: - webpack: ^5.0.0 - checksum: 10c0/104bae8abd0627579dc14f3917cf65f1117e8098e3529872f09c26b5eee07933567b7be5c8ebf94d16e322b6e726dc569c5787111bf3786915850db4e351ef33 + webpack: ^5.27.0 + checksum: 10c0/214bc0f3b018f8c374f79b9fa16da43df78c7fef2261e9a99e36c2f8387601fad10ac75a171aa8edba75903db214bc46952ae08b94a1f8544bd146c2c8d07d27 languageName: node linkType: hard @@ -21634,7 +21650,7 @@ __metadata: languageName: node linkType: hard -"terser-webpack-plugin@npm:^5.3.1, terser-webpack-plugin@npm:^5.3.11": +"terser-webpack-plugin@npm:^5.3.11, terser-webpack-plugin@npm:^5.3.14": version: 5.3.14 resolution: "terser-webpack-plugin@npm:5.3.14" dependencies: @@ -21684,17 +21700,6 @@ __metadata: languageName: node linkType: hard -"test-exclude@npm:^7.0.1": - version: 7.0.1 - resolution: "test-exclude@npm:7.0.1" - dependencies: - "@istanbuljs/schema": "npm:^0.1.2" - glob: "npm:^10.4.1" - minimatch: "npm:^9.0.4" - checksum: 10c0/6d67b9af4336a2e12b26a68c83308c7863534c65f27ed4ff7068a56f5a58f7ac703e8fc80f698a19bb154fd8f705cdf7ec347d9512b2c522c737269507e7b263 - languageName: node - linkType: hard - "text-decoder@npm:^1.1.0": version: 1.2.3 resolution: "text-decoder@npm:1.2.3" @@ -21758,7 +21763,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" dependencies: @@ -21768,13 +21773,6 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^1.1.1": - version: 1.1.1 - resolution: "tinypool@npm:1.1.1" - checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b - languageName: node - linkType: hard - "tinyrainbow@npm:^2.0.0": version: 2.0.0 resolution: "tinyrainbow@npm:2.0.0" @@ -21782,6 +21780,13 @@ __metadata: languageName: node linkType: hard +"tinyrainbow@npm:^3.0.3": + version: 3.0.3 + resolution: "tinyrainbow@npm:3.0.3" + checksum: 10c0/1e799d35cd23cabe02e22550985a3051dc88814a979be02dc632a159c393a998628eacfc558e4c746b3006606d54b00bcdea0c39301133956d10a27aa27e988c + languageName: node + linkType: hard + "tinyspy@npm:^4.0.3": version: 4.0.3 resolution: "tinyspy@npm:4.0.3" @@ -22462,15 +22467,15 @@ __metadata: languageName: node linkType: hard -"unplugin@npm:^1.3.1": - version: 1.5.0 - resolution: "unplugin@npm:1.5.0" +"unplugin@npm:^2.3.5": + version: 2.3.10 + resolution: "unplugin@npm:2.3.10" dependencies: - acorn: "npm:^8.10.0" - chokidar: "npm:^3.5.3" - webpack-sources: "npm:^3.2.3" - webpack-virtual-modules: "npm:^0.5.0" - checksum: 10c0/2f79a7bf6b428a6aac80bf21852ed83cafead0ae3ed8866db1dca1cd4489f3b50c95874275e9a9b0f10c2e3c4892bfe0431c70d13635775c4c620a6a3f9eae37 + "@jridgewell/remapping": "npm:^2.3.5" + acorn: "npm:^8.15.0" + picomatch: "npm:^4.0.3" + webpack-virtual-modules: "npm:^0.6.2" + checksum: 10c0/29dcd738772aeff91c6f0154f156c95c58a37a4674fcb7cc34d6868af763834f0f447a1c3af074818c0c5602baead49bd3b9399a13f0425d69a00a527e58ddda languageName: node linkType: hard @@ -22650,21 +22655,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:3.2.4": - version: 3.2.4 - resolution: "vite-node@npm:3.2.4" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.4.1" - es-module-lexer: "npm:^1.7.0" - pathe: "npm:^2.0.3" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - bin: - vite-node: vite-node.mjs - checksum: 10c0/6ceca67c002f8ef6397d58b9539f80f2b5d79e103a18367288b3f00a8ab55affa3d711d86d9112fce5a7fa658a212a087a005a045eb8f4758947dd99af2a6c6b - languageName: node - linkType: hard - "vite@npm:6.2.7": version: 6.2.7 resolution: "vite@npm:6.2.7" @@ -22717,9 +22707,9 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.1.11": - version: 7.1.11 - resolution: "vite@npm:7.1.11" +"vite@npm:^6.0.0 || ^7.0.0, vite@npm:^7.2.2": + version: 7.2.2 + resolution: "vite@npm:7.2.2" dependencies: esbuild: "npm:^0.25.0" fdir: "npm:^6.5.0" @@ -22768,43 +22758,42 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/c4aa7f47b1fb07f734ed6f4f605d73e5acf7ff9754d75b4adbfbdddf0e520413019834620c1f7b4a207bce7e1d20a2636c584db2b1b17f5a3ba2cd23d47e50ab + checksum: 10c0/9c76ee441f8dbec645ddaecc28d1f9cf35670ffa91cff69af7b1d5081545331603f0b1289d437b2fa8dc43cdc77b4d96b5bd9c9aed66310f490cb1a06f9c814c languageName: node linkType: hard -"vitest@npm:^3.2.4": - version: 3.2.4 - resolution: "vitest@npm:3.2.4" - dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/expect": "npm:3.2.4" - "@vitest/mocker": "npm:3.2.4" - "@vitest/pretty-format": "npm:^3.2.4" - "@vitest/runner": "npm:3.2.4" - "@vitest/snapshot": "npm:3.2.4" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - debug: "npm:^4.4.1" - expect-type: "npm:^1.2.1" - magic-string: "npm:^0.30.17" +"vitest@npm:^4.0.9": + version: 4.0.9 + resolution: "vitest@npm:4.0.9" + dependencies: + "@vitest/expect": "npm:4.0.9" + "@vitest/mocker": "npm:4.0.9" + "@vitest/pretty-format": "npm:4.0.9" + "@vitest/runner": "npm:4.0.9" + "@vitest/snapshot": "npm:4.0.9" + "@vitest/spy": "npm:4.0.9" + "@vitest/utils": "npm:4.0.9" + debug: "npm:^4.4.3" + es-module-lexer: "npm:^1.7.0" + expect-type: "npm:^1.2.2" + magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.2" - std-env: "npm:^3.9.0" + picomatch: "npm:^4.0.3" + std-env: "npm:^3.10.0" tinybench: "npm:^2.9.0" tinyexec: "npm:^0.3.2" - tinyglobby: "npm:^0.2.14" - tinypool: "npm:^1.1.1" - tinyrainbow: "npm:^2.0.0" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node: "npm:3.2.4" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.0.3" + vite: "npm:^6.0.0 || ^7.0.0" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@types/debug": ^4.1.12 - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 3.2.4 - "@vitest/ui": 3.2.4 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.0.9 + "@vitest/browser-preview": 4.0.9 + "@vitest/browser-webdriverio": 4.0.9 + "@vitest/ui": 4.0.9 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -22814,7 +22803,11 @@ __metadata: optional: true "@types/node": optional: true - "@vitest/browser": + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": optional: true "@vitest/ui": optional: true @@ -22824,7 +22817,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/5bf53ede3ae6a0e08956d72dab279ae90503f6b5a05298a6a5e6ef47d2fd1ab386aaf48fafa61ed07a0ebfe9e371772f1ccbe5c258dd765206a8218bf2eb79eb + checksum: 10c0/aa66f926f23e9f75892417be2bd75fb8d088784caa424c6bcd15d6ad42300172d6ee8b9067705bef878fd6e9ebdb2010fcc177386ede046e53d5c2619950c1fc languageName: node linkType: hard @@ -23023,14 +23016,7 @@ __metadata: languageName: node linkType: hard -"webpack-virtual-modules@npm:^0.5.0": - version: 0.5.0 - resolution: "webpack-virtual-modules@npm:0.5.0" - checksum: 10c0/0742e069cd49d91ccd0b59431b3666903d321582c1b1062fa6bdae005c3538af55ff8787ea5eafbf72662f3496d3a879e2c705d55ca0af8283548a925be18484 - languageName: node - linkType: hard - -"webpack-virtual-modules@npm:^0.6.0": +"webpack-virtual-modules@npm:^0.6.0, webpack-virtual-modules@npm:^0.6.2": version: 0.6.2 resolution: "webpack-virtual-modules@npm:0.6.2" checksum: 10c0/5ffbddf0e84bf1562ff86cf6fcf039c74edf09d78358a6904a09bbd4484e8bb6812dc385fe14330b715031892dcd8423f7a88278b57c9f5002c84c2860179add @@ -23357,7 +23343,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.13.0, ws@npm:^8.18.0, ws@npm:^8.18.2": +"ws@npm:^8.13.0, ws@npm:^8.18.0, ws@npm:^8.18.3": version: 8.18.3 resolution: "ws@npm:8.18.3" peerDependencies: @@ -23415,13 +23401,6 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^1.10.0": - version: 1.10.2 - resolution: "yaml@npm:1.10.2" - checksum: 10c0/5c28b9eb7adc46544f28d9a8d20c5b3cb1215a886609a2fd41f51628d8aaa5878ccd628b755dbcd29f6bb4921bd04ffbc6dcc370689bb96e594e2f9813d2605f - languageName: node - linkType: hard - "yaml@npm:^2.8.1": version: 2.8.1 resolution: "yaml@npm:2.8.1"