diff --git a/README.md b/README.md index 487488e..a99a1b6 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,71 @@ const formatter = ref({ ``` +## Native Scroll Selector Mode + +Enable native-like month/year scrolling with `selectorMode` (use `:selector-mode` in templates). Default is `false`. + +**Single date** + +```vue + + + +``` + +**Range** + +```vue + + + +``` + +**Selector behavior options** + +- `selector-year-scroll-mode="boundary"`: clarity-first default; year wheel advances discretely. +- `selector-year-scroll-mode="fractional"`: continuous month-synced year-wheel drift. +- `:selector-year-home-jump="100"`: Home key jump size (years) in year wheel mode. +- `:selector-year-end-jump="100"`: End key jump size (years) in year wheel mode. +- `:selector-year-page-jump="10"`: PageUp/PageDown jump size (years) in year wheel mode. +- `:selector-year-page-shift-jump="100"`: Shift+PageUp/Shift+PageDown jump size (years). +- `:selector-focus-tint="false"`: keeps selector containers neutral while preserving functionality. +- `:close-on-range-selection="false"`: keeps the popover open after selecting the second range date. + Recommended with `selector-mode` when you want a fully native-like keep-open flow. + In `no-input` static mode this option is a no-op because there is no popover to close. + +```vue + +``` + +Selector wheel visuals are also themeable through CSS variables on `.vtd-datepicker` (month/year selected and hover colors, borders, typography, and wheel cell sizing). Calendar range preview colors/opacity are exposed via `--vtd-calendar-range-preview-bg` and `--vtd-calendar-range-preview-bg-dark`. See `docs/theming-options.md` for examples. + ## Theming options **Light Mode** diff --git a/docs/props.md b/docs/props.md index 5226740..ef2bc4a 100644 --- a/docs/props.md +++ b/docs/props.md @@ -292,9 +292,9 @@ const formatter = ref({ ``` -## Auto apply - -Change auto apply, by default `autoApply` is true. +## Auto apply + +Change auto apply, by default `autoApply` is true. - -``` + +``` + +## Close on range selection + +Control whether popover mode closes immediately after selecting the second date in range mode. +Default is `true`. + +When using `selector-mode`, set this to `false` if you want a fully native-like keep-open flow after second-date selection. + +In `no-input` static mode this prop is a no-op because there is no popover to close. + +```vue + +``` ## Start from @@ -548,7 +567,7 @@ const dateValue = ref([]) ``` -## Options +## Options Change default options @@ -584,6 +603,95 @@ const options = ref({ v-model="dateValue" :options="options" :auto-apply="false" - /> - -``` + /> + +``` + +## Selector Mode + +Enable native-like month/year wheel selectors. Default is `false`. + + + + + +```vue + +``` + +## Selector Year Scroll Mode + +Choose year-wheel sync behavior when selector mode is enabled. + +- `boundary` (default): year wheel moves discretely on year boundaries. +- `fractional`: year wheel drifts continuously with month progress. + + + + + +```vue + +``` + +## Selector Year Keyboard Jumps + +Control keyboard jump distance (in years) for the selector year wheel. + +- `selector-year-home-jump` and `selector-year-end-jump` default to `100`. +- `selector-year-page-jump` defaults to `10`. +- `selector-year-page-shift-jump` defaults to `100`. + +```vue + +``` + +## Selector Focus Tint + +Control whether the active selector column receives extra focus tint styling. +Default is `true`. + +```vue + +``` diff --git a/docs/theming-options.md b/docs/theming-options.md index 7cd2226..61197e9 100644 --- a/docs/theming-options.md +++ b/docs/theming-options.md @@ -37,3 +37,60 @@ Light mode color system using custom color `vtd-primary`. ## Dark mode Dark mode color system using color palette `vtd-secondary`. Vue Tailwind Datepicker work it well with Tailwind CSS `dark` mode configuration. + +## Selector Wheel Tokens + +When `selector-mode` is enabled, month/year wheel visuals can be tuned via CSS variables on `.vtd-datepicker`. + +```css +.vtd-datepicker { + --vtd-selector-wheel-cell-height: 40px; + + --vtd-selector-month-font-family: inherit; + --vtd-selector-month-font-size: 0.875rem; + --vtd-selector-month-font-weight: 500; + --vtd-selector-month-line-height: 1.5rem; + --vtd-selector-month-text: rgb(163 163 163 / 100%); + --vtd-selector-month-hover-bg: rgb(14 165 233 / 10%); + --vtd-selector-month-hover-border: rgb(56 189 248 / 45%); + --vtd-selector-month-hover-border-width: 0.85px; + --vtd-selector-month-hover-text: rgb(14 116 144 / 100%); + --vtd-selector-month-selected-bg: rgb(14 165 233 / 13%); + --vtd-selector-month-selected-border: rgb(14 165 233 / 62%); + --vtd-selector-month-selected-border-width: 0.85px; + --vtd-selector-month-selected-text: rgb(56 189 248 / 100%); + + --vtd-selector-year-font-family: inherit; + --vtd-selector-year-font-size: 0.875rem; + --vtd-selector-year-font-weight: 500; + --vtd-selector-year-text: rgb(163 163 163 / 100%); + --vtd-selector-year-hover-bg: rgb(14 165 233 / 10%); + --vtd-selector-year-hover-border: rgb(56 189 248 / 45%); + --vtd-selector-year-hover-border-width: 0.85px; + --vtd-selector-year-hover-text: rgb(14 116 144 / 100%); + --vtd-selector-year-selected-bg: rgb(14 165 233 / 13%); + --vtd-selector-year-selected-border: rgb(14 165 233 / 62%); + --vtd-selector-year-selected-border-width: 0.85px; + --vtd-selector-year-selected-text: rgb(56 189 248 / 100%); + + /* Advanced year-canvas tuning */ + --vtd-selector-year-canvas-border-width-scale: 0.5; + --vtd-selector-year-canvas-dpr: 4; + --vtd-selector-year-text-offset-y: 0px; +} +``` + +## Calendar Range Preview Tokens + +You can tune the in-range background (including opacity) without custom selectors: + +```css +.vtd-datepicker { + --vtd-calendar-range-preview-bg: rgb(224 242 254 / 60%); + --vtd-calendar-range-preview-bg-dark: rgb(55 65 81 / 50%); + + /* Range edge caps (start/end) */ + --vtd-calendar-range-preview-edge-bg: var(--vtd-calendar-day-selected-bg); + --vtd-calendar-range-preview-edge-bg-dark: var(--vtd-calendar-day-selected-bg); +} +``` diff --git a/package-lock.json b/package-lock.json index 1400d0b..5ad93d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,17 +27,20 @@ "@types/fs-extra": "^11.0.4", "@types/node": "^20.8.10", "@vitejs/plugin-vue": "^6.0.4", + "@vue/test-utils": "^2.4.6", "dayjs": "^1.11.19", "esbuild": "^0.27.3", "eslint": "^8.51.0", "fs-extra": "^11.3.3", "husky": "^8.0.3", + "jsdom": "^28.0.0", "postcss": "^8.4.49", "semantic-release": "^25.0.3", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-dts": "^4.5.4", + "vitest": "^4.0.18", "vue": "^3.5.28", "vue-tsc": "^3.2.4" }, @@ -55,6 +58,13 @@ "node": ">=0.10.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@actions/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz", @@ -187,6 +197,61 @@ "eslint": ">=7.4.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", + "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -562,6 +627,138 @@ "node": ">=v18" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.40.1", "dev": true, @@ -1043,6 +1240,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@headlessui/vue": { "version": "1.7.23", "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", @@ -1112,6 +1327,109 @@ "node": "20 || >=22" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1471,6 +1789,24 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -2738,6 +3074,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin-js": { "version": "0.0.4", "dev": true, @@ -3086,6 +3429,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3354,35 +3715,156 @@ "vue": "^3.2.25" } }, - "node_modules/@volar/language-core": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.28" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@volar/source-map": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.27", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", - "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.27", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, "node_modules/@volar/typescript/node_modules/@volar/language-core": { "version": "2.4.27", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", @@ -3581,6 +4063,17 @@ "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/@vueuse/core": { "version": "10.6.1", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.6.1.tgz", @@ -3664,6 +4157,16 @@ } } }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3835,6 +4338,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -3847,6 +4360,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "dev": true, @@ -3910,6 +4433,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -4113,6 +4646,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/comment-parser": { "version": "1.4.0", "dev": true, @@ -4338,6 +4881,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "dev": true, @@ -4349,6 +4906,32 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4368,6 +4951,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -4400,6 +4997,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -4519,6 +5123,58 @@ "readable-stream": "^2.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4746,6 +5402,13 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -5464,6 +6127,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -5664,6 +6337,36 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -6015,6 +6718,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "dev": true, @@ -6347,6 +7063,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -6400,6 +7123,22 @@ "node": "^18.17 || >=20.6.1" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -6427,6 +7166,86 @@ "dev": true, "license": "MIT" }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6455,6 +7274,92 @@ "node": ">=12.0.0" } }, + "node_modules/jsdom": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.20.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.0.2", "dev": true, @@ -7119,6 +8024,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -7240,6 +8152,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -7350,6 +8272,22 @@ "node": ">=18" } }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "dev": true, @@ -9504,6 +10442,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "dev": true, @@ -9667,6 +10616,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "dev": true, @@ -9785,6 +10741,30 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-type": { "version": "4.0.0", "dev": true, @@ -9994,7 +10974,9 @@ "dev": true }, "node_modules/punycode": { - "version": "2.3.0", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -10489,6 +11471,19 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semantic-release": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", @@ -10978,6 +11973,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -11158,6 +12160,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -11201,6 +12217,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "dev": true, @@ -11212,6 +12244,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -11300,6 +12346,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -11429,6 +12482,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -11484,6 +12544,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11497,6 +12587,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/traverse": { "version": "0.6.7", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.7.tgz", @@ -11874,6 +12990,97 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -11902,6 +13109,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-eslint-parser": { "version": "9.3.1", "dev": true, @@ -11988,6 +13202,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/web-worker": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", @@ -11995,6 +13232,41 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -12009,6 +13281,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -12033,6 +13322,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "dev": true, @@ -12046,6 +13354,13 @@ "node": ">=12" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 2b49a11..bb9dbd5 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,13 @@ "tailwind-datepicker", "tailwind-daterange-picker" ], - "exports": { - ".": { - "types": "./dist/types.d.ts", - "import": "./dist/vue-tailwind-datepicker.js", - "require": "./dist/vue-tailwind-datepicker.umd.cjs" - } - }, + "exports": { + ".": { + "types": "./dist/types.d.ts", + "import": "./dist/vue-tailwind-datepicker.js", + "require": "./dist/vue-tailwind-datepicker.umd.cjs" + } + }, "main": "./dist/vue-tailwind-datepicker.umd.cjs", "module": "./dist/vue-tailwind-datepicker.js", "types": "./dist/types.d.ts", @@ -43,14 +43,15 @@ "dev": "vite", "build": "vite build", "preview": "vite preview --port 4173", + "test:unit": "vitest run", "lint": "eslint", "lint:fix": "eslint --fix", - "docs:dev": "npm --prefix docs run dev", - "docs:build": "npm --prefix docs run build", - "docs:serve": "npm --prefix docs run serve", - "typecheck": "vue-tsc --noEmit", - "semantic-release": "semantic-release" - }, + "docs:dev": "npm --prefix docs run dev", + "docs:build": "npm --prefix docs run build", + "docs:serve": "npm --prefix docs run serve", + "typecheck": "vue-tsc --noEmit", + "semantic-release": "semantic-release" + }, "release": { "branches": [ "main" @@ -190,18 +191,21 @@ "@types/fs-extra": "^11.0.4", "@types/node": "^20.8.10", "@vitejs/plugin-vue": "^6.0.4", + "@vue/test-utils": "^2.4.6", "dayjs": "^1.11.19", "esbuild": "^0.27.3", "eslint": "^8.51.0", "fs-extra": "^11.3.3", - "husky": "^8.0.3", - "postcss": "^8.4.49", - "semantic-release": "^25.0.3", - "tailwindcss": "^4.1.18", - "typescript": "^5.9.3", - "vite": "^7.3.1", - "vite-plugin-dts": "^4.5.4", - "vue": "^3.5.28", - "vue-tsc": "^3.2.4" - } -} + "husky": "^8.0.3", + "jsdom": "^28.0.0", + "postcss": "^8.4.49", + "semantic-release": "^25.0.3", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vite-plugin-dts": "^4.5.4", + "vitest": "^4.0.18", + "vue": "^3.5.28", + "vue-tsc": "^3.2.4" + } +} diff --git a/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md b/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md new file mode 100644 index 0000000..3c956e9 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md @@ -0,0 +1,113 @@ +# Contract: Selector Mode Interface and Behavior + +## Scope + +Public and internal behavior contract for native-like month/year selector mode. + +## Public Component Contract + +### New Prop + +- Name: `selectorMode` +- Type: `boolean` +- Default: `false` +- Behavior: + - `false`: existing month/year panel behavior remains unchanged. + - `true`: header interaction toggles between calendar and selector views. + +### Additional Selector Props + +- Name: `selectorFocusTint` +- Type: `boolean` +- Default: `true` +- Behavior: + - `true`: active selector column can apply focus tint/background accent. + - `false`: selector columns keep neutral container styling while functional focus/selection remains unchanged. + +- Name: `selectorYearScrollMode` +- Type: `'boundary' | 'fractional'` +- Default: `'boundary'` +- Behavior: + - `boundary`: year wheel moves discretely when year changes. + - `fractional`: year wheel position drifts continuously with month progress (June-centered anchor) while selected year semantics remain discrete. + +### Selector Styling Surface + +- Month and year wheels expose CSS variable-based styling hooks on `.vtd-datepicker`. +- Required customizable groups: + - typography (font family, size, weight) + - default/hover/selected text colors + - default/hover/selected background and border colors + - wheel cell sizing (`--vtd-selector-wheel-cell-height`) +- Year wheel additionally exposes canvas tuning hooks: + - `--vtd-selector-year-canvas-border-width-scale` + - `--vtd-selector-year-canvas-dpr` + - `--vtd-selector-year-text-offset-y` + +## Toggle Behavior Contract + +1. Enter selector mode +- Trigger: click month or year label in header while calendar view is active. +- Result: + - view switches to selector mode. + - focus target is month/year column based on click target. + - selector-mode header is presented as a single combined month+year toggle button. + - side month navigation arrows remain available as quick actions with reduced visual prominence. + +2. Exit selector mode +- Trigger: header toggle action from selector view. +- Result: + - view switches back to calendar mode. + - selected month/year remains applied. + +3. Month/year selection +- Trigger: selecting month or year item in selector mode. +- Result: + - updates calendar month/year for current `SelectionContext`. + - does not force-close popover; user remains in selector until toggled back. + - clicked item is centered in its wheel/list, with smooth centering where applicable. + +4. Header quick month navigation (selector mode) +- Trigger: clicking left/right header arrows while selector view is active. +- Result: + - action routes through month-wheel stepping semantics. + - month wheel uses smooth motion and does not introduce snap-back/rubber-band behavior. + - selector view remains open. + +5. Wheel step controls +- Trigger: clicking up/down step controls on month or year wheel. +- Result: + - target wheel advances by one logical step. + - smooth motion is preserved on repeated clicks. + - focus remains keyboard-operable without unintended focus jumps. + +## Range-Context Semantics + +### Single-panel range (`use-range` + `as-single`) + +- Selector applies month/year updates to displayed context only. +- Existing range endpoint selection behavior remains unchanged. + +### Double-panel range + +- Selector applies month/year updates only to clicked panel context: + - left header -> `previousPanel` + - right header -> `nextPanel` +- Both selector panels may be open at the same time. +- Opening/toggling one panel must not mutate the opposite panel's selected month/year state. + +## Year Virtualization Contract + +- Year choices are conceptually unbounded. +- Implementation may render a moving window around `anchorYear`. +- Scrolling must not require pre-rendering the full integer range. + +## Accessibility Contract + +- Selector mode remains keyboard reachable. +- Focus transition on enter/exit is deterministic. +- Existing escape/cancel semantics for popover remain intact. + +## Visual Stability Contract + +- In selector mode, calendar and selector toggles should not introduce noticeable container width/height jitter for the same picker configuration. diff --git a/specs/001-feat-native-scroll-month-year-selector/data-model.md b/specs/001-feat-native-scroll-month-year-selector/data-model.md new file mode 100644 index 0000000..ae6c688 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/data-model.md @@ -0,0 +1,76 @@ +# Data Model: Native-Like Month/Year Scrolling Selector + +## Overview + +This feature introduces UI state entities only. No persistent storage changes. + +## Entities + +### PickerViewMode + +- Purpose: controls which major picker view is shown. +- Values: + - `calendar` + - `selector` + +### SelectorFocus + +- Purpose: controls which selector column is primary. +- Values: + - `month` + - `year` + +### SelectionContext + +- Purpose: identifies which calendar context receives month/year changes. +- Values: + - `single` (single-date mode) + - `singleRangeDisplayed` (single-panel range; displayed month/year) + - `previousPanel` (double-panel range, left) + - `nextPanel` (double-panel range, right) + +### SelectorState + +- Purpose: active-context selector synchronization state used for focus and year-window anchoring. +- Fields: + - `selectedMonth: number` (0-11) + - `selectedYear: number` (unbounded integer) + - `anchorYear: number` (used for virtual year windowing) +- Note: in dual-panel selector mode, each panel displays selected month/year from its own calendar context (`previous`/`next`) rather than from a single shared displayed tuple. + +### SelectorPanelState + +- Purpose: controls per-panel selector visibility in double-panel range mode. +- Fields: + - `previous: boolean` + - `next: boolean` + +### FeatureConfig + +- Purpose: opt-in behavior gate. +- Fields: + - `selectorMode: boolean` (default `false`) + +## State Transitions + +1. Header month/year click in calendar view: + - `PickerViewMode: calendar -> selector` + - `SelectorFocus` set from clicked header element. + - `SelectionContext` derived from active panel/mode. +2. Selector value change: + - updates `SelectorState` and calendar context month/year. +3. Toggle back to calendar: + - `PickerViewMode: selector -> calendar` + - calendar reflects selected month/year. +4. Double-panel selector toggles: + - opening/toggling one panel selector updates only that panel's visibility flag. + - both panel selectors may be open simultaneously. + - closing both selectors returns view to calendar mode. + +## Invariants + +- Legacy behavior is unchanged when `selectorMode = false`. +- Single-panel range never edits hidden/non-existent second panel. +- Double-panel range selector never mutates opposite panel in v1. +- Double-panel selectors may be open simultaneously, but each panel reads/writes only its own month/year context. +- Year selection supports unbounded values without exhausting DOM rendering. diff --git a/specs/001-feat-native-scroll-month-year-selector/plan.md b/specs/001-feat-native-scroll-month-year-selector/plan.md new file mode 100644 index 0000000..cac617f --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/plan.md @@ -0,0 +1,128 @@ +# Implementation Plan: Native-Like Month/Year Scrolling Selector + +**Branch**: `001-feat-native-scroll-month-year-selector` | **Date**: 2026-02-11 | **Spec**: `specs/001-feat-native-scroll-month-year-selector/spec.md` +**Input**: Feature specification from `specs/001-feat-native-scroll-month-year-selector/spec.md` + +## Summary + +Add an opt-in selector mode for `vue-tailwind-datepicker` that toggles between calendar view and native-like month/year scrolling selectors. The implementation must preserve existing single/range behavior, support single-panel range (`use-range` + `as-single`) and double-panel range (per-panel selector context), and keep the existing default behavior unchanged unless explicitly enabled. + +Current UX scope also includes: explicit selector-header toggle affordance, selector-mode month quick-nav header arrows, month/year wheel step controls, configurable selector focus tinting, selector container size stability across view toggles, click-to-center selector behavior, dual-panel simultaneous selector visibility with per-panel independence, and selectable year scroll sync variants (`boundary` and `fractional`). + +## Technical Context + +**Language/Version**: TypeScript 5.9, Vue 3.5 SFCs +**Primary Dependencies**: Vue 3, dayjs, headlessui/vue, tailwindcss +**Storage**: N/A (component-local state only) +**Testing**: `vue-tsc --noEmit`, `vitest`, `vite build`, manual demo verification in local app and consumer usage +**Target Platform**: Browser (desktop + mobile), Vue 3 library consumers +**Project Type**: Single frontend component library +**Performance Goals**: Keep interactions smooth and maintain 60fps scroll/animation during selector use +**Constraints**: Non-breaking API by default, preserve current range semantics and accessibility behavior +**Scale/Scope**: One core component (`src/VueTailwindDatePicker.vue`) plus selector/header subcomponents + +## Constitution Check + +Project constitution is defined at `.specify/memory/constitution.md`. This plan is checked against its MUST-level principles: + +- Backward compatibility by default: PASS (feature remains opt-in via `selectorMode`) +- Spec-driven traceability: PASS (spec/plan/tasks/contracts/quickstart are linked and requirement-tagged) +- Deterministic UX semantics: PASS (single-panel and double-panel selection-context rules are explicit) +- Verification before merge: PASS (`npm run typecheck`, `npm run build`, manual quickstart checks including SC-002 and edge-case matrix) +- Minimal-risk evolution: PASS (incremental changes in existing component architecture) + +Re-check after Phase 1 design: PASS (design and tasks remain aligned with constitution gates). + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-feat-native-scroll-month-year-selector/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── selector-mode-contract.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/ +├── VueTailwindDatePicker.vue +├── components/ +│ ├── Header.vue +│ ├── Month.vue +│ ├── Year.vue +│ ├── SelectorWheelStepButton.vue +│ └── Calendar.vue +├── types.ts +└── App.vue +``` + +**Structure Decision**: Use existing component structure. Add selector mode state and rendering control in `src/VueTailwindDatePicker.vue`; evolve or replace `Month.vue` and `Year.vue` into scroll-selector variants; minimally extend `Header.vue` for toggle behavior. + +## Phase 0: Research and Tradeoffs + +See `research.md`. + +## Phase 1: Design + +1. Introduce opt-in prop for selector mode and keep legacy behavior as default. +2. Add explicit view mode state (`calendar` vs `selector`) and selector focus (`month` or `year`). +3. Implement scroll selector UI and interactions for month/year with virtually unbounded year generation. +4. Encode range selection context rules: + - Single-panel range: operate on displayed month/year only. + - Double-panel range: operate on clicked panel only. +5. Preserve model update behavior and auto-apply/manual apply semantics. +6. Provide stable visual container geometry and clear selector-toggle affordance. +7. Support both clarity-first and continuous year-wheel sync variants via prop. +8. Keep header quick month navigation available in selector mode and route it to smooth wheel-step behavior. +9. In double-panel range, support both selectors open simultaneously while preserving per-panel state independence. + +Detailed entities and transitions are in `data-model.md` and `contracts/selector-mode-contract.md`. + +## Phase 2: Implementation Outline + +1. Add prop and default handling in `src/VueTailwindDatePicker.vue`. +2. Add view mode and active context state. +3. Update header interactions to toggle selector mode. +4. Implement month/year scroll selector rendering and selection handlers. +5. Integrate range context behavior rules. +6. Validate accessibility keyboard/focus behavior. +7. Update demo usage (`src/App.vue`) if needed for manual verification only. +8. Run `npm run typecheck` and `npm run build`. + +## Test and Verification Strategy + +- Type safety: `npm run typecheck` +- Build integrity: `npm run build` +- Manual checks: + - Single date + selector mode on/off + - Single-panel range (`use-range` + `as-single`) + - Double-panel range (per-panel selector) + - Auto-apply and manual apply + - Keyboard interaction and focus transitions + - SC-002 check: from selector open state, target month/year can be reached in <=2 direct interactions (selection actions), excluding scroll travel distance + +### Edge Case Coverage Plan + +- Far-year navigation: verify virtual year window behavior at large positive/negative offsets (maps to FR-011). +- Small screens: verify selector mode layout and toggle behavior on mobile breakpoints (maps to FR-004, FR-010). +- Visual stability: verify no width/height jitter when toggling calendar <-> selector for same mode/config (maps to FR-014). +- Disabled dates/month constraints: verify month/year changes do not break disabled-date semantics (maps to FR-006, FR-007). +- Invalid/empty model values: verify selector entry and fallback anchoring behavior remain stable (maps to FR-006). +- Double-panel interaction boundaries: verify clicked-panel-only rule is preserved (maps to FR-006). + +## Linear and Traceability + +- Linear issue reference: None provided. +- Spec reference: `specs/001-feat-native-scroll-month-year-selector/spec.md` +- Contract reference: `specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md` + +## Complexity Tracking + +No constitution violations identified that require exceptions. diff --git a/specs/001-feat-native-scroll-month-year-selector/quickstart.md b/specs/001-feat-native-scroll-month-year-selector/quickstart.md new file mode 100644 index 0000000..6b56375 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/quickstart.md @@ -0,0 +1,106 @@ +# Quickstart: Native-Like Month/Year Scrolling Selector + +## 1. Install and baseline checks + +```bash +npm install +npm run typecheck +npm run build +``` + +## 2. Enable selector mode in a local usage example + +In a consumer or local demo usage, enable the new prop: + +```vue + +``` + +Optional experimental variant: + +```vue + +``` + +## 3. Verify core flows + +1. Calendar -> selector toggle via header month/year click. +2. Selector -> calendar toggle preserves selected month/year. +3. Single-date mode continues working with selector mode off and on. +4. Single-panel range (`use-range` + `as-single`) updates displayed month/year context only. +5. Double-panel range updates clicked panel context only. +6. `selector-year-scroll-mode="boundary"` keeps year movement discrete and clarity-first. +7. `selector-year-scroll-mode="fractional"` keeps year wheel in continuous month-synced drift. +8. Selector-mode header is a single combined month+year toggle with explicit button affordance. +9. Selector-mode header side arrows remain available for quick month stepping and use smooth wheel motion semantics. +10. Month/year wheels expose explicit up/down step controls and repeated clicks remain smooth. +11. Clicking month/year selector items recenters the wheel/list to the clicked item. +12. In double-panel range mode, opening the second selector does not reset or mutate the first selector panel state. +13. Selector-mode container size remains visually stable across calendar <-> selector toggles. + +## 4. Verify non-regression behaviors + +1. `selector-mode` omitted (`false`) keeps legacy month/year panels. +2. `autoApply` and manual apply still behave as before. +3. Keyboard navigation/focus remains usable. +4. Double-panel selector mode keeps per-panel month/year state independent. + +## 5. Verify SC-002 interaction-count criterion + +1. Open selector mode from calendar view. +2. Validate that choosing the target month/year requires no more than 2 direct interactions (selection actions), excluding scroll travel distance. +3. Repeat for single-date, single-panel range (`use-range` + `as-single`), and double-panel range contexts. + +## 6. Final validation + +```bash +npm run typecheck +npm run build +``` + +## 7. QA evidence tracking (SC-001, SC-004) + +Record each manual run to make success criteria auditable. + +| Date | Tester | Scenario | Console Errors (SC-001) | Smooth Interaction Pass (SC-004) | Notes | +|------|--------|----------|---------------------------|-----------------------------------|-------| +| YYYY-MM-DD | name | single / single-range / double-range | pass/fail | pass/fail | details | +| 2026-02-11 | Codex | selector-mode demo (`src/App.vue`) | fail (non-picker favicon 404) | pass (manual visual check) | Month selector and year selector listboxes rendered; toggle flow observed | +| 2026-02-11 | Codex | SC-002 sample checks | pass | pass | Month change: 1 direct selection (`May`); Year change: 1 direct selection (`2030`) after selector open | +| 2026-02-11 | Codex | T021 double-panel range clicked-context sync (`:selector-mode=true`) | fail (non-picker favicon 404 only) | pass | Left panel month/year stayed on its own context while right-panel header interaction opened selector on right context; no cross-panel focus bleed observed | +| 2026-02-11 | Codex | T021 single-panel range sync (`use-range` + `as-single`) | fail (non-picker favicon 404 only) | pass | Selector transitions stayed scoped to single displayed context; no second-panel state was introduced | +| 2026-02-11 | Codex | T029 disabled-date constraints (`disableDate` weekend function) | fail (non-picker favicon 404 only) | pass | Weekend cells remained disabled after selector-mode month change and return to calendar | +| 2026-02-11 | Codex | T029 invalid/empty model seeds (`''`, `not-a-date ~ not-a-date`) | fail (non-picker favicon 404 only) | pass | Popovers opened without runtime exceptions; both scenarios anchored to stable calendar months (Feb/Mar 2026) | +| 2026-02-12 | Codex | T029 far-year offsets + small-screen 375x812 + selector sync modes | fail (non-picker favicon 404 only) | pass | Year list remained virtually unbounded under boundary/fractional modes; mobile viewport stayed operable; prior year/header drift issue was resolved | +| 2026-02-15 | Codex | Selector header quick-nav + wheel step controls + dual-panel simultaneous selectors | fail (non-picker favicon 404 only) | pass | Header arrows remained available in selector mode with smooth month stepping; month/year wheel step buttons were functional; opening right selector kept left selector month/year unchanged | + +Checklist per run: +1. Open browser devtools console and confirm no errors during calendar -> selector -> calendar transitions. +2. Execute at least one scenario per mode (single, `use-range` + `as-single`, double-panel range). +3. Mark smoothness pass/fail using the SC-004 checklist in spec/plan. +4. Attach run notes to PR description. + +## 8. Implementation TODO checkpoints (T002) + +- [x] TODO-SC001-1: Run calendar -> selector -> calendar flow in browser and confirm no console errors. +- [x] TODO-SC001-2: Repeat console-error check for single, single-panel range, and double-panel range scenarios. +- [x] TODO-SC004-1: Validate smooth selector scrolling and selection response for month and year columns. +- [x] TODO-SC004-2: Record pass/fail outcomes in the QA evidence table with notes per scenario. + +Notes: +- Observed console error is a dev demo static asset miss (`/favicon.ico` 404), not a picker runtime exception. +- T021 and T029 manual checks were executed with Playwright against `http://127.0.0.1:4173` on 2026-02-11. +- Added `src/App.vue` manual-test scenarios for `use-range + as-single`, `disableDate`, and empty/invalid model seeds to complete matrix coverage. diff --git a/specs/001-feat-native-scroll-month-year-selector/references/native-picker-reference.png b/specs/001-feat-native-scroll-month-year-selector/references/native-picker-reference.png new file mode 100644 index 0000000..7205079 Binary files /dev/null and b/specs/001-feat-native-scroll-month-year-selector/references/native-picker-reference.png differ diff --git a/specs/001-feat-native-scroll-month-year-selector/references/native-selector-reference.png b/specs/001-feat-native-scroll-month-year-selector/references/native-selector-reference.png new file mode 100644 index 0000000..b1e0249 Binary files /dev/null and b/specs/001-feat-native-scroll-month-year-selector/references/native-selector-reference.png differ diff --git a/specs/001-feat-native-scroll-month-year-selector/research.md b/specs/001-feat-native-scroll-month-year-selector/research.md new file mode 100644 index 0000000..7d4375a --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/research.md @@ -0,0 +1,75 @@ +# Research: Native-Like Month/Year Scrolling Selector + +## Decision 1: Rollout Strategy + +- Decision: Make selector mode opt-in via a new prop. +- Rationale: Prevent regressions for existing consumers and keep upgrade non-breaking. +- Alternatives considered: + - Default-on behavior: rejected due to migration risk. + +## Decision 2: View Flow Behavior + +- Decision: Mimic native toggle behavior between calendar and selector views. +- Rationale: Aligns with requested UX and avoids auto-close surprises. +- Alternatives considered: + - Auto-close after month/year selection: rejected as non-native for this target flow. + +## Decision 3: Range Context Rules + +- Decision: + - Single-panel range (`use-range` + `as-single`): selector edits displayed month/year context only. + - Double-panel range: selector applies only to clicked panel. +- Rationale: Clear and predictable behavior with low state complexity. +- Alternatives considered: + - Cross-panel switching inside selector: deferred due to higher complexity and bug risk. + +## Decision 4: Year Selector Bounds + +- Decision: Virtually unbounded year scrolling. +- Rationale: Meets requirement FR-011 and avoids arbitrary hard limits. +- Implementation note: Use lazily generated windows around current anchor year to avoid rendering huge DOM lists. + +## Decision 5: Technical Shape + +- Decision: Keep implementation inside existing component structure and state model. +- Rationale: Minimizes code churn and aligns with current architecture. +- Alternatives considered: + - New fully separate picker component: rejected due to duplication and maintenance overhead. + +## Decision 6: Selector Header Affordance + +- Decision: In selector mode, use a single combined month+year toggle button with explicit affordance styling; keep side month arrows as subdued quick-navigation actions. +- Rationale: Retains native-like toggle clarity while restoring low-friction month stepping for keyboard/mouse users without forcing wheel scrolling. +- Alternatives considered: + - Keep split month/year header buttons in selector mode: rejected due to weaker toggle clarity. + - Hide side month arrows completely: rejected after UX review due to discoverability loss for simple month stepping. + +## Decision 7: Selector Styling Controls + +- Decision: Expose `selectorFocusTint` to allow custom UIs to disable active column tinting while retaining behavior. +- Rationale: Integrators need visual-system flexibility without forking selector logic. +- Alternatives considered: + - Hard-coded focus tint only: rejected due to integration friction. + +## Decision 8: Year Wheel Sync Variants + +- Decision: Expose `selectorYearScrollMode` with `boundary` default and `fractional` experimental mode. +- Rationale: `boundary` is clearer for most users; `fractional` offers a native-like continuous feel for advanced adopters. +- Alternatives considered: + - Single fixed behavior only: rejected due to unresolved UX preference tradeoff. + +## Decision 9: Dual-Panel Selector Visibility + +- Decision: Allow both selector panels to remain open simultaneously in double-panel range mode. +- Rationale: Improves side-by-side comparison and avoids collapsing the first panel when opening the second. +- Alternatives considered: + - Single-active selector panel only: rejected after UX review because it hides context and causes panel-state churn. + +## Risks and Mitigations + +- Risk: Fast-flick rendering gaps (especially year wheel) under high momentum. + - Mitigation: maintain stable list structures, reduce expensive per-scroll visual effects during active scroll, and tune virtual window/reanchor strategy. +- Risk: Range synchronization regressions. + - Mitigation: centralize selector apply handlers and reuse existing month/year update logic where possible. +- Risk: Keyboard/focus regressions. + - Mitigation: explicit focus targets on mode entry/exit and manual keyboard regression checks. diff --git a/specs/001-feat-native-scroll-month-year-selector/spec.md b/specs/001-feat-native-scroll-month-year-selector/spec.md new file mode 100644 index 0000000..7ac0a64 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/spec.md @@ -0,0 +1,140 @@ +# Feature Specification: Native-Like Month/Year Scrolling Selector + +**Feature Branch**: `001-feat-native-scroll-month-year-selector` +**Created**: 2026-02-11 +**Status**: Approved +**Input**: User description: "Introduce native-similar scrolling for date selection: start in calendar view, click header, then use month/year scrolling selectors instead of split paginated month and year panels." + +## Reference Assets + +- Picker reference: `specs/001-feat-native-scroll-month-year-selector/references/native-picker-reference.png` +- Selector reference: `specs/001-feat-native-scroll-month-year-selector/references/native-selector-reference.png` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Switch from Calendar to Scroll Selectors (Priority: P1) + +As a date picker user, I can click the header in calendar view and toggle into a month/year selector view that behaves like a native scrolling picker. + +**Why this priority**: This is the core workflow change the feature is intended to deliver. + +**Independent Test**: Open date picker in calendar mode, click header month/year area, and verify selector view appears with scrollable month and year columns. + +**Acceptance Scenarios**: + +1. **Given** the picker is open in calendar view, **When** the user clicks the month label in the header, **Then** the picker transitions to the selector view focused on month selection while showing year selection context. +2. **Given** the picker is open in calendar view, **When** the user clicks the year label in the header, **Then** the picker transitions to the selector view focused on year selection while showing month selection context. +3. **Given** selector view is open, **When** the user toggles back to calendar view from the header control, **Then** the picker returns to calendar view with the selected month/year applied. +4. **Given** selector mode is enabled, **When** the header renders, **Then** month and year are shown as a single explicit toggle button and side month quick-navigation arrows remain available with reduced visual prominence. +5. **Given** double-panel range selector view is active, **When** the user opens selector view on the opposite panel, **Then** both selector panels can remain open and each panel keeps its own month/year selection state. + +--- + +### User Story 2 - Scroll and Select Month/Year Efficiently (Priority: P1) + +As a date picker user, I can scroll month and year lists with native-like behavior and select values quickly without stepping through paginated grids. + +**Why this priority**: Replacing split paginated grids with scroll selection is the main usability improvement. + +**Independent Test**: In selector view, scroll month and year columns, select target values, and verify selection updates the visible calendar period. + +**Acceptance Scenarios**: + +1. **Given** selector view is open, **When** the user scrolls month or year lists, **Then** scrolling remains smooth and values remain readable and selectable according to the SC-004 verification checklist. +2. **Given** selector view is open, **When** the user selects a month and/or year, **Then** the selected values become active and update the calendar period. +3. **Given** selector view is open, **When** the user confirms selection via direct selection behavior, **Then** the picker returns to calendar mode with correct month/year. +4. **Given** selector view is open, **When** the user clicks wheel up/down step controls for month or year, **Then** the corresponding wheel advances smoothly and keeps focus behavior consistent with keyboard navigation. + +--- + +### User Story 3 - Preserve Existing Date-Picker Behavior (Priority: P2) + +As an integrator using existing picker modes (single/range, autoApply/manual), I can adopt the new selector flow without regressions in selection semantics. + +**Why this priority**: The UX update must not break existing model value behavior or range logic. + +**Independent Test**: Run single-date and range-date flows in autoApply and manual apply modes before and after selector interaction. + +**Acceptance Scenarios**: + +1. **Given** range mode is enabled, **When** month/year is changed through selector view, **Then** range panel synchronization rules remain intact. +2. **Given** manual apply mode is enabled, **When** month/year is changed via selector view, **Then** Apply/Cancel behavior remains consistent with current component semantics. +3. **Given** accessibility users navigate via keyboard, **When** selector view is active, **Then** focus order, arrow/scroll interaction, and escape/back behavior are operable. + +--- + +### User Story 4 - Customizable and Backward Compatible API Surface (Priority: P3) + +As a library consumer, I can enable or configure native-like selector behavior without mandatory breaking API changes. + +**Why this priority**: Consumers need controlled rollout and migration safety. + +**Independent Test**: Enable and disable selector mode via component options/props and verify legacy behavior remains available where configured. + +**Acceptance Scenarios**: + +1. **Given** the feature flag/config is disabled, **When** user interacts with header, **Then** existing month/year panel behavior is preserved. +2. **Given** the feature flag/config is enabled, **When** user interacts with header, **Then** selector view behavior is used. + +### Edge Cases + +- What happens when year lists are scrolled far outside the default visible range (very old or far-future years)? +- How does selector mode behave on small screens where picker is already full-screen or modal-like? +- How are disabled dates/month constraints represented when month/year can be jumped via selectors? +- What happens if the current model value is invalid/empty when opening selector view? +- How does selector view interact with range mode when the second panel is visible and one panel is currently active? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a calendar default view that can transition to a selector view via header interaction. +- **FR-002**: System MUST provide month and year scroll selectors in selector view, replacing split paginated month/year grids for this mode. +- **FR-003**: System MUST allow users to select month and year from selector view and apply the selection to the active calendar context. +- **FR-004**: System MUST support smooth pointer/touch scrolling interaction in selector view on desktop and mobile. +- **FR-005**: System MUST return users from selector view back to calendar view after selection or explicit back action. +- **FR-006**: System MUST preserve single and range selection semantics after month/year changes initiated from selector view. +- **FR-007**: System MUST preserve auto-apply and manual apply flows consistent with existing behavior. +- **FR-008**: System MUST expose a non-breaking opt-in prop to enable selector mode while preserving legacy behavior by default. +- **FR-009**: System MUST maintain keyboard accessibility and focus management in selector mode. +- **FR-010**: System MUST maintain visual consistency with current component styling system. +- **FR-011**: System MUST support virtually unbounded year scrolling in selector mode. +- **FR-012**: System MUST use a native-like toggle flow between calendar view and selector view rather than auto-closing on month/year selection. +- **FR-013**: System MUST expose styling control to disable selector focus tinting between month/year columns while preserving functional focus behavior. +- **FR-014**: System MUST keep selector-mode container dimensions visually stable (no width/height jitter) during calendar <-> selector toggles in the same mode/context. +- **FR-015**: System MUST center clicked selector items in their wheel/list view using smooth motion where applicable. +- **FR-016**: System MUST support wheel-like continuous month scrolling behavior that mimics native calendar selectors while keeping selected month/year semantics correct. +- **FR-017**: System MUST expose a selector year-scroll mode option with `boundary` (clarity-first default) and `fractional` (continuous drift) variants. +- **FR-018**: System MUST present selector-mode header control styling that clearly communicates clickability/toggle intent. +- **FR-019**: System MUST keep header month quick-navigation arrows available in selector mode and route their actions through smooth month wheel stepping semantics. +- **FR-020**: System MUST support selector wheel up/down step controls for both month and year wheels with smooth repeated motion behavior. +- **FR-021**: System MUST allow both selector panels to remain open in double-panel range mode while preserving independent month/year selection state per panel. + +### Key Entities *(include if feature involves data)* + +- **Picker View Mode**: Represents active UI state (`calendar`, `selector`) and optional focus (`month`, `year`). +- **Selector State**: Represents currently highlighted/selected month and year values used to update calendar period. +- **Selection Context**: Represents which panel is active in range mode (`previous` vs `next`) when applying month/year changes. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of header-to-selector transitions complete without console errors in supported browsers. +- **SC-002**: Users can change to a target month/year in at most 2 interactions after opening selector view (excluding scrolling distance). +- **SC-003**: Existing single/range automated tests for model update semantics continue passing after feature integration. +- **SC-004**: At least 90% of manual QA runs confirm selector interaction is smooth and visually stable on desktop and mobile breakpoints. + +## Clarifications + +- [RESOLVED 2026-02-11] Native-like selector mode is opt-in via prop. +- [RESOLVED 2026-02-11] In double-panel range mode, selector behavior is per-panel (clicked-panel only). +- [RESOLVED 2026-02-11] In single-panel range mode (`use-range` + `as-single`), selector edits the currently displayed month/year context only. +- [RESOLVED 2026-02-11] Selector flow mimics native picker toggle behavior between calendar and selector views. +- [RESOLVED 2026-02-11] Year selector should be virtually unbounded. +- [RESOLVED 2026-02-12] Selector-mode header uses a combined month+year toggle button. +- [RESOLVED 2026-02-12] Selector focus tinting is configurable and can be disabled for custom visual systems. +- [RESOLVED 2026-02-12] Selector mode supports two year wheel sync variants: `boundary` (default) and `fractional` (experimental/continuous). +- [RESOLVED 2026-02-12] Selector wheel month/year visual styling is exposed via CSS variables (including typography, hover/selected states, and year-canvas tuning hooks). +- [RESOLVED 2026-02-15] Selector mode re-enables subdued header month quick-navigation arrows and adds explicit wheel step controls for month and year. +- [RESOLVED 2026-02-15] Double-panel range selector mode supports both panels open simultaneously while preserving per-panel month/year independence. diff --git a/specs/001-feat-native-scroll-month-year-selector/tasks.md b/specs/001-feat-native-scroll-month-year-selector/tasks.md new file mode 100644 index 0000000..3eb19d2 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/tasks.md @@ -0,0 +1,165 @@ +# Tasks: Native-Like Month/Year Scrolling Selector + +**Input**: Design documents from `specs/001-feat-native-scroll-month-year-selector/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/selector-mode-contract.md`, `quickstart.md` + +## Format: `[ID] [P?] [Story] Description` + +- `[P]`: Can run in parallel (different files, no direct dependency) +- `[Story]`: `US1`, `US2`, `US3`, `US4`, or `Shared` +- Each task includes requirement mapping tags (FR/SC) + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Introduce the feature scaffolding without changing default behavior. + +- [x] T001 [Shared] Update `specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md` to document `selectorMode`, toggle behavior, and range-context semantics [FR-008] +- [x] T002 [Shared] Add implementation TODO checkpoints in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` for manual verification runs [SC-001, SC-004] + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core state and toggling primitives that all stories depend on. + +**⚠️ CRITICAL**: No user-story completion before this phase is done. + +- [x] T003 [Shared] Add `selectorMode?: boolean` prop (default `false`) in `src/VueTailwindDatePicker.vue` [FR-008] +- [x] T004 [Shared] Add internal state entities in `src/VueTailwindDatePicker.vue`: `pickerViewMode`, `selectorFocus`, `selectionContext`, `selectorState` [FR-001, FR-003] +- [x] T005 [Shared] Implement view toggle handlers in `src/VueTailwindDatePicker.vue` for calendar <-> selector transitions [FR-001, FR-005, FR-012] +- [x] T006 [Shared] Extend `src/components/Header.vue` emit/props to trigger selector mode entry and focus (`month`/`year`) [FR-001, FR-012] + +**Checkpoint**: Foundational selector state and mode transitions are working behind opt-in prop. + +--- + +## Phase 3: User Story 1 - Switch from Calendar to Scroll Selectors (Priority: P1) 🎯 MVP + +**Goal**: Header click toggles calendar/selector mode with native-like flow. + +**Independent Test**: With `:selector-mode="true"`, clicking month/year in header enters selector view; toggling back returns to calendar with applied month/year. + +### Implementation for User Story 1 + +- [x] T007 [US1] Add selector-view rendering branch in `src/VueTailwindDatePicker.vue` while preserving current calendar rendering [FR-001, FR-012] +- [x] T008 [P] [US1] Add month selector UI component implementation in `src/components/Month.vue` (or new selector component wired here) with scrollable interaction [FR-002, FR-004] +- [x] T009 [P] [US1] Add year selector UI component implementation in `src/components/Year.vue` (or new selector component wired here) with scrollable interaction [FR-002, FR-004] +- [x] T010 [US1] Wire Header interaction to selector focus (month/year) and selector-to-calendar toggle in `src/VueTailwindDatePicker.vue` [FR-001, FR-005, FR-012] +- [x] T011 [US1] Ensure default mode (`selectorMode=false`) path uses legacy month/year panels unchanged in `src/VueTailwindDatePicker.vue` [FR-008] + +**Checkpoint**: Selector mode flow is functional and legacy flow still works when disabled. + +--- + +## Phase 4: User Story 2 - Scroll and Select Month/Year Efficiently (Priority: P1) + +**Goal**: Native-like month/year scrolling and selection updates active calendar context. + +**Independent Test**: In selector mode, scrolling/selecting month/year updates displayed calendar period smoothly, and target month/year can be reached in <=2 direct interactions after selector view is opened (excluding scroll distance). + +### Implementation for User Story 2 + +- [x] T012 [US2] Implement month selection handler in `src/VueTailwindDatePicker.vue` that updates active context without popover close [FR-003, FR-012] +- [x] T013 [US2] Implement year selection handler in `src/VueTailwindDatePicker.vue` that updates active context without popover close [FR-003, FR-012] +- [x] T014 [US2] Add virtually unbounded year window generation/anchoring logic in `src/VueTailwindDatePicker.vue` and/or `src/composables/date.ts` [FR-011] +- [x] T015 [US2] Add scroll-snap and item-state styling for selector lists in `src/index.css` (and any related component classes) [FR-004, FR-010] +- [x] T016 [US2] Validate selector update latency and avoid heavy per-scroll recomputation in `src/VueTailwindDatePicker.vue` [SC-004] + +**Checkpoint**: Selector interactions are smooth and correctly drive month/year changes. + +--- + +## Phase 5: User Story 3 - Preserve Existing Date-Picker Behavior (Priority: P2) + +**Goal**: Preserve single/range semantics and apply behavior with new selector mode. + +**Independent Test**: Single, single-panel range, and double-panel range all retain expected model semantics. + +### Implementation for User Story 3 + +- [x] T017 [US3] Implement `selectionContext` derivation for single mode and single-panel range (`use-range` + `as-single`) in `src/VueTailwindDatePicker.vue` [FR-006] +- [x] T018 [US3] Implement per-panel context routing for double-panel range (clicked header determines panel) in `src/VueTailwindDatePicker.vue` [FR-006] +- [x] T019 [US3] Verify autoApply/manual apply behavior remains unchanged after selector updates in `src/VueTailwindDatePicker.vue` [FR-007] +- [x] T020 [US3] Ensure keyboard entry/exit focus management for selector mode in `src/VueTailwindDatePicker.vue` and `src/components/Header.vue` [FR-009] +- [x] T021 [US3] Manual non-regression run for range panel sync edge cases using `src/App.vue` demo scenarios [SC-003, SC-004] + +**Checkpoint**: Existing model semantics and accessibility behavior are preserved. + +--- + +## Phase 6: User Story 4 - Customizable and Backward Compatible API Surface (Priority: P3) + +**Goal**: Provide clear opt-in API and integration guidance. + +**Independent Test**: Consumers can enable/disable selector mode with no breakage. + +### Implementation for User Story 4 + +- [x] T022 [US4] Document new `selectorMode` prop in `README.md` with examples for single and range usage [FR-008] +- [x] T023 [US4] Add opt-in selector mode example in `src/App.vue` for manual verification [FR-008, SC-001] +- [x] T024 [US4] Ensure emitted behavior and exposed API remain unchanged unless `selectorMode` is enabled in `src/VueTailwindDatePicker.vue` [FR-008] +- [x] T030 [US4] Add configurable selector year scroll mode (`boundary` default, `fractional` experimental) in `src/VueTailwindDatePicker.vue` and `src/components/Year.vue` [FR-008, FR-011] +- [x] T031 [US4] Add both selector year-scroll variants in `src/App.vue` for side-by-side UX validation [SC-004] +- [x] T032 [US1] Update selector-mode header to a single combined month+year toggle and tuned selector-mode side-arrow behavior in `src/components/Header.vue` [FR-001, FR-018] +- [x] T033 [US2] Ensure selector item click recenters wheel/list with smooth motion in `src/components/Month.vue` and `src/components/Year.vue` [FR-015, FR-016] +- [x] T034 [US2] Stabilize selector-mode container dimensions across view toggles in `src/VueTailwindDatePicker.vue` [FR-014] +- [x] T035 [US4] Add configurable selector focus tint behavior in `src/VueTailwindDatePicker.vue` and demo usage in `src/App.vue` [FR-013] +- [x] T036 [US4] Document `selectorYearScrollMode` and `selectorFocusTint` usage in `README.md` and `docs/props.md` [FR-013, FR-017] +- [x] T037 [US4] Document selector wheel styling tokens (including year-canvas tuning variables) in `docs/theming-options.md` [FR-010] + +**Checkpoint**: API is backward compatible and documented. + +--- + +## Phase 7: Polish & Verification + +**Purpose**: Final checks across all stories. + +- [x] T025 [Shared] Run `npm run typecheck` and address failures [SC-003] +- [x] T026 [Shared] Run `npm run build` and address failures [SC-001, SC-003] +- [x] T027 [Shared] Execute quickstart verification checklist in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` and record outcomes in PR notes [SC-001, SC-004] +- [x] T028 [Shared] Verify and document SC-002 interaction-count criterion (<=2 direct interactions after selector open, excluding scroll distance) using `src/App.vue` scenarios [SC-002] +- [x] T029 [Shared] Execute and document edge-case matrix checks (far-year offsets, small screens, disabled-date constraints, invalid/empty model, double-panel clicked-context) in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` [FR-004, FR-006, FR-007, FR-010, FR-011, SC-004] +- [x] T038 [Shared] Update quickstart QA evidence after selector sync fixes (`boundary` + `fractional`) in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` [SC-004] +- [x] T039 [Shared] Re-introduce selector-mode header month quick-nav arrows with smooth wheel-step routing in `src/components/Header.vue` and `src/VueTailwindDatePicker.vue` [FR-019] +- [x] T040 [Shared] Add reusable selector wheel step controls for month and year wheels in `src/components/SelectorWheelStepButton.vue`, `src/components/Month.vue`, and `src/components/Year.vue` [FR-020] +- [x] T041 [Shared] Support simultaneous dual-panel selector visibility with independent per-panel selection bindings in `src/VueTailwindDatePicker.vue` [FR-021, FR-006] +- [x] T042 [Shared] Add regression unit coverage for selector quick-nav arrows, wheel step controls, repeated smooth stepping, and dual-panel independence in `tests/unit/*.spec.ts` [SC-003, SC-004] + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 -> Phase 2 -> Phase 3/4/5/6 -> Phase 7 +- User stories begin only after Phase 2 checkpoint. + +### User Story Dependencies + +- **US1 (P1)**: Depends on foundational tasks T003-T006. +- **US2 (P1)**: Depends on US1 rendering/toggle baseline (T007-T011). +- **US3 (P2)**: Depends on US1 + US2 handlers. +- **US4 (P3)**: Depends on stable behavior from US1-US3. + +### Parallel Opportunities + +- T008 and T009 can run in parallel. +- T022 and T023 can run in parallel after US3 stabilizes. +- Final verification T025 and T026 can run in sequence; T027-T029 after both pass. + +## Implementation Strategy + +### MVP First + +1. Complete Phases 1-2. +2. Complete US1. +3. Complete US2. +4. Validate with T025-T026 before moving to higher-priority polish. + +### Incremental Delivery + +1. Deliver opt-in toggle + selector entry/exit (US1). +2. Deliver full scroll selection and unbounded years (US2). +3. Harden range/accessibility semantics (US3). +4. Finalize docs and demo integration (US4). diff --git a/specs/constitution.md b/specs/constitution.md new file mode 100644 index 0000000..1427fa0 --- /dev/null +++ b/specs/constitution.md @@ -0,0 +1,69 @@ + + +# vue-tailwind-datepicker Constitution + +## Core Principles + +### I. Backward Compatibility by Default +All new behavior MUST be opt-in unless explicitly approved as breaking. Existing consumers MUST +retain current behavior when new feature flags/props are not enabled. + +### II. Spec-Driven Changes +Feature work MUST trace back to `spec.md`, `plan.md`, and `tasks.md` under `specs/`. +Implementation tasks MUST map to explicit requirements and success criteria. + +### III. Deterministic UX Semantics +UI behavior MUST be explicit, testable, and mode-aware. For range flows, selection context +rules MUST be documented and preserved without hidden side effects. + +### IV. Verification Before Merge +Changes MUST pass typecheck and build, and MUST include manual verification for UX-critical +flows and edge cases defined in the feature quickstart. + +### V. Minimal-Risk Evolution +Prefer incremental, low-churn changes in existing architecture. Introduce new component or API +surface only when simpler extensions cannot satisfy requirements. + +## Sources of Truth + +- **Requirements, scope, and status**: `specs/001-feat-native-scroll-month-year-selector/spec.md` and companion artifacts in `specs/001-feat-native-scroll-month-year-selector/` +- **Engineering standards and decision rules**: TODO(ENGINEERING_GUIDE): define repository-local AGENTS.md (or equivalent canonical standards file) path + +## Quality Gates + +All feature PRs MUST satisfy: +- `npm run typecheck` +- `npm run build` +- Manual checks documented in the relevant `quickstart.md` +- Traceability from tasks to requirements/success criteria + +## Workflow & Review + +Use Spec-Kit phases in order: specify -> plan -> tasks -> analyze -> implement. +Before implementation, cross-artifact analysis MUST report no unaddressed critical issues. +Warnings MUST be either resolved or explicitly accepted with rationale. + +## Governance + +This constitution governs feature-process rules for this repository and supersedes conflicting +local practices for spec-driven work. + +Amendment policy: +- MAJOR: incompatible governance/principle changes +- MINOR: new principle or materially expanded requirement +- PATCH: clarifications and editorial updates + +Compliance review expectations: +- Planning artifacts MUST include constitution check status +- Reviewers SHOULD verify traceability and quality-gate completion + +**Version**: 1.0.0 | **Ratified**: 2026-02-11 | **Last Amended**: 2026-02-11 diff --git a/src/App.vue b/src/App.vue index 67b7fdf..aa78684 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,9 +8,35 @@ const dateValue = ref({ startDate: dayjs().format('YYYY-MM-DD HH:mm:ss'), endDate: dayjs().format('YYYY-MM-DD HH:mm:ss'), }) +const selectorModeValue = ref({ + startDate: dayjs().subtract(3, 'month').format('YYYY-MM-DD HH:mm:ss'), + endDate: dayjs().add(3, 'month').format('YYYY-MM-DD HH:mm:ss'), +}) +const selectorSingleRangeValue = ref({ + startDate: dayjs().subtract(10, 'day').format('YYYY-MM-DD HH:mm:ss'), + endDate: dayjs().add(10, 'day').format('YYYY-MM-DD HH:mm:ss'), +}) +const selectorSingleRangeFractionalValue = ref({ + startDate: dayjs().subtract(10, 'day').format('YYYY-MM-DD HH:mm:ss'), + endDate: dayjs().add(10, 'day').format('YYYY-MM-DD HH:mm:ss'), +}) +const selectorDisabledValue = ref(dayjs().format('YYYY-MM-DD HH:mm:ss')) +const selectorEmptyModelValue = ref('') +const selectorInvalidModelValue = ref('not-a-date ~ not-a-date') +const singleDateValue = ref(dayjs().format('YYYY-MM-DD HH:mm:ss')) -const currentLocale = ref('es') -const locales = ['en', 'es', 'de'] +const currentLocale = ref('en') +const localeOptions = [ + { code: 'en', flag: '🇺🇸' }, + { code: 'es', flag: '🇪🇸' }, + { code: 'de', flag: '🇩🇪' }, +] +const isDark = ref(false) + +function disableWeekendDates(date: Date) { + const day = dayjs(date).day() + return day === 0 || day === 6 +} function onClickSomething(e: Dayjs) { console.log(e) @@ -22,24 +48,146 @@ function onSelectSomething(e: Dayjs) { diff --git a/src/VueTailwindDatePicker.vue b/src/VueTailwindDatePicker.vue index 6aa8162..389849a 100644 --- a/src/VueTailwindDatePicker.vue +++ b/src/VueTailwindDatePicker.vue @@ -70,6 +70,14 @@ export interface Props { startFrom?: Date weekdaysSize?: string weekNumber?: boolean + selectorMode?: boolean + selectorFocusTint?: boolean + selectorYearScrollMode?: 'boundary' | 'fractional' + selectorYearHomeJump?: number + selectorYearEndJump?: number + selectorYearPageJump?: number + selectorYearPageShiftJump?: number + closeOnRangeSelection?: boolean options?: { shortcuts: { today: string @@ -110,6 +118,14 @@ const props = withDefaults(defineProps(), { startFrom: () => new Date(), weekdaysSize: 'short', weekNumber: false, + selectorMode: false, + selectorFocusTint: true, + selectorYearScrollMode: 'boundary', + selectorYearHomeJump: 100, + selectorYearEndJump: 100, + selectorYearPageJump: 10, + selectorYearPageShiftJump: 100, + closeOnRangeSelection: true, options: () => ({ shortcuts: { today: 'Today', @@ -159,7 +175,9 @@ dayjs.extend(duration) dayjs.extend(weekOfYear) const VtdRef = ref(null) +const VtdStaticRef = ref(null) const VtdInputRef = ref(null) +const VtdPopoverButtonRef = ref(null) const placement = ref(null) const givenPlaceholder = ref('') const selection = ref(null) @@ -440,6 +458,733 @@ const calendar = computed(() => { }) const displayDatepicker = ref(false) +type PickerViewMode = 'calendar' | 'selector' +type SelectorFocus = 'month' | 'year' +type SelectionContext = 'single' | 'singleRangeDisplayed' | 'previousPanel' | 'nextPanel' +type SelectionPanel = 'previous' | 'next' +type SelectorMonthPayload = number | { month: number, year: number } + +interface SelectorState { + selectedMonth: number + selectedYear: number + anchorYear: number +} + +interface SelectorPanelState { + previous: boolean + next: boolean +} + +interface HeaderInteractionPayload { + panel: SelectionPanel + focus: SelectorFocus +} + +interface HeaderMonthStepPayload { + panel: SelectionPanel + delta: -1 | 1 +} + +interface SelectorMonthWheelHandle { + stepBy: (delta: -1 | 1) => void +} + +interface TogglePickerViewOptions { + restoreFocus?: boolean +} + +const SELECTOR_YEAR_WINDOW_SIZE = 401 +const SELECTOR_YEAR_WINDOW_RADIUS = Math.floor(SELECTOR_YEAR_WINDOW_SIZE / 2) +const SELECTOR_YEAR_REANCHOR_THRESHOLD = 24 + +const pickerViewMode = ref('calendar') +const selectorFocus = ref('month') +const selectionContext = ref('single') +const selectorState = reactive({ + selectedMonth: datepicker.value.previous.month(), + selectedYear: datepicker.value.previous.year(), + anchorYear: datepicker.value.previous.year(), +}) +const selectorPanels = reactive({ + previous: false, + next: false, +}) +const previousSelectorMonthWheelRef = ref(null) +const nextSelectorMonthWheelRef = ref(null) + +function setPreviousSelectorMonthWheelRef(instance: unknown) { + previousSelectorMonthWheelRef.value = instance as SelectorMonthWheelHandle | null +} + +function setNextSelectorMonthWheelRef(instance: unknown) { + nextSelectorMonthWheelRef.value = instance as SelectorMonthWheelHandle | null +} + +function generateSelectorYears(anchorYear: number) { + const startYear = anchorYear - SELECTOR_YEAR_WINDOW_RADIUS + return Array.from({ length: SELECTOR_YEAR_WINDOW_SIZE }, (_, index) => startYear + index) +} + +function anchorSelectorYearWindow(targetYear: number) { + if (selectorState.anchorYear === targetYear) + return + selectorState.anchorYear = targetYear +} + +const selectorYears = computed(() => { + return generateSelectorYears(selectorState.anchorYear) +}) + +function resolveSelectionContext(panel: SelectionPanel): SelectionContext { + const isSingleDateMode = !!props.asSingle && !props.useRange + const isSinglePanelRangeMode = !!props.asSingle && !!props.useRange + + if (isSingleDateMode) + return 'single' + if (isSinglePanelRangeMode) + return 'singleRangeDisplayed' + return panel === 'next' ? 'nextPanel' : 'previousPanel' +} + +function isDualPanelRangeMode() { + return asRange() && !props.asSingle +} + +function resolveContextDate(context: SelectionContext): Dayjs { + if (context === 'nextPanel') + return datepicker.value.next + return datepicker.value.previous +} + +function resolveContextPanel(context: SelectionContext): SelectionPanel { + return context === 'nextPanel' ? 'next' : 'previous' +} + +function getPanelSelectedMonth(panel: SelectionPanel) { + return resolveContextDate(resolveSelectionContext(panel)).month() +} + +function getPanelSelectedYear(panel: SelectionPanel) { + return resolveContextDate(resolveSelectionContext(panel)).year() +} + +function getPickerQueryRoot() { + if (props.noInput) + return VtdStaticRef.value + return VtdRef.value as HTMLElement | null +} + +function isPopoverOpen() { + return !!(VtdRef.value && document.body.contains(VtdRef.value)) +} + +function resolvePopoverButtonElement() { + const refValue = VtdPopoverButtonRef.value as + | HTMLElement + | { $el?: unknown; $?: { vnode?: { el?: unknown } } } + | null + + if (!refValue) + return null + if (refValue instanceof HTMLElement) + return refValue + if (refValue.$el instanceof HTMLElement) + return refValue.$el + if (refValue.$?.vnode?.el instanceof HTMLElement) + return refValue.$.vnode.el + return VtdInputRef.value?.closest('label') ?? null +} + +function triggerPopoverButtonClick() { + const buttonElement = resolvePopoverButtonElement() + if (buttonElement) + buttonElement.click() +} + +function focusHeaderValue(panel: SelectionPanel, focus: SelectorFocus) { + const queryRoot = getPickerQueryRoot() + if (!queryRoot) + return + const headerButton + = queryRoot.querySelector(`#vtd-header-${panel}-${focus}`) + ?? queryRoot.querySelector(`#vtd-header-${panel}-month`) + if (headerButton instanceof HTMLElement) + headerButton.focus() +} + +function focusSelectorModeTarget( + context: SelectionContext = selectionContext.value, + focus: SelectorFocus = selectorFocus.value, +) { + const queryRoot = getPickerQueryRoot() + if (!queryRoot) + return + const panel = resolveContextPanel(context) + const panelElement = queryRoot.querySelector(`[data-vtd-selector-panel="${panel}"]`) + if (!panelElement) + return + + if (focus === 'month') { + const monthSelector = panelElement.querySelector('[aria-label="Month selector"]') + if (monthSelector) { + monthSelector.focus() + return true + } + } + else { + const yearSelector = panelElement.querySelector('[aria-label="Year selector"]') + if (yearSelector) { + yearSelector.focus() + return true + } + } + + return false +} + +function focusSelectorModeTargetDeferred( + context: SelectionContext = selectionContext.value, + focus: SelectorFocus = selectorFocus.value, + attempt = 0, +) { + requestAnimationFrame(() => { + const focused = focusSelectorModeTarget(context, focus) + if (!focused && attempt < 2) + focusSelectorModeTargetDeferred(context, focus, attempt + 1) + }) +} + +function focusCalendarModeTarget() { + const queryRoot = getPickerQueryRoot() + if (!queryRoot) + return false + + const preferredPanel = queryRoot.querySelector('[data-vtd-selector-panel="previous"]') + const fallbackPanel = queryRoot.querySelector('[data-vtd-selector-panel="next"]') + const panelElement = preferredPanel ?? fallbackPanel + if (!panelElement) + return false + + const calendarDateButton + = panelElement.querySelector('.vtd-calendar-focus-target') + ?? panelElement.querySelector('.vtd-datepicker-date:not(:disabled)') + if (!calendarDateButton) + return false + + calendarDateButton.focus() + return true +} + +function focusCalendarModeTargetDeferred(attempt = 0) { + requestAnimationFrame(() => { + const focused = focusCalendarModeTarget() + if (!focused && attempt < 2) + focusCalendarModeTargetDeferred(attempt + 1) + }) +} + +interface SyncSelectorStateOptions { + syncAnchor?: boolean +} + +function syncSelectorState( + context: SelectionContext = selectionContext.value, + options: SyncSelectorStateOptions = {}, +) { + const { syncAnchor = true } = options + const contextDate = resolveContextDate(context) + selectorState.selectedMonth = contextDate.month() + selectorState.selectedYear = contextDate.year() + if (syncAnchor) + anchorSelectorYearWindow(contextDate.year()) +} + +function syncDatepickerYears() { + datepicker.value.year.previous = datepicker.value.previous.year() + datepicker.value.year.next = datepicker.value.next.year() +} + +function syncSelectorRangeOrder(context: SelectionContext) { + if (context === 'nextPanel') { + if ( + datepicker.value.previous.isSame(datepicker.value.next, 'month') + || datepicker.value.previous.isAfter(datepicker.value.next) + ) { + datepicker.value.previous = datepicker.value.next.subtract(1, 'month') + } + return + } + + if ( + datepicker.value.next.isSame(datepicker.value.previous, 'month') + || datepicker.value.next.isBefore(datepicker.value.previous) + ) { + datepicker.value.next = datepicker.value.previous.add(1, 'month') + } +} + +function applySelectorMonth(context: SelectionContext, month: number, year?: number) { + if (context === 'nextPanel') { + let nextDate = datepicker.value.next + if (typeof year === 'number') + nextDate = nextDate.year(year) + nextDate = nextDate.month(month) + datepicker.value.next = nextDate + emit('selectRightMonth', datepicker.value.next) + } + else { + let previousDate = datepicker.value.previous + if (typeof year === 'number') + previousDate = previousDate.year(year) + previousDate = previousDate.month(month) + datepicker.value.previous = previousDate + emit('selectMonth', datepicker.value.previous) + } + + syncSelectorRangeOrder(context) + syncDatepickerYears() +} + +function resolveSelectorMonthDelta(currentMonth: number, targetMonth: number) { + const rawDelta = targetMonth - currentMonth + if (rawDelta > 6) + return rawDelta - 12 + if (rawDelta < -6) + return rawDelta + 12 + return rawDelta +} + +function applySelectorYear(context: SelectionContext, year: number) { + if (context === 'nextPanel') { + datepicker.value.next = datepicker.value.next.year(year) + emit('selectRightYear', datepicker.value.next) + } + else { + datepicker.value.previous = datepicker.value.previous.year(year) + emit('selectYear', datepicker.value.previous) + } + + syncSelectorRangeOrder(context) + syncDatepickerYears() +} + +function closeLegacyPanels() { + const isAlreadyClosed + = panel.previous.calendar + && !panel.previous.month + && !panel.previous.year + && panel.next.calendar + && !panel.next.month + && !panel.next.year + if (isAlreadyClosed) + return + + panel.previous.calendar = true + panel.previous.month = false + panel.previous.year = false + panel.next.calendar = true + panel.next.month = false + panel.next.year = false +} + +function shouldReanchorSelectorYearWindow(year: number) { + const years = selectorYears.value + if (years.length === 0) + return true + const firstYear = years[0] + const lastYear = years[years.length - 1] + return year <= firstYear + SELECTOR_YEAR_REANCHOR_THRESHOLD + || year >= lastYear - SELECTOR_YEAR_REANCHOR_THRESHOLD +} + +function ensureSelectorYearInWindow(year: number) { + const years = selectorYears.value + if (years.length === 0) { + anchorSelectorYearWindow(year) + return + } + + const firstYear = years[0] + const lastYear = years[years.length - 1] + if (year < firstYear || year > lastYear) + anchorSelectorYearWindow(year) +} + +function isSelectorPanel(panelName: SelectionPanel) { + if (!props.selectorMode || pickerViewMode.value !== 'selector') + return false + if (isDualPanelRangeMode()) + return selectorPanels[panelName] + return selectionContext.value === resolveSelectionContext(panelName) +} + +function getPanelPickerViewMode(panelName: SelectionPanel): PickerViewMode { + return isSelectorPanel(panelName) ? 'selector' : 'calendar' +} + +function getSelectorColumnClass(focus: SelectorFocus) { + if (!props.selectorFocusTint) { + return selectorFocus.value === focus + ? 'border-vtd-primary-300 dark:border-vtd-primary-500' + : 'border-black/[.08] dark:border-vtd-secondary-700/[1]' + } + + return selectorFocus.value === focus + ? 'border-vtd-primary-300 bg-vtd-primary-50/40 ring-2 ring-vtd-primary-400/35 ring-offset-1 ring-offset-transparent dark:border-vtd-primary-500 dark:bg-vtd-secondary-700/50 dark:ring-vtd-primary-500/35' + : 'border-black/[.08] dark:border-vtd-secondary-700/[1]' +} + +function onSelectorColumnFocus(panelName: SelectionPanel, focus: SelectorFocus) { + if (!props.selectorMode) + return + selectionContext.value = resolveSelectionContext(panelName) + selectorFocus.value = focus +} + +function requestSelectorColumnFocus(panelName: SelectionPanel, focus: SelectorFocus) { + if (!props.selectorMode) + return + + const context = resolveSelectionContext(panelName) + selectionContext.value = context + selectorFocus.value = focus + nextTick(() => { + focusSelectorModeTargetDeferred(context, focus) + }) +} + +function enterSelectorMode(payload: HeaderInteractionPayload) { + if (!props.selectorMode) + return + const { panel, focus } = payload + + selectorFocus.value = focus + selectionContext.value = resolveSelectionContext(panel) + if (isDualPanelRangeMode()) + selectorPanels[panel] = true + syncSelectorState(selectionContext.value) + pickerViewMode.value = 'selector' + closeLegacyPanels() + nextTick(() => { + if (pickerViewMode.value === 'selector') { + closeLegacyPanels() + focusSelectorModeTargetDeferred(selectionContext.value, selectorFocus.value) + } + }) +} + +function togglePickerViewMode( + payload: HeaderInteractionPayload, + options: TogglePickerViewOptions = {}, +) { + if (!props.selectorMode) + return + const { restoreFocus = false } = options + const { panel, focus } = payload + + selectorFocus.value = focus + selectionContext.value = resolveSelectionContext(panel) + const isDualPanel = isDualPanelRangeMode() + + if (isDualPanel) { + const nextOpenState = !selectorPanels[panel] + selectorPanels[panel] = nextOpenState + + if (!selectorPanels.previous && !selectorPanels.next) { + pickerViewMode.value = 'calendar' + closeLegacyPanels() + if (restoreFocus) + nextTick(() => focusHeaderValue(panel, focus)) + return + } + + pickerViewMode.value = 'selector' + closeLegacyPanels() + if (nextOpenState) { + syncSelectorState(selectionContext.value) + nextTick(() => { + if (pickerViewMode.value === 'selector') { + closeLegacyPanels() + focusSelectorModeTargetDeferred(selectionContext.value, selectorFocus.value) + } + }) + return + } + + if (!selectorPanels[panel]) { + selectionContext.value = selectorPanels.previous ? 'previousPanel' : 'nextPanel' + } + return + } + + if (pickerViewMode.value === 'selector') { + pickerViewMode.value = 'calendar' + closeLegacyPanels() + if (restoreFocus) + nextTick(() => focusHeaderValue(panel, focus)) + return + } + syncSelectorState(selectionContext.value) + pickerViewMode.value = 'selector' + closeLegacyPanels() + nextTick(() => { + if (pickerViewMode.value === 'selector') { + closeLegacyPanels() + focusSelectorModeTargetDeferred(selectionContext.value, selectorFocus.value) + } + }) +} + +function onSelectorMonthUpdate(panelName: SelectionPanel, payload: SelectorMonthPayload) { + if (!props.selectorMode) + return + const context = resolveSelectionContext(panelName) + selectionContext.value = context + const targetMonth = typeof payload === 'number' ? payload : payload.month + const targetYear = typeof payload === 'number' ? undefined : payload.year + const contextDate = resolveContextDate(context) + const isSameMonth = contextDate.month() === targetMonth + const isSameYear = targetYear === undefined || contextDate.year() === targetYear + if (isSameMonth && isSameYear) { + closeLegacyPanels() + return + } + + if (typeof targetYear === 'number') { + applySelectorMonth(context, targetMonth, targetYear) + } + else { + // Month-only selector events infer year rollover from cyclic month deltas. + const delta = resolveSelectorMonthDelta(contextDate.month(), targetMonth) + const inferredDate = contextDate.add(delta, 'month') + applySelectorMonth(context, inferredDate.month(), inferredDate.year()) + } + + syncSelectorState(context, { syncAnchor: false }) + if (shouldReanchorSelectorYearWindow(selectorState.selectedYear)) + anchorSelectorYearWindow(selectorState.selectedYear) + closeLegacyPanels() +} + +function onSelectorYearUpdate(panelName: SelectionPanel, year: number) { + if (!props.selectorMode) + return + const context = resolveSelectionContext(panelName) + selectionContext.value = context + + // Keyboard jumps can target a year beyond the current virtual window. + // Anchor first so the requested year is renderable in the next selector paint. + ensureSelectorYearInWindow(year) + + if (resolveContextDate(context).year() === year) { + syncSelectorState(context, { syncAnchor: false }) + if (shouldReanchorSelectorYearWindow(selectorState.selectedYear)) + anchorSelectorYearWindow(selectorState.selectedYear) + closeLegacyPanels() + return + } + applySelectorYear(context, year) + syncSelectorState(context, { syncAnchor: false }) + if (shouldReanchorSelectorYearWindow(selectorState.selectedYear)) + anchorSelectorYearWindow(selectorState.selectedYear) + closeLegacyPanels() +} + +function onHeaderMonthStep(payload: HeaderMonthStepPayload) { + if (!props.selectorMode || pickerViewMode.value !== 'selector') + return + + const wheel = payload.panel === 'next' + ? nextSelectorMonthWheelRef.value + : previousSelectorMonthWheelRef.value + if (wheel) { + selectionContext.value = resolveSelectionContext(payload.panel) + wheel.stepBy(payload.delta) + closeLegacyPanels() + return + } + + const context = resolveSelectionContext(payload.panel) + selectionContext.value = context + const nextDate = resolveContextDate(context).add(payload.delta, 'month') + applySelectorMonth(context, nextDate.month(), nextDate.year()) + syncSelectorState(context, { syncAnchor: false }) + if (shouldReanchorSelectorYearWindow(selectorState.selectedYear)) + anchorSelectorYearWindow(selectorState.selectedYear) + closeLegacyPanels() +} + +function resetPickerViewMode() { + pickerViewMode.value = 'calendar' + selectorPanels.previous = false + selectorPanels.next = false + if (props.selectorMode) + closeLegacyPanels() +} + +function isVisibleElement(element: HTMLElement) { + return element.getClientRects().length > 0 +} + +function getSelectorFocusCycleTargets() { + const queryRoot = getPickerQueryRoot() + if (!queryRoot) + return [] + + const targets: HTMLElement[] = [] + const pushTarget = (element: HTMLElement | null) => { + if (!element || !isVisibleElement(element)) + return + if (targets.includes(element)) + return + targets.push(element) + } + + const appendPanelTargets = (panel: SelectionPanel) => { + pushTarget(queryRoot.querySelector(`#vtd-header-${panel}-month`)) + const panelElement = queryRoot.querySelector(`[data-vtd-selector-panel="${panel}"]`) + if (!panelElement) + return + pushTarget(panelElement.querySelector('[aria-label="Month selector"]')) + pushTarget(panelElement.querySelector('[aria-label="Year selector"]')) + } + + appendPanelTargets('previous') + if (asRange() && !props.asSingle) + appendPanelTargets('next') + + // Shortcuts are treated as a single focus stop in selector tab order. + pushTarget(queryRoot.querySelector('.vtd-shortcuts')) + return targets +} + +function getCalendarFocusCycleTargets() { + const queryRoot = getPickerQueryRoot() + if (!queryRoot) + return [] + + const targets: HTMLElement[] = [] + const pushTarget = (element: HTMLElement | null) => { + if (!element || !isVisibleElement(element)) + return + if (targets.includes(element)) + return + targets.push(element) + } + + const appendCalendarTarget = (panel: SelectionPanel) => { + const panelElement = queryRoot.querySelector(`[data-vtd-selector-panel="${panel}"]`) + if (!panelElement) + return + pushTarget(panelElement.querySelector('.vtd-calendar-focus-target')) + } + + const isDoublePanel = asRange() && !props.asSingle + const previousHeader = queryRoot.querySelector('#vtd-header-previous-month') + const nextHeader = queryRoot.querySelector('#vtd-header-next-month') + const shortcutContainer = queryRoot.querySelector('.vtd-shortcuts') + + // Calendar mode tab order: + // previous calendar -> (next header -> next calendar) -> shortcuts -> previous header -> repeat + appendCalendarTarget('previous') + if (isDoublePanel) { + pushTarget(nextHeader) + appendCalendarTarget('next') + } + pushTarget(shortcutContainer) + pushTarget(previousHeader) + return targets +} + +type PopoverCloseFn = (ref?: Ref | HTMLElement) => void + +function closePopoverFromPanel(close?: PopoverCloseFn) { + resetPickerViewMode() + if (close) { + if (VtdInputRef.value) { + close(VtdInputRef.value) + return + } + close() + return + } + if (!props.noInput) + triggerPopoverButtonClick() +} + +function onPickerPanelKeydown(event: KeyboardEvent, close?: PopoverCloseFn) { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + closePopoverFromPanel(close) + return + } + + if (event.key !== 'Tab') + return + + const inSelectorMode = props.selectorMode && pickerViewMode.value === 'selector' + const targets = inSelectorMode + ? getSelectorFocusCycleTargets() + : getCalendarFocusCycleTargets() + if (targets.length === 0) + return + + event.preventDefault() + event.stopPropagation() + + const activeElement = document.activeElement as HTMLElement | null + let currentIndex = targets.findIndex(target => target === activeElement || target.contains(activeElement)) + if (currentIndex < 0) { + currentIndex = targets.findIndex((target) => { + const panel = resolveContextPanel(selectionContext.value) + const expectedHeader = target.id === `vtd-header-${panel}-month` + const expectedMonth = selectorFocus.value === 'month' && target.getAttribute('aria-label') === 'Month selector' + const expectedYear = selectorFocus.value === 'year' && target.getAttribute('aria-label') === 'Year selector' + return expectedHeader || expectedMonth || expectedYear + }) + } + if (currentIndex < 0) + currentIndex = 0 + + const delta = event.shiftKey ? -1 : 1 + const nextIndex = (currentIndex + delta + targets.length) % targets.length + targets[nextIndex].focus() +} + +function onInputKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter' || event.altKey || event.ctrlKey || event.metaKey) + return + + event.preventDefault() + event.stopPropagation() + resetPickerViewMode() + + if (!isPopoverOpen()) + triggerPopoverButtonClick() + + nextTick(() => { + focusCalendarModeTargetDeferred() + }) +} + +function onPopoverAfterEnter() { + nextTick(() => { + if (props.selectorMode && pickerViewMode.value === 'selector') { + focusSelectorModeTargetDeferred(selectionContext.value, selectorFocus.value) + return + } + focusCalendarModeTargetDeferred() + }) +} + +watch( + () => props.selectorMode, + (enabled) => { + if (!enabled) + resetPickerViewMode() + }, +) setTimeout(() => { displayDatepicker.value = true @@ -632,7 +1377,11 @@ function setDate(date: Dayjs, close?: (ref?: Ref | HTMLElement) => void) { ), ) } - if (close) + const shouldClosePopover + = props.closeOnRangeSelection && typeof close === 'function' + // In `no-input` static mode there is no popover close callback, so + // this option intentionally becomes a no-op. + if (shouldClosePopover) close() applyValue.value = [] @@ -958,12 +1707,12 @@ function datepickerClasses(date: DatePickerDay) { } if (active) { classes = today - ? 'text-vtd-primary-500 font-semibold dark:text-vtd-primary-400 rounded-full focus:bg-vtd-primary-50 focus:text-vtd-secondary-900 focus:border-vtd-primary-300 focus:ring focus:ring-vtd-primary-500 focus:ring-opacity-10 focus:outline-none dark:bg-vtd-secondary-800 dark:text-vtd-secondary-300 dark:hover:bg-vtd-secondary-700 dark:hover:text-vtd-secondary-300 dark:focus:bg-vtd-secondary-600 dark:focus:text-vtd-secondary-100 dark:focus:border-vtd-primary-500 dark:focus:ring-opacity-25 dark:focus:bg-opacity-50' - : disabled - ? 'text-vtd-secondary-600 font-normal disabled:text-vtd-secondary-500 disabled:cursor-not-allowed rounded-full' - : date.isBetween(s as Dayjs, e as Dayjs, 'date', '()') - ? 'text-vtd-secondary-700 font-medium dark:text-vtd-secondary-100 rounded-full' - : 'text-vtd-secondary-600 font-medium dark:text-vtd-secondary-200 rounded-full' + ? 'text-vtd-primary-500 font-semibold dark:text-vtd-primary-400 rounded-full focus:bg-vtd-primary-50 focus:text-vtd-secondary-900 focus:border-vtd-primary-300 focus:ring focus:ring-vtd-primary-500/10 focus:outline-none dark:bg-vtd-secondary-800 dark:text-vtd-secondary-300 dark:hover:bg-vtd-secondary-700 dark:hover:text-vtd-secondary-300 dark:focus:bg-vtd-secondary-600/50 dark:focus:text-vtd-secondary-100 dark:focus:border-vtd-primary-500 dark:focus:ring-vtd-primary-500/25' + : disabled + ? 'text-vtd-secondary-600 font-normal disabled:text-vtd-secondary-500 disabled:cursor-not-allowed rounded-full' + : date.isBetween(s as Dayjs, e as Dayjs, 'date', '()') + ? 'text-vtd-secondary-700 font-medium dark:text-vtd-secondary-100' + : 'text-vtd-secondary-600 font-medium dark:text-vtd-secondary-200 rounded-full' } if (off) classes = 'text-vtd-secondary-400 font-light disabled:cursor-not-allowed' @@ -971,27 +1720,27 @@ function datepickerClasses(date: DatePickerDay) { if (s && e && !off) { if (date.isSame(s, 'date')) { classes = e.isAfter(s, 'date') - ? 'bg-vtd-primary-500 text-white font-bold rounded-l-full disabled:cursor-not-allowed' - : 'bg-vtd-primary-500 text-white font-bold rounded-r-full disabled:cursor-not-allowed' + ? 'vtd-datepicker-date-selected vtd-datepicker-date-selected-start disabled:cursor-not-allowed' + : 'vtd-datepicker-date-selected vtd-datepicker-date-selected-end disabled:cursor-not-allowed' if (s.isSame(e, 'date')) { classes - = 'bg-vtd-primary-500 text-white font-bold rounded-full disabled:cursor-not-allowed' + = 'vtd-datepicker-date-selected vtd-datepicker-date-selected-single disabled:cursor-not-allowed' } } if (date.isSame(e, 'date')) { classes = e.isAfter(s, 'date') - ? 'bg-vtd-primary-500 text-white font-bold rounded-r-full disabled:cursor-not-allowed' - : 'bg-vtd-primary-500 text-white font-bold rounded-l-full disabled:cursor-not-allowed' + ? 'vtd-datepicker-date-selected vtd-datepicker-date-selected-end disabled:cursor-not-allowed' + : 'vtd-datepicker-date-selected vtd-datepicker-date-selected-start disabled:cursor-not-allowed' if (s.isSame(e, 'date')) { classes - = 'bg-vtd-primary-500 text-white font-bold rounded-full disabled:cursor-not-allowed' + = 'vtd-datepicker-date-selected vtd-datepicker-date-selected-single disabled:cursor-not-allowed' } } } else if (s) { if (date.isSame(s, 'date') && !off) { classes - = 'bg-vtd-primary-500 text-white font-bold rounded-full disabled:cursor-not-allowed' + = 'vtd-datepicker-date-selected vtd-datepicker-date-selected-single disabled:cursor-not-allowed' } } @@ -1065,20 +1814,35 @@ function betweenRangeClasses(date: Dayjs) { } } - if (s && e) { - if (date.isSame(s, 'date')) { - if (e.isBefore(s)) - classes += ' rounded-r-full inset-0' + const startDate = dayjs.isDayjs(s) && s.isValid() ? s : null + const endDate = dayjs.isDayjs(e) && e.isValid() ? e : null + + if (startDate && endDate) { + const isWithinRange + = date.isBetween(startDate, endDate, 'date', '[]') + || date.isBetween(endDate, startDate, 'date', '[]') + if (!isWithinRange) + return classes - if (s.isBefore(e)) - classes += ' rounded-l-full inset-0' + if (date.isSame(startDate, 'date')) { + if (endDate.isBefore(startDate)) + classes += ' rounded-r-full inset-0 vtd-datepicker-range-preview-edge' + + if (startDate.isBefore(endDate)) + classes += ' rounded-l-full inset-0 vtd-datepicker-range-preview-edge' + + if (startDate.isSame(endDate, 'date')) + classes += ' rounded-full inset-0 vtd-datepicker-range-preview-edge' } - else if (date.isSame(e, 'date')) { - if (e.isBefore(s)) - classes += ' rounded-l-full inset-0' + else if (date.isSame(endDate, 'date')) { + if (endDate.isBefore(startDate)) + classes += ' rounded-l-full inset-0 vtd-datepicker-range-preview-edge' + + if (startDate.isBefore(endDate)) + classes += ' rounded-r-full inset-0 vtd-datepicker-range-preview-edge' - if (s.isBefore(e)) - classes += ' rounded-r-full inset-0' + if (startDate.isSame(endDate, 'date')) + classes += ' rounded-full inset-0 vtd-datepicker-range-preview-edge' } else { classes += ' inset-0' @@ -1417,6 +2181,11 @@ function getAbsoluteParentClass(open: boolean) { return 'left-0 right-auto' } +watchEffect(() => { + if (pickerViewMode.value === 'calendar') + syncSelectorState(resolveSelectionContext('previous')) +}) + provide(isBetweenRangeKey, isBetweenRange) provide(betweenRangeClassesKey, betweenRangeClasses) provide(datepickerClassesKey, datepickerClasses) @@ -1433,18 +2202,18 @@ provide(setToCustomShortcutKey, setToCustomShortcut) - + + @keyup.stop="keyUp" @keydown.stop="onInputKeydown">