diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d63416b292..4d6f8db243 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -192,6 +192,22 @@ export default defineConfig({ text: `Time Field ${BadgeHTML('Alpha', true)}`, link: '/docs/components/time-field', }, + { + text: `Month Picker ${BadgeHTML('Alpha', true)}`, + link: '/docs/components/month-picker', + }, + { + text: `Month Range Picker ${BadgeHTML('Alpha', true)}`, + link: '/docs/components/month-range-picker', + }, + { + text: `Year Picker ${BadgeHTML('Alpha', true)}`, + link: '/docs/components/year-picker', + }, + { + text: `Year Range Picker ${BadgeHTML('Alpha', true)}`, + link: '/docs/components/year-range-picker', + }, ], }, { @@ -317,6 +333,10 @@ export default defineConfig({ text: 'Date Picker Selection', link: '/examples/date-picker-selection', }, + { + text: 'Date Picker View Switching', + link: '/examples/date-picker-view-switching', + }, ], }, { diff --git a/docs/components/demo/MonthPicker/css/index.vue b/docs/components/demo/MonthPicker/css/index.vue new file mode 100644 index 0000000000..289c4e07d6 --- /dev/null +++ b/docs/components/demo/MonthPicker/css/index.vue @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components/demo/MonthPicker/css/styles.css b/docs/components/demo/MonthPicker/css/styles.css new file mode 100644 index 0000000000..2e65f27643 --- /dev/null +++ b/docs/components/demo/MonthPicker/css/styles.css @@ -0,0 +1,134 @@ +@import '@radix-ui/colors/black-alpha.css'; +@import '@radix-ui/colors/grass.css'; + +.Icon { + width: 1rem; + height: 1rem; +} + +.MonthPicker { + margin-top: 1.5rem; + border-radius: 0.75rem; + border: 1px solid #e5e5e5; + background-color: #ffffff; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + padding: 1rem; +} + +.MonthPickerHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.MonthPickerNavButton { + display: inline-flex; + justify-content: center; + align-items: center; + width: 2rem; + height: 2rem; + color: #000000; + background-color: transparent; + cursor: pointer; + border-radius: 0.375rem; +} + +.MonthPickerNavButton:hover { + background-color: #fafaf9; +} + +.MonthPickerNavButton:focus { + box-shadow: 0 0 0 2px #000000; +} + +.MonthPickerHeading { + font-size: 0.875rem; + font-weight: 500; + color: #000000; +} + +.MonthPickerWrapper { + padding-top: 1rem; +} + +.MonthPickerGrid { + width: 100%; + user-select: none; +} + +.MonthPickerGridWrapper { + display: grid; + gap: 0.25rem 0; +} + +.MonthPickerGridRow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.25rem; +} + +.MonthPickerCell { + position: relative; + text-align: center; +} + +.MonthPickerCellTrigger { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 3rem; + height: 3rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 400; + color: #000000; + background-color: transparent; + outline: none; + cursor: pointer; + border: none; +} + +.MonthPickerCellTrigger:hover { + background-color: var(--grass-5); +} + +.MonthPickerCellTrigger:focus { + box-shadow: 0 0 0 2px #000000; +} + +.MonthPickerCellTrigger[data-disabled] { + color: rgba(0, 0, 0, 0.3); + pointer-events: none; +} + +.MonthPickerCellTrigger[data-selected] { + background-color: var(--grass-10); + color: #ffffff; + font-weight: 500; +} + +.MonthPickerCellTrigger[data-unavailable] { + color: rgba(0, 0, 0, 0.3); + text-decoration: line-through; + pointer-events: none; +} + +.MonthPickerCellTrigger::before { + content: ''; + position: absolute; + top: 0.25rem; + width: 0.25rem; + height: 0.25rem; + border-radius: 9999px; + display: none; +} + +.MonthPickerCellTrigger[data-today]::before { + display: block; + background-color: var(--grass-9); +} + +.MonthPickerCellTrigger[data-selected]::before { + background-color: #ffffff; +} diff --git a/docs/components/demo/MonthPicker/tailwind/index.vue b/docs/components/demo/MonthPicker/tailwind/index.vue new file mode 100644 index 0000000000..79d184f5c2 --- /dev/null +++ b/docs/components/demo/MonthPicker/tailwind/index.vue @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components/demo/MonthPicker/tailwind/tailwind.config.js b/docs/components/demo/MonthPicker/tailwind/tailwind.config.js new file mode 100644 index 0000000000..a9c58386b0 --- /dev/null +++ b/docs/components/demo/MonthPicker/tailwind/tailwind.config.js @@ -0,0 +1,16 @@ +const { blackA, grass, green } = require('@radix-ui/colors') + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./**/*.vue'], + theme: { + extend: { + colors: { + ...blackA, + ...grass, + ...green, + }, + }, + }, + plugins: [], +} diff --git a/docs/components/demo/MonthRangePicker/css/index.vue b/docs/components/demo/MonthRangePicker/css/index.vue new file mode 100644 index 0000000000..0858f6e5be --- /dev/null +++ b/docs/components/demo/MonthRangePicker/css/index.vue @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components/demo/MonthRangePicker/css/styles.css b/docs/components/demo/MonthRangePicker/css/styles.css new file mode 100644 index 0000000000..b24b1a737c --- /dev/null +++ b/docs/components/demo/MonthRangePicker/css/styles.css @@ -0,0 +1,138 @@ +@import '@radix-ui/colors/black-alpha.css'; +@import '@radix-ui/colors/grass.css'; + +.Icon { + width: 1rem; + height: 1rem; +} + +.MonthRangePicker { + margin-top: 1.5rem; + border-radius: 0.75rem; + border: 1px solid #e5e5e5; + background-color: #ffffff; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + padding: 1rem; +} + +.MonthRangePickerHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.MonthRangePickerNavButton { + display: inline-flex; + justify-content: center; + align-items: center; + width: 2rem; + height: 2rem; + color: #000000; + background-color: transparent; + cursor: pointer; + border-radius: 0.375rem; +} + +.MonthRangePickerNavButton:hover { + background-color: #fafaf9; +} + +.MonthRangePickerNavButton:focus { + box-shadow: 0 0 0 2px #000000; +} + +.MonthRangePickerHeading { + font-size: 0.875rem; + font-weight: 500; + color: #000000; +} + +.MonthRangePickerWrapper { + padding-top: 1rem; +} + +.MonthRangePickerGrid { + width: 100%; + user-select: none; +} + +.MonthRangePickerGridWrapper { + display: grid; + gap: 0.25rem 0; +} + +.MonthRangePickerGridRow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.25rem; +} + +.MonthRangePickerCell { + position: relative; + text-align: center; +} + +.MonthRangePickerCellTrigger { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 3rem; + height: 3rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 400; + color: #000000; + background-color: transparent; + outline: none; + cursor: pointer; + border: none; +} + +.MonthRangePickerCellTrigger:hover { + background-color: var(--grass-5); +} + +.MonthRangePickerCellTrigger:focus { + box-shadow: 0 0 0 2px #000000; +} + +.MonthRangePickerCellTrigger[data-disabled] { + color: rgba(0, 0, 0, 0.3); + pointer-events: none; +} + +.MonthRangePickerCellTrigger[data-selected] { + background-color: var(--grass-10); + color: #ffffff; + font-weight: 500; +} + +.MonthRangePickerCellTrigger[data-highlighted] { + background-color: var(--grass-5); +} + +.MonthRangePickerCellTrigger[data-unavailable] { + color: rgba(0, 0, 0, 0.3); + text-decoration: line-through; + pointer-events: none; +} + +.MonthRangePickerCellTrigger::before { + content: ''; + position: absolute; + top: 0.25rem; + width: 0.25rem; + height: 0.25rem; + border-radius: 9999px; + display: none; +} + +.MonthRangePickerCellTrigger[data-today]::before { + display: block; + background-color: var(--grass-9); +} + +.MonthRangePickerCellTrigger[data-selected]::before { + background-color: #ffffff; +} diff --git a/docs/components/demo/MonthRangePicker/tailwind/index.vue b/docs/components/demo/MonthRangePicker/tailwind/index.vue new file mode 100644 index 0000000000..7c1888f6cf --- /dev/null +++ b/docs/components/demo/MonthRangePicker/tailwind/index.vue @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components/demo/YearPicker/css/index.vue b/docs/components/demo/YearPicker/css/index.vue new file mode 100644 index 0000000000..84d04d67cd --- /dev/null +++ b/docs/components/demo/YearPicker/css/index.vue @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components/demo/YearPicker/css/styles.css b/docs/components/demo/YearPicker/css/styles.css new file mode 100644 index 0000000000..eb8edea63a --- /dev/null +++ b/docs/components/demo/YearPicker/css/styles.css @@ -0,0 +1,134 @@ +@import '@radix-ui/colors/black-alpha.css'; +@import '@radix-ui/colors/grass.css'; + +.Icon { + width: 1rem; + height: 1rem; +} + +.YearPicker { + margin-top: 1.5rem; + border-radius: 0.75rem; + border: 1px solid #e5e5e5; + background-color: #ffffff; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + padding: 1rem; +} + +.YearPickerHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.YearPickerNavButton { + display: inline-flex; + justify-content: center; + align-items: center; + width: 2rem; + height: 2rem; + color: #000000; + background-color: transparent; + cursor: pointer; + border-radius: 0.375rem; +} + +.YearPickerNavButton:hover { + background-color: #fafaf9; +} + +.YearPickerNavButton:focus { + box-shadow: 0 0 0 2px #000000; +} + +.YearPickerHeading { + font-size: 0.875rem; + font-weight: 500; + color: #000000; +} + +.YearPickerWrapper { + padding-top: 1rem; +} + +.YearPickerGrid { + width: 100%; + user-select: none; +} + +.YearPickerGridWrapper { + display: grid; + gap: 0.25rem 0; +} + +.YearPickerGridRow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.25rem; +} + +.YearPickerCell { + position: relative; + text-align: center; +} + +.YearPickerCellTrigger { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 3rem; + height: 3rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 400; + color: #000000; + background-color: transparent; + outline: none; + cursor: pointer; + border: none; +} + +.YearPickerCellTrigger:hover { + background-color: var(--grass-5); +} + +.YearPickerCellTrigger:focus { + box-shadow: 0 0 0 2px #000000; +} + +.YearPickerCellTrigger[data-disabled] { + color: rgba(0, 0, 0, 0.3); + pointer-events: none; +} + +.YearPickerCellTrigger[data-selected] { + background-color: var(--grass-10); + color: #ffffff; + font-weight: 500; +} + +.YearPickerCellTrigger[data-unavailable] { + color: rgba(0, 0, 0, 0.3); + text-decoration: line-through; + pointer-events: none; +} + +.YearPickerCellTrigger::before { + content: ''; + position: absolute; + top: 0.25rem; + width: 0.25rem; + height: 0.25rem; + border-radius: 9999px; + display: none; +} + +.YearPickerCellTrigger[data-today]::before { + display: block; + background-color: var(--grass-9); +} + +.YearPickerCellTrigger[data-selected]::before { + background-color: #ffffff; +} diff --git a/docs/components/demo/YearPicker/tailwind/index.vue b/docs/components/demo/YearPicker/tailwind/index.vue new file mode 100644 index 0000000000..619dc2556e --- /dev/null +++ b/docs/components/demo/YearPicker/tailwind/index.vue @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components/demo/YearPicker/tailwind/tailwind.config.js b/docs/components/demo/YearPicker/tailwind/tailwind.config.js new file mode 100644 index 0000000000..a9c58386b0 --- /dev/null +++ b/docs/components/demo/YearPicker/tailwind/tailwind.config.js @@ -0,0 +1,16 @@ +const { blackA, grass, green } = require('@radix-ui/colors') + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./**/*.vue'], + theme: { + extend: { + colors: { + ...blackA, + ...grass, + ...green, + }, + }, + }, + plugins: [], +} diff --git a/docs/components/demo/YearRangePicker/css/index.vue b/docs/components/demo/YearRangePicker/css/index.vue new file mode 100644 index 0000000000..0c2c5706eb --- /dev/null +++ b/docs/components/demo/YearRangePicker/css/index.vue @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components/demo/YearRangePicker/css/styles.css b/docs/components/demo/YearRangePicker/css/styles.css new file mode 100644 index 0000000000..e9025b2974 --- /dev/null +++ b/docs/components/demo/YearRangePicker/css/styles.css @@ -0,0 +1,138 @@ +@import '@radix-ui/colors/black-alpha.css'; +@import '@radix-ui/colors/grass.css'; + +.Icon { + width: 1rem; + height: 1rem; +} + +.YearRangePicker { + margin-top: 1.5rem; + border-radius: 0.75rem; + border: 1px solid #e5e5e5; + background-color: #ffffff; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + padding: 1rem; +} + +.YearRangePickerHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.YearRangePickerNavButton { + display: inline-flex; + justify-content: center; + align-items: center; + width: 2rem; + height: 2rem; + color: #000000; + background-color: transparent; + cursor: pointer; + border-radius: 0.375rem; +} + +.YearRangePickerNavButton:hover { + background-color: #fafaf9; +} + +.YearRangePickerNavButton:focus { + box-shadow: 0 0 0 2px #000000; +} + +.YearRangePickerHeading { + font-size: 0.875rem; + font-weight: 500; + color: #000000; +} + +.YearRangePickerWrapper { + padding-top: 1rem; +} + +.YearRangePickerGrid { + width: 100%; + user-select: none; +} + +.YearRangePickerGridWrapper { + display: grid; + gap: 0.25rem 0; +} + +.YearRangePickerGridRow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.25rem; +} + +.YearRangePickerCell { + position: relative; + text-align: center; +} + +.YearRangePickerCellTrigger { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 3rem; + height: 3rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 400; + color: #000000; + background-color: transparent; + outline: none; + cursor: pointer; + border: none; +} + +.YearRangePickerCellTrigger:hover { + background-color: var(--grass-5); +} + +.YearRangePickerCellTrigger:focus { + box-shadow: 0 0 0 2px #000000; +} + +.YearRangePickerCellTrigger[data-disabled] { + color: rgba(0, 0, 0, 0.3); + pointer-events: none; +} + +.YearRangePickerCellTrigger[data-selected] { + background-color: var(--grass-10); + color: #ffffff; + font-weight: 500; +} + +.YearRangePickerCellTrigger[data-highlighted] { + background-color: var(--grass-5); +} + +.YearRangePickerCellTrigger[data-unavailable] { + color: rgba(0, 0, 0, 0.3); + text-decoration: line-through; + pointer-events: none; +} + +.YearRangePickerCellTrigger::before { + content: ''; + position: absolute; + top: 0.25rem; + width: 0.25rem; + height: 0.25rem; + border-radius: 9999px; + display: none; +} + +.YearRangePickerCellTrigger[data-today]::before { + display: block; + background-color: var(--grass-9); +} + +.YearRangePickerCellTrigger[data-selected]::before { + background-color: #ffffff; +} diff --git a/docs/components/demo/YearRangePicker/tailwind/index.vue b/docs/components/demo/YearRangePicker/tailwind/index.vue new file mode 100644 index 0000000000..20c0d9c465 --- /dev/null +++ b/docs/components/demo/YearRangePicker/tailwind/index.vue @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components/examples/DatePickerViewSwitching/index.vue b/docs/components/examples/DatePickerViewSwitching/index.vue new file mode 100644 index 0000000000..06e3c6fc7f --- /dev/null +++ b/docs/components/examples/DatePickerViewSwitching/index.vue @@ -0,0 +1,342 @@ + + + + + + + + + + + + + {{ monthLabel }} + + + {{ yearLabel }} + + + + + + + + + + + + {{ day }} + + + + + + + + + + + + + + + + + + + + + + {{ monthLabel }} + + + {{ yearLabel }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ monthLabel }} + + + {{ yearLabel }} + + + + + + + + + + + + + + + + + + + diff --git a/docs/content/docs/components/month-picker.md b/docs/content/docs/components/month-picker.md new file mode 100644 index 0000000000..e724f9ff93 --- /dev/null +++ b/docs/content/docs/components/month-picker.md @@ -0,0 +1,285 @@ +--- + +title: MonthPicker +description: Presents a calendar view tailored for selecting months. +name: month-picker +--- + +# Month Picker + +Alpha + + +Presents a calendar view tailored for selecting months. + + + + +## Features + + + +## Preface + +The component depends on the [@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/index.html) package, which solves a lot of the problems that come with working with dates and times in JavaScript. + +We highly recommend reading through the documentation for the package to get a solid feel for how it works, and you'll need to install it in your project to use the date-related components. + +## Installation + +Install the date package. + + + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + + + + + + + + + + + + + + + + + + +``` + +## API Reference + +### Root + +Contains all the parts of a month picker + + + + + +### Header + +Contains the navigation buttons and the heading segments. + + + +### Prev Button + +Calendar navigation button. It navigates the calendar one year in the past. + + + + + +### Next Button + +Calendar navigation button. It navigates the calendar one year in the future. + + + + + +### Heading + +Heading for displaying the current year. + + + + + +### Grid + +Container for wrapping the month picker grid. + + + + + +### Grid Body + +Container for wrapping the grid body. + + + +### Grid Row + +Container for wrapping the grid row. + + + +### Cell + +Container for wrapping the month picker cells. + + + + + +### Cell Trigger + +Interactable container for displaying the cell months. Clicking it selects the month. + + + + + +## Accessibility + +### Keyboard Interactions + + diff --git a/docs/content/docs/components/month-range-picker.md b/docs/content/docs/components/month-range-picker.md new file mode 100644 index 0000000000..14be445b4c --- /dev/null +++ b/docs/content/docs/components/month-range-picker.md @@ -0,0 +1,309 @@ +--- + +title: MonthRangePicker +description: Presents a calendar view tailored for selecting month ranges. +name: month-range-picker +--- + +# Month Range Picker + +Alpha + + +Presents a calendar view tailored for selecting month ranges. + + + + +## Features + + + +## Preface + +The component depends on the [@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/index.html) package, which solves a lot of the problems that come with working with dates and times in JavaScript. + +We highly recommend reading through the documentation for the package to get a solid feel for how it works, and you'll need to install it in your project to use the date-related components. + +## Installation + +Install the date package. + + + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + + + + + + + + + + + + + + + + + + +``` + +## API Reference + +### Root + +Contains all the parts of a month range picker + + + + + +### Header + +Contains the navigation buttons and the heading segments. + + + +### Prev Button + +Calendar navigation button. It navigates the calendar one year in the past. + + + + + +### Next Button + +Calendar navigation button. It navigates the calendar one year in the future. + + + + + +### Heading + +Heading for displaying the current year. + + + + + +### Grid + +Container for wrapping the month range picker grid. + + + + + +### Grid Body + +Container for wrapping the grid body. + + + +### Grid Row + +Container for wrapping the grid row. + + + +### Cell + +Container for wrapping the month range picker cells. + + + + + +### Cell Trigger + +Interactable container for displaying the cell months. Clicking it selects the month. + + + + + +## Accessibility + +### Keyboard Interactions + + diff --git a/docs/content/docs/components/year-picker.md b/docs/content/docs/components/year-picker.md new file mode 100644 index 0000000000..c898fd33da --- /dev/null +++ b/docs/content/docs/components/year-picker.md @@ -0,0 +1,286 @@ +--- + +title: YearPicker +description: Presents a calendar view tailored for selecting years. +name: year-picker +--- + +# Year Picker + +Alpha + + +Presents a calendar view tailored for selecting years. + + + + +## Features + + + +## Preface + +The component depends on the [@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/index.html) package, which solves a lot of the problems that come with working with dates and times in JavaScript. + +We highly recommend reading through the documentation for the package to get a solid feel for how it works, and you'll need to install it in your project to use the date-related components. + +## Installation + +Install the date package. + + + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + + + + + + + + + + + + + + + + + + +``` + +## API Reference + +### Root + +Contains all the parts of a year picker + + + + + +### Header + +Contains the navigation buttons and the heading segments. + + + +### Prev Button + +Calendar navigation button. It navigates the calendar one page (default: 12 years) in the past. + + + + + +### Next Button + +Calendar navigation button. It navigates the calendar one page (default: 12 years) in the future. + + + + + +### Heading + +Heading for displaying the current year range (e.g., "2020 - 2031"). + + + + + +### Grid + +Container for wrapping the year picker grid. + + + + + +### Grid Body + +Container for wrapping the grid body. + + + +### Grid Row + +Container for wrapping the grid row. + + + +### Cell + +Container for wrapping the year picker cells. + + + + + +### Cell Trigger + +Interactable container for displaying the cell years. Clicking it selects the year. + + + + + +## Accessibility + +### Keyboard Interactions + + diff --git a/docs/content/docs/components/year-range-picker.md b/docs/content/docs/components/year-range-picker.md new file mode 100644 index 0000000000..09bf163d01 --- /dev/null +++ b/docs/content/docs/components/year-range-picker.md @@ -0,0 +1,309 @@ +--- + +title: YearRangePicker +description: Presents a calendar view tailored for selecting year ranges. +name: year-range-picker +--- + +# Year Range Picker + +Alpha + + +Presents a calendar view tailored for selecting year ranges. + + + + +## Features + + + +## Preface + +The component depends on the [@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/index.html) package, which solves a lot of the problems that come with working with dates and times in JavaScript. + +We highly recommend reading through the documentation for the package to get a solid feel for how it works, and you'll need to install it in your project to use the date-related components. + +## Installation + +Install the date package. + + + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + + + + + + + + + + + + + + + + + + +``` + +## API Reference + +### Root + +Contains all the parts of a year range picker + + + + + +### Header + +Contains the navigation buttons and the heading segments. + + + +### Prev Button + +Calendar navigation button. It navigates the calendar one page (12 years by default) in the past. + + + + + +### Next Button + +Calendar navigation button. It navigates the calendar one page (12 years by default) in the future. + + + + + +### Heading + +Heading for displaying the current year range. + + + + + +### Grid + +Container for wrapping the year range picker grid. + + + + + +### Grid Body + +Container for wrapping the grid body. + + + +### Grid Row + +Container for wrapping the grid row. + + + +### Cell + +Container for wrapping the year range picker cells. + + + + + +### Cell Trigger + +Interactable container for displaying the cell years. Clicking it selects the year. + + + + + +## Accessibility + +### Keyboard Interactions + + diff --git a/docs/content/examples/date-picker-view-switching.md b/docs/content/examples/date-picker-view-switching.md new file mode 100644 index 0000000000..b20c2e239c --- /dev/null +++ b/docs/content/examples/date-picker-view-switching.md @@ -0,0 +1,27 @@ +--- +title: Date Picker View Switching +tags: + - Calendar + - Month Picker + - Year Picker +--- + +# Date Picker View Switching + + + +Compose Calendar, MonthPicker, and YearPicker to create a date picker with drill-down view switching. + + + + + + + + + +### View Switching Pattern + +Click the month or year in the header to switch views. Selecting a month returns to day view, selecting a year returns to month view. Common pattern in native OS calendars and libraries like Mantine. + + diff --git a/docs/content/meta/MonthPickerCell.md b/docs/content/meta/MonthPickerCell.md new file mode 100644 index 0000000000..b8649ecb13 --- /dev/null +++ b/docs/content/meta/MonthPickerCell.md @@ -0,0 +1,23 @@ + + + diff --git a/docs/content/meta/MonthPickerCellTrigger.md b/docs/content/meta/MonthPickerCellTrigger.md new file mode 100644 index 0000000000..3faa3e370b --- /dev/null +++ b/docs/content/meta/MonthPickerCellTrigger.md @@ -0,0 +1,51 @@ + + + + + diff --git a/docs/content/meta/MonthPickerGrid.md b/docs/content/meta/MonthPickerGrid.md new file mode 100644 index 0000000000..61391feab0 --- /dev/null +++ b/docs/content/meta/MonthPickerGrid.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/MonthPickerGridBody.md b/docs/content/meta/MonthPickerGridBody.md new file mode 100644 index 0000000000..2500824059 --- /dev/null +++ b/docs/content/meta/MonthPickerGridBody.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/MonthPickerGridRow.md b/docs/content/meta/MonthPickerGridRow.md new file mode 100644 index 0000000000..bfc07e1195 --- /dev/null +++ b/docs/content/meta/MonthPickerGridRow.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/MonthPickerHeader.md b/docs/content/meta/MonthPickerHeader.md new file mode 100644 index 0000000000..5df63e84f6 --- /dev/null +++ b/docs/content/meta/MonthPickerHeader.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/MonthPickerHeading.md b/docs/content/meta/MonthPickerHeading.md new file mode 100644 index 0000000000..c3d2efbd64 --- /dev/null +++ b/docs/content/meta/MonthPickerHeading.md @@ -0,0 +1,25 @@ + + + + + diff --git a/docs/content/meta/MonthPickerNext.md b/docs/content/meta/MonthPickerNext.md new file mode 100644 index 0000000000..07f0c8eacc --- /dev/null +++ b/docs/content/meta/MonthPickerNext.md @@ -0,0 +1,31 @@ + + + + + diff --git a/docs/content/meta/MonthPickerPrev.md b/docs/content/meta/MonthPickerPrev.md new file mode 100644 index 0000000000..ab1b02dceb --- /dev/null +++ b/docs/content/meta/MonthPickerPrev.md @@ -0,0 +1,31 @@ + + + + + diff --git a/docs/content/meta/MonthPickerRoot.md b/docs/content/meta/MonthPickerRoot.md new file mode 100644 index 0000000000..77f242ac7b --- /dev/null +++ b/docs/content/meta/MonthPickerRoot.md @@ -0,0 +1,179 @@ + + + + + + + + + diff --git a/docs/content/meta/MonthRangePickerCell.md b/docs/content/meta/MonthRangePickerCell.md new file mode 100644 index 0000000000..b8649ecb13 --- /dev/null +++ b/docs/content/meta/MonthRangePickerCell.md @@ -0,0 +1,23 @@ + + + diff --git a/docs/content/meta/MonthRangePickerCellTrigger.md b/docs/content/meta/MonthRangePickerCellTrigger.md new file mode 100644 index 0000000000..fd00f57fb9 --- /dev/null +++ b/docs/content/meta/MonthRangePickerCellTrigger.md @@ -0,0 +1,76 @@ + + + + + diff --git a/docs/content/meta/MonthRangePickerGrid.md b/docs/content/meta/MonthRangePickerGrid.md new file mode 100644 index 0000000000..61391feab0 --- /dev/null +++ b/docs/content/meta/MonthRangePickerGrid.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/MonthRangePickerGridBody.md b/docs/content/meta/MonthRangePickerGridBody.md new file mode 100644 index 0000000000..2500824059 --- /dev/null +++ b/docs/content/meta/MonthRangePickerGridBody.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/MonthRangePickerGridRow.md b/docs/content/meta/MonthRangePickerGridRow.md new file mode 100644 index 0000000000..bfc07e1195 --- /dev/null +++ b/docs/content/meta/MonthRangePickerGridRow.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/MonthRangePickerHeader.md b/docs/content/meta/MonthRangePickerHeader.md new file mode 100644 index 0000000000..5df63e84f6 --- /dev/null +++ b/docs/content/meta/MonthRangePickerHeader.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/MonthRangePickerHeading.md b/docs/content/meta/MonthRangePickerHeading.md new file mode 100644 index 0000000000..c3d2efbd64 --- /dev/null +++ b/docs/content/meta/MonthRangePickerHeading.md @@ -0,0 +1,25 @@ + + + + + diff --git a/docs/content/meta/MonthRangePickerNext.md b/docs/content/meta/MonthRangePickerNext.md new file mode 100644 index 0000000000..5838b47ce0 --- /dev/null +++ b/docs/content/meta/MonthRangePickerNext.md @@ -0,0 +1,31 @@ + + + + + diff --git a/docs/content/meta/MonthRangePickerPrev.md b/docs/content/meta/MonthRangePickerPrev.md new file mode 100644 index 0000000000..1cd8ed9dcf --- /dev/null +++ b/docs/content/meta/MonthRangePickerPrev.md @@ -0,0 +1,31 @@ + + + + + diff --git a/docs/content/meta/MonthRangePickerRoot.md b/docs/content/meta/MonthRangePickerRoot.md new file mode 100644 index 0000000000..184e9695fd --- /dev/null +++ b/docs/content/meta/MonthRangePickerRoot.md @@ -0,0 +1,197 @@ + + + + + + + + + diff --git a/docs/content/meta/YearPickerCell.md b/docs/content/meta/YearPickerCell.md new file mode 100644 index 0000000000..b8649ecb13 --- /dev/null +++ b/docs/content/meta/YearPickerCell.md @@ -0,0 +1,23 @@ + + + diff --git a/docs/content/meta/YearPickerCellTrigger.md b/docs/content/meta/YearPickerCellTrigger.md new file mode 100644 index 0000000000..a9c040ae1c --- /dev/null +++ b/docs/content/meta/YearPickerCellTrigger.md @@ -0,0 +1,51 @@ + + + + + diff --git a/docs/content/meta/YearPickerGrid.md b/docs/content/meta/YearPickerGrid.md new file mode 100644 index 0000000000..61391feab0 --- /dev/null +++ b/docs/content/meta/YearPickerGrid.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/YearPickerGridBody.md b/docs/content/meta/YearPickerGridBody.md new file mode 100644 index 0000000000..2500824059 --- /dev/null +++ b/docs/content/meta/YearPickerGridBody.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/YearPickerGridRow.md b/docs/content/meta/YearPickerGridRow.md new file mode 100644 index 0000000000..bfc07e1195 --- /dev/null +++ b/docs/content/meta/YearPickerGridRow.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/YearPickerHeader.md b/docs/content/meta/YearPickerHeader.md new file mode 100644 index 0000000000..5df63e84f6 --- /dev/null +++ b/docs/content/meta/YearPickerHeader.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/YearPickerHeading.md b/docs/content/meta/YearPickerHeading.md new file mode 100644 index 0000000000..6fa583631d --- /dev/null +++ b/docs/content/meta/YearPickerHeading.md @@ -0,0 +1,25 @@ + + + + + diff --git a/docs/content/meta/YearPickerNext.md b/docs/content/meta/YearPickerNext.md new file mode 100644 index 0000000000..16d0a829b7 --- /dev/null +++ b/docs/content/meta/YearPickerNext.md @@ -0,0 +1,31 @@ + + + + + diff --git a/docs/content/meta/YearPickerPrev.md b/docs/content/meta/YearPickerPrev.md new file mode 100644 index 0000000000..5cdff064f9 --- /dev/null +++ b/docs/content/meta/YearPickerPrev.md @@ -0,0 +1,31 @@ + + + + + diff --git a/docs/content/meta/YearPickerRoot.md b/docs/content/meta/YearPickerRoot.md new file mode 100644 index 0000000000..5390c0980b --- /dev/null +++ b/docs/content/meta/YearPickerRoot.md @@ -0,0 +1,186 @@ + + + + + + + + + diff --git a/docs/content/meta/YearRangePickerCell.md b/docs/content/meta/YearRangePickerCell.md new file mode 100644 index 0000000000..b8649ecb13 --- /dev/null +++ b/docs/content/meta/YearRangePickerCell.md @@ -0,0 +1,23 @@ + + + diff --git a/docs/content/meta/YearRangePickerCellTrigger.md b/docs/content/meta/YearRangePickerCellTrigger.md new file mode 100644 index 0000000000..69b50f2180 --- /dev/null +++ b/docs/content/meta/YearRangePickerCellTrigger.md @@ -0,0 +1,76 @@ + + + + + diff --git a/docs/content/meta/YearRangePickerGrid.md b/docs/content/meta/YearRangePickerGrid.md new file mode 100644 index 0000000000..61391feab0 --- /dev/null +++ b/docs/content/meta/YearRangePickerGrid.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/YearRangePickerGridBody.md b/docs/content/meta/YearRangePickerGridBody.md new file mode 100644 index 0000000000..2500824059 --- /dev/null +++ b/docs/content/meta/YearRangePickerGridBody.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/YearRangePickerGridRow.md b/docs/content/meta/YearRangePickerGridRow.md new file mode 100644 index 0000000000..bfc07e1195 --- /dev/null +++ b/docs/content/meta/YearRangePickerGridRow.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/YearRangePickerHeader.md b/docs/content/meta/YearRangePickerHeader.md new file mode 100644 index 0000000000..5df63e84f6 --- /dev/null +++ b/docs/content/meta/YearRangePickerHeader.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/YearRangePickerHeading.md b/docs/content/meta/YearRangePickerHeading.md new file mode 100644 index 0000000000..6fa583631d --- /dev/null +++ b/docs/content/meta/YearRangePickerHeading.md @@ -0,0 +1,25 @@ + + + + + diff --git a/docs/content/meta/YearRangePickerNext.md b/docs/content/meta/YearRangePickerNext.md new file mode 100644 index 0000000000..5838b47ce0 --- /dev/null +++ b/docs/content/meta/YearRangePickerNext.md @@ -0,0 +1,31 @@ + + + + + diff --git a/docs/content/meta/YearRangePickerPrev.md b/docs/content/meta/YearRangePickerPrev.md new file mode 100644 index 0000000000..1cd8ed9dcf --- /dev/null +++ b/docs/content/meta/YearRangePickerPrev.md @@ -0,0 +1,31 @@ + + + + + diff --git a/docs/content/meta/YearRangePickerRoot.md b/docs/content/meta/YearRangePickerRoot.md new file mode 100644 index 0000000000..057e606404 --- /dev/null +++ b/docs/content/meta/YearRangePickerRoot.md @@ -0,0 +1,204 @@ + + + + + + + + + diff --git a/docs/scripts/autogen.ts b/docs/scripts/autogen.ts index 3535e903d4..33e19ab75d 100644 --- a/docs/scripts/autogen.ts +++ b/docs/scripts/autogen.ts @@ -31,6 +31,12 @@ const eventDescriptionMap = new Map() const depTree = new Map() let prevDeps: string[] = [] +function toSingleQuotedJson(obj: unknown): string { + return JSON.stringify(obj, null, 2) + .replace(/'/g, '\\\'') + .replace(/"/g, '\'') +} + const allComponents = fg.sync(['src/**/*.vue', '!src/**/story/*.vue', '!src/**/*.story.vue'], { cwd: resolve(__dirname, '../../packages/core'), absolute: true, @@ -62,19 +68,11 @@ primitiveComponents.forEach((componentPath) => { const meta = parseMeta(tsconfigChecker.getComponentMeta(componentPath)) const metaDirPath = resolve(__dirname, '../content/meta') - // if meta dir doesn't exist create mkdirSync(metaDirPath, { recursive: true }) const metaMdFilePath = join(metaDirPath, `${componentName}.md`) - // Convert JSON to single-quoted format safely by escaping existing single quotes first - function toSingleQuotedJson(obj: unknown): string { - return JSON.stringify(obj, null, 2) - .replace(/'/g, '\\\'') // Escape existing single quotes - .replace(/"/g, '\'') // Then convert double quotes to single - } - - let parsedString = '\n\n' + let parsedString = '\n\n' if (meta.props.length) parsedString += `\n` diff --git a/packages/core/constant/components.ts b/packages/core/constant/components.ts index 8a82a11111..baf7c1846d 100644 --- a/packages/core/constant/components.ts +++ b/packages/core/constant/components.ts @@ -232,6 +232,32 @@ export const components = { 'MenubarMenu', ] as const, + monthPicker: [ + 'MonthPickerRoot', + 'MonthPickerHeader', + 'MonthPickerHeading', + 'MonthPickerGrid', + 'MonthPickerCell', + 'MonthPickerNext', + 'MonthPickerPrev', + 'MonthPickerGridBody', + 'MonthPickerGridRow', + 'MonthPickerCellTrigger', + ] as const, + + monthRangePicker: [ + 'MonthRangePickerRoot', + 'MonthRangePickerHeader', + 'MonthRangePickerHeading', + 'MonthRangePickerGrid', + 'MonthRangePickerCell', + 'MonthRangePickerNext', + 'MonthRangePickerPrev', + 'MonthRangePickerGridBody', + 'MonthRangePickerGridRow', + 'MonthRangePickerCellTrigger', + ] as const, + navigationMenu: [ 'NavigationMenuRoot', 'NavigationMenuContent', @@ -432,6 +458,32 @@ export const components = { 'Viewport', ] as const, + yearPicker: [ + 'YearPickerRoot', + 'YearPickerHeader', + 'YearPickerHeading', + 'YearPickerGrid', + 'YearPickerCell', + 'YearPickerNext', + 'YearPickerPrev', + 'YearPickerGridBody', + 'YearPickerGridRow', + 'YearPickerCellTrigger', + ] as const, + + yearRangePicker: [ + 'YearRangePickerRoot', + 'YearRangePickerHeader', + 'YearRangePickerHeading', + 'YearRangePickerGrid', + 'YearRangePickerCell', + 'YearRangePickerNext', + 'YearRangePickerPrev', + 'YearRangePickerGridBody', + 'YearRangePickerGridRow', + 'YearRangePickerCellTrigger', + ] as const, + // Utility component configProvider: [ 'ConfigProvider', diff --git a/packages/core/src/Calendar/CalendarCellTrigger.vue b/packages/core/src/Calendar/CalendarCellTrigger.vue index 492887b77b..11323aaffc 100644 --- a/packages/core/src/Calendar/CalendarCellTrigger.vue +++ b/packages/core/src/Calendar/CalendarCellTrigger.vue @@ -171,7 +171,8 @@ function handleArrowKey(e: KeyboardEvent) { () const disabled = computed(() => rootContext.disabled.value || rootContext.isNextButtonDisabled(props.nextPage)) const rootContext = injectCalendarRootContext() + +function handleClick() { + if (disabled.value) + return + rootContext.nextPage(props.nextPage) +} @@ -33,11 +39,11 @@ const rootContext = injectCalendarRootContext() :as="props.as" :as-child="props.asChild" aria-label="Next page" - :type="as === 'button' ? 'button' : undefined" + :type="props.as === 'button' ? 'button' : undefined" :aria-disabled="disabled || undefined" :data-disabled="disabled || undefined" :disabled="disabled" - @click="rootContext.nextPage(props.nextPage)" + @click="handleClick" > Next page diff --git a/packages/core/src/Calendar/CalendarPrev.vue b/packages/core/src/Calendar/CalendarPrev.vue index e67e1d1bd9..8c895c3252 100644 --- a/packages/core/src/Calendar/CalendarPrev.vue +++ b/packages/core/src/Calendar/CalendarPrev.vue @@ -26,6 +26,12 @@ defineSlots() const disabled = computed(() => rootContext.disabled.value || rootContext.isPrevButtonDisabled(props.prevPage)) const rootContext = injectCalendarRootContext() + +function handleClick() { + if (disabled.value) + return + rootContext.prevPage(props.prevPage) +} @@ -33,11 +39,11 @@ const rootContext = injectCalendarRootContext() aria-label="Previous page" :as="props.as" :as-child="props.asChild" - :type="as === 'button' ? 'button' : undefined" + :type="props.as === 'button' ? 'button' : undefined" :aria-disabled="disabled || undefined" :data-disabled="disabled || undefined" :disabled="disabled" - @click="rootContext.prevPage(props.prevPage)" + @click="handleClick" > Prev page diff --git a/packages/core/src/MonthPicker/MonthPicker.test.ts b/packages/core/src/MonthPicker/MonthPicker.test.ts new file mode 100644 index 0000000000..071c68d9c0 --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPicker.test.ts @@ -0,0 +1,405 @@ +import type { DateValue } from '@internationalized/date' +import type { MonthPickerRootProps } from './MonthPickerRoot.vue' +import { CalendarDate, CalendarDateTime, toZoned } from '@internationalized/date' +import userEvent from '@testing-library/user-event' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import { useTestKbd } from '@/shared' +import { MonthPickerHeader, MonthPickerHeading, MonthPickerNext, MonthPickerPrev, MonthPickerRoot } from '..' +import MonthPicker from './story/_MonthPicker.vue' + +const calendarDate = new CalendarDate(1980, 1, 20) +const calendarDateTime = new CalendarDateTime(1980, 1, 20, 12, 30, 0, 0) +const zonedDateTime = toZoned(calendarDateTime, 'America/New_York') + +const kbd = useTestKbd() + +function setup(props: { pickerProps?: MonthPickerRootProps, emits?: { 'onUpdate:modelValue'?: (data: DateValue | DateValue[] | undefined) => void } } = {}) { + const user = userEvent.setup() + const returned = render(MonthPicker, { props }) + const picker = returned.getByTestId('month-picker') + expect(picker).toBeVisible() + return { ...returned, user, picker } +} + +function getSelectedMonth(picker: HTMLElement) { + return picker.querySelector('[data-selected]') as HTMLElement +} + +function getSelectedMonths(picker: HTMLElement) { + return Array.from(picker.querySelectorAll('[data-selected]')) +} + +it('should pass axe accessibility tests', async () => { + const { picker } = setup() + expect(await axe(picker)).toHaveNoViolations() +}) + +describe('month picker', async () => { + it('does not forward month prop as a DOM attribute', async () => { + const { getByTestId } = setup({ pickerProps: { placeholder: calendarDate } }) + const janMonth = getByTestId('month-1') + expect(janMonth.getAttribute('month')).toBe(null) + }) + + it('does not navigate when prev is disabled and rendered as div', async () => { + const user = userEvent.setup() + + const Test = { + components: { + MonthPickerRoot, + MonthPickerHeader, + MonthPickerPrev, + MonthPickerHeading, + MonthPickerNext, + }, + setup() { + const placeholder = new CalendarDate(1980, 1, 1) + const minValue = new CalendarDate(1980, 1, 1) + return { placeholder, minValue } + }, + template: ` + + + + + + + + `, + } + + const { getByTestId } = render(Test) + expect(getByTestId('heading')).toHaveTextContent('1980') + await user.click(getByTestId('prev-button')) + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('respects a default value if provided - `CalendarDate`', async () => { + const { getByTestId, picker } = setup({ pickerProps: { modelValue: calendarDate } }) + expect(getSelectedMonth(picker)).toHaveTextContent('Jan') + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('respects a default value if provided - `CalendarDateTime`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: calendarDateTime } }) + expect(getSelectedMonth(picker)).toHaveTextContent('Jan') + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('respects a default value if provided - `ZonedDateTime`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: zonedDateTime } }) + expect(getSelectedMonth(picker)).toHaveTextContent('Jan') + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('navigates to next year using next button', async () => { + const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDate } }) + + const heading = getByTestId('heading') + const nextBtn = getByTestId('next-button') + + expect(heading).toHaveTextContent('1980') + await user.click(nextBtn) + expect(heading).toHaveTextContent('1981') + await user.click(nextBtn) + expect(heading).toHaveTextContent('1982') + }) + + it('navigates to prev year using prev button', async () => { + const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDate } }) + + const heading = getByTestId('heading') + const prevBtn = getByTestId('prev-button') + + expect(heading).toHaveTextContent('1980') + await user.click(prevBtn) + expect(heading).toHaveTextContent('1979') + await user.click(prevBtn) + expect(heading).toHaveTextContent('1978') + }) + + it('allows months to be deselected by clicking the selected month', async () => { + const { user, picker, rerender } = setup({ + pickerProps: { modelValue: calendarDate }, + emits: { 'onUpdate:modelValue': (data: DateValue) => rerender({ pickerProps: { modelValue: data } }) }, + }) + + const selectedMonth = getSelectedMonth(picker) + expect(selectedMonth).toHaveTextContent('Jan') + await user.click(selectedMonth) + expect(getSelectedMonth(picker)).toBe(null) + }) + + it.each([kbd.ENTER, kbd.SPACE])('allows deselection with %s key', async (key) => { + const { user, picker, rerender } = setup({ + pickerProps: { modelValue: calendarDate }, + emits: { 'onUpdate:modelValue': (data: DateValue) => rerender({ pickerProps: { modelValue: data } }) }, + }) + + const selectedMonth = getSelectedMonth(picker) + expect(selectedMonth).toHaveTextContent('Jan') + selectedMonth!.focus() + await user.keyboard(key) + expect(getSelectedMonth(picker)).toBe(null) + }) + + it('allows selection with mouse', async () => { + const { getByTestId, user, picker } = setup({ + pickerProps: { placeholder: zonedDateTime }, + }) + + const marchMonth = getByTestId('month-3') + expect(marchMonth).toHaveTextContent('Mar') + await user.click(marchMonth) + + const selectedMonth = getSelectedMonth(picker) + expect(selectedMonth).toHaveTextContent('Mar') + }) + + it.each([kbd.ENTER, kbd.SPACE])('allows selection with %s key', async (key) => { + const { getByTestId, user, picker } = setup({ + pickerProps: { placeholder: zonedDateTime }, + }) + + const marchMonth = getByTestId('month-3') + marchMonth.focus() + await user.keyboard(key) + + const selectedMonth = getSelectedMonth(picker) + expect(selectedMonth).toHaveTextContent('Mar') + }) + + it('should not allow navigation before the `minValue` (prev button)', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + modelValue: calendarDate, + minValue: new CalendarDate(1979, 6, 1), + }, + }) + + const prevBtn = getByTestId('prev-button') + await user.click(prevBtn) + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1979') + expect(prevBtn).toHaveAttribute('aria-disabled', 'true') + expect(prevBtn).toHaveAttribute('data-disabled') + + await user.click(prevBtn) + expect(heading).toHaveTextContent('1979') + }) + + it('should not allow navigation after the `maxValue` (next button)', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + modelValue: calendarDate, + maxValue: new CalendarDate(1981, 6, 30), + }, + }) + + const nextBtn = getByTestId('next-button') + await user.click(nextBtn) + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1981') + expect(nextBtn).toHaveAttribute('aria-disabled', 'true') + expect(nextBtn).toHaveAttribute('data-disabled') + + await user.click(nextBtn) + expect(heading).toHaveTextContent('1981') + }) + + it('handles unavailable months appropriately', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + isMonthUnavailable: (date: DateValue) => { + return date.month === 3 + }, + }, + }) + + const marchMonth = getByTestId('month-3') + expect(marchMonth).toHaveTextContent('Mar') + expect(marchMonth).toHaveAttribute('data-unavailable') + expect(marchMonth).toHaveAttribute('aria-disabled', 'true') + await user.click(marchMonth) + expect(marchMonth).not.toHaveAttribute('data-selected') + }) + + it('handles disabled months appropriately', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + isMonthDisabled: (date: DateValue) => { + return date.month === 3 + }, + }, + }) + + const marchMonth = getByTestId('month-3') + expect(marchMonth).toHaveTextContent('Mar') + expect(marchMonth).toHaveAttribute('data-disabled') + expect(marchMonth).toHaveAttribute('aria-disabled', 'true') + await user.click(marchMonth) + expect(marchMonth).not.toHaveAttribute('data-selected') + }) + + it('doesnt allow focus or interaction when `disabled` is `true`', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + disabled: true, + }, + }) + + const grid = getByTestId('grid') + expect(grid).toHaveAttribute('aria-disabled', 'true') + expect(grid).toHaveAttribute('data-disabled') + + const janMonth = getByTestId('month-1') + expect(janMonth).toHaveAttribute('aria-disabled', 'true') + expect(janMonth).toHaveAttribute('data-disabled') + + await user.click(janMonth) + expect(janMonth).not.toHaveAttribute('data-selected') + janMonth.focus() + expect(janMonth).not.toHaveFocus() + + const prevButton = getByTestId('prev-button') + const nextButton = getByTestId('next-button') + expect(prevButton).toBeDisabled() + expect(nextButton).toBeDisabled() + }) + + it('prevents selection but allows focus when `readonly` is `true`', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + readonly: true, + }, + }) + + const grid = getByTestId('grid') + expect(grid).toHaveAttribute('aria-readonly', 'true') + expect(grid).toHaveAttribute('data-readonly') + + const janMonth = getByTestId('month-1') + await user.click(janMonth) + expect(janMonth).not.toHaveAttribute('data-selected') + janMonth.focus() + expect(janMonth).toHaveFocus() + }) +}) + +describe('month picker - keyboard navigation', () => { + it('navigates with arrow keys within the grid', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDate }, + }) + + const janMonth = getByTestId('month-1') + janMonth.focus() + expect(janMonth).toHaveFocus() + + await user.keyboard(kbd.ARROW_RIGHT) + expect(getByTestId('month-2')).toHaveFocus() + + await user.keyboard(kbd.ARROW_DOWN) + expect(getByTestId('month-6')).toHaveFocus() + + await user.keyboard(kbd.ARROW_LEFT) + expect(getByTestId('month-5')).toHaveFocus() + + await user.keyboard(kbd.ARROW_UP) + expect(getByTestId('month-1')).toHaveFocus() + }) + + it('navigates to next/prev year with PageDown/PageUp', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDate }, + }) + + const janMonth = getByTestId('month-1') + janMonth.focus() + expect(janMonth).toHaveFocus() + expect(getByTestId('heading')).toHaveTextContent('1980') + + await user.keyboard(kbd.PAGE_DOWN) + expect(getByTestId('heading')).toHaveTextContent('1981') + + await user.keyboard(kbd.PAGE_UP) + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('wraps around months when navigating past boundaries', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDate }, + }) + + const decMonth = getByTestId('month-12') + decMonth.focus() + expect(decMonth).toHaveFocus() + + await user.keyboard(kbd.ARROW_RIGHT) + expect(getByTestId('heading')).toHaveTextContent('1981') + expect(getByTestId('month-1')).toHaveFocus() + }) +}) + +describe('month picker - multiple', () => { + it('handles multiple selection', async () => { + const d1 = new CalendarDate(1980, 1, 1) + const d2 = new CalendarDate(1980, 3, 1) + + const { picker, getByTestId, user, rerender } = setup({ + pickerProps: { modelValue: [d1, d2], multiple: true }, + emits: { 'onUpdate:modelValue': (data: DateValue) => rerender({ pickerProps: { modelValue: data as any, multiple: true } }) }, + } as any) + + const selectedMonths = getSelectedMonths(picker) + expect(selectedMonths.length).toBe(2) + + const mayMonth = getByTestId('month-5') + await user.click(mayMonth) + + expect(getSelectedMonths(picker).length).toBe(3) + }) + + it('allows deselection in multiple mode', async () => { + const d1 = new CalendarDate(1980, 1, 1) + const d2 = new CalendarDate(1980, 3, 1) + + const { picker, user, rerender } = setup({ + pickerProps: { modelValue: [d1, d2], multiple: true }, + emits: { 'onUpdate:modelValue': (data: DateValue) => rerender({ pickerProps: { modelValue: data as any, multiple: true } }) }, + } as any) + + const selectedMonths = getSelectedMonths(picker) + expect(selectedMonths.length).toBe(2) + + await user.click(selectedMonths[0]) + expect(getSelectedMonths(picker).length).toBe(1) + }) + + it('normalizes single modelValue when multiple is true', async () => { + const d1 = new CalendarDate(1980, 1, 1) + + const { picker, getByTestId, user, rerender } = setup({ + pickerProps: { modelValue: d1, multiple: true }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data as any, multiple: true } }) }, + }) + + expect(getSelectedMonths(picker)).toHaveLength(1) + expect(getByTestId('month-1')).toHaveAttribute('data-selected') + + await user.click(getByTestId('month-5')) + + expect(getSelectedMonths(picker)).toHaveLength(2) + expect(getByTestId('month-1')).toHaveAttribute('data-selected') + expect(getByTestId('month-5')).toHaveAttribute('data-selected') + }) +}) diff --git a/packages/core/src/MonthPicker/MonthPickerCell.vue b/packages/core/src/MonthPicker/MonthPickerCell.vue new file mode 100644 index 0000000000..0c9899ba3f --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerCell.vue @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/packages/core/src/MonthPicker/MonthPickerCellTrigger.vue b/packages/core/src/MonthPicker/MonthPickerCellTrigger.vue new file mode 100644 index 0000000000..aa67141701 --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerCellTrigger.vue @@ -0,0 +1,214 @@ + + + + + + + + {{ shortMonthValue }} + + + diff --git a/packages/core/src/MonthPicker/MonthPickerGrid.vue b/packages/core/src/MonthPicker/MonthPickerGrid.vue new file mode 100644 index 0000000000..1011cacffb --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerGrid.vue @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/packages/core/src/MonthPicker/MonthPickerGridBody.vue b/packages/core/src/MonthPicker/MonthPickerGridBody.vue new file mode 100644 index 0000000000..9ce4813422 --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerGridBody.vue @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/packages/core/src/MonthPicker/MonthPickerGridRow.vue b/packages/core/src/MonthPicker/MonthPickerGridRow.vue new file mode 100644 index 0000000000..a0734e62d5 --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerGridRow.vue @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/core/src/MonthPicker/MonthPickerHeader.vue b/packages/core/src/MonthPicker/MonthPickerHeader.vue new file mode 100644 index 0000000000..267f7cbc8e --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerHeader.vue @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/packages/core/src/MonthPicker/MonthPickerHeading.vue b/packages/core/src/MonthPicker/MonthPickerHeading.vue new file mode 100644 index 0000000000..e534212dc8 --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerHeading.vue @@ -0,0 +1,35 @@ + + + + + + + + {{ rootContext.headingValue.value }} + + + diff --git a/packages/core/src/MonthPicker/MonthPickerNext.vue b/packages/core/src/MonthPicker/MonthPickerNext.vue new file mode 100644 index 0000000000..49edcb9062 --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerNext.vue @@ -0,0 +1,52 @@ + + + + + + + + Next year + + + diff --git a/packages/core/src/MonthPicker/MonthPickerPrev.vue b/packages/core/src/MonthPicker/MonthPickerPrev.vue new file mode 100644 index 0000000000..b61b3289e4 --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerPrev.vue @@ -0,0 +1,52 @@ + + + + + + + + Prev year + + + diff --git a/packages/core/src/MonthPicker/MonthPickerRoot.vue b/packages/core/src/MonthPicker/MonthPickerRoot.vue new file mode 100644 index 0000000000..ff2c3fbb7e --- /dev/null +++ b/packages/core/src/MonthPicker/MonthPickerRoot.vue @@ -0,0 +1,311 @@ + + + + + + + + + + {{ fullCalendarLabel }} + + + + diff --git a/packages/core/src/MonthPicker/index.ts b/packages/core/src/MonthPicker/index.ts new file mode 100644 index 0000000000..dc9bcb118d --- /dev/null +++ b/packages/core/src/MonthPicker/index.ts @@ -0,0 +1,42 @@ +export { + default as MonthPickerCell, + type MonthPickerCellProps, +} from './MonthPickerCell.vue' +export { + default as MonthPickerCellTrigger, + type MonthPickerCellTriggerProps, +} from './MonthPickerCellTrigger.vue' +export { + default as MonthPickerGrid, + type MonthPickerGridProps, +} from './MonthPickerGrid.vue' +export { + default as MonthPickerGridBody, + type MonthPickerGridBodyProps, +} from './MonthPickerGridBody.vue' +export { + default as MonthPickerGridRow, + type MonthPickerGridRowProps, +} from './MonthPickerGridRow.vue' +export { + default as MonthPickerHeader, + type MonthPickerHeaderProps, +} from './MonthPickerHeader.vue' +export { + default as MonthPickerHeading, + type MonthPickerHeadingProps, +} from './MonthPickerHeading.vue' +export { + default as MonthPickerNext, + type MonthPickerNextProps, +} from './MonthPickerNext.vue' +export { + default as MonthPickerPrev, + type MonthPickerPrevProps, +} from './MonthPickerPrev.vue' +export { + injectMonthPickerRootContext, + default as MonthPickerRoot, + type MonthPickerRootEmits, + type MonthPickerRootProps, +} from './MonthPickerRoot.vue' diff --git a/packages/core/src/MonthPicker/story/MonthPickerDefault.story.vue b/packages/core/src/MonthPicker/story/MonthPickerDefault.story.vue new file mode 100644 index 0000000000..312bab083a --- /dev/null +++ b/packages/core/src/MonthPicker/story/MonthPickerDefault.story.vue @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/MonthPicker/story/_MonthPicker.vue b/packages/core/src/MonthPicker/story/_MonthPicker.vue new file mode 100644 index 0000000000..b8204d782c --- /dev/null +++ b/packages/core/src/MonthPicker/story/_MonthPicker.vue @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/MonthPicker/useMonthPicker.ts b/packages/core/src/MonthPicker/useMonthPicker.ts new file mode 100644 index 0000000000..fcc78d2838 --- /dev/null +++ b/packages/core/src/MonthPicker/useMonthPicker.ts @@ -0,0 +1,193 @@ +import type { DateValue } from '@internationalized/date' +import type { Ref } from 'vue' +import type { Grid, Matcher } from '@/date' +import type { DateFormatterOptions } from '@/shared/useDateFormatter' +import { endOfMonth } from '@internationalized/date' +import { computed, ref, watch } from 'vue' +import { createMonthGrid, isAfter, isBefore, isSameYearMonth, toDate } from '@/date' +import { useDateFormatter } from '@/shared' + +export type UseMonthPickerProps = { + locale: Ref + placeholder: Ref + minValue: Ref + maxValue: Ref + disabled: Ref + isMonthDisabled?: Matcher | Ref + isMonthUnavailable?: Matcher | Ref + calendarLabel: Ref + nextPage: Ref<((placeholder: DateValue) => DateValue) | undefined> + prevPage: Ref<((placeholder: DateValue) => DateValue) | undefined> +} + +export type UseMonthPickerStateProps = { + isMonthDisabled: Matcher + isMonthUnavailable: Matcher + date: Ref +} + +export function useMonthPickerState(props: UseMonthPickerStateProps) { + function isMonthSelected(dateObj: DateValue) { + if (Array.isArray(props.date.value)) + return props.date.value.some(d => isSameYearMonth(d, dateObj)) + else if (!props.date.value) + return false + else + return isSameYearMonth(props.date.value, dateObj) + } + + const isInvalid = computed(() => { + if (Array.isArray(props.date.value)) { + if (!props.date.value.length) + return false + for (const dateObj of props.date.value) { + if (props.isMonthDisabled?.(dateObj)) + return true + if (props.isMonthUnavailable?.(dateObj)) + return true + } + } + else { + if (!props.date.value) + return false + if (props.isMonthDisabled?.(props.date.value)) + return true + if (props.isMonthUnavailable?.(props.date.value)) + return true + } + return false + }) + + return { isMonthSelected, isInvalid } +} + +export function useMonthPicker(props: UseMonthPickerProps) { + const formatter = useDateFormatter(props.locale.value) + + const resolveMatcher = (matcher?: Matcher | Ref) => + typeof matcher === 'function' ? matcher : matcher?.value + + const headingFormatOptions = computed(() => { + const options: DateFormatterOptions = { + calendar: props.placeholder.value.calendar.identifier, + } + + if (props.placeholder.value.calendar.identifier === 'gregory' && props.placeholder.value.era === 'BC') + options.era = 'short' + + return options + }) + + const grid = ref>(createMonthGrid({ dateObj: props.placeholder.value })) as Ref> + + function isMonthDisabled(dateObj: DateValue) { + if (resolveMatcher(props.isMonthDisabled)?.(dateObj) || props.disabled.value) + return true + if (props.maxValue.value && isAfter(dateObj.set({ day: 1 }), props.maxValue.value)) + return true + if (props.minValue.value && isBefore(endOfMonth(dateObj), props.minValue.value)) + return true + return false + } + + const isMonthUnavailable = (date: DateValue) => { + if (resolveMatcher(props.isMonthUnavailable)?.(date)) + return true + return false + } + + const isNextButtonDisabled = (nextPageFunc?: (date: DateValue) => DateValue) => { + if (!props.maxValue.value) + return false + if (props.disabled.value) + return true + + const currentDate = grid.value.value + if (nextPageFunc || props.nextPage.value) { + const nextDate = (nextPageFunc || props.nextPage.value)!(currentDate) + return isAfter(nextDate.set({ month: 1, day: 1 }), props.maxValue.value) + } + + const nextYear = currentDate.add({ years: 1 }).set({ month: 1, day: 1 }) + return isAfter(nextYear, props.maxValue.value) + } + + const isPrevButtonDisabled = (prevPageFunc?: (date: DateValue) => DateValue) => { + if (!props.minValue.value) + return false + if (props.disabled.value) + return true + + const currentDate = grid.value.value + if (prevPageFunc || props.prevPage.value) { + const prevDate = (prevPageFunc || props.prevPage.value)!(currentDate) + return isBefore(endOfMonth(prevDate.set({ month: 12 })), props.minValue.value) + } + + const prevYear = currentDate.subtract({ years: 1 }).set({ month: 12, day: 31 }) + return isBefore(prevYear, props.minValue.value) + } + + const nextPage = (nextPageFunc?: (date: DateValue) => DateValue) => { + const currentDate = grid.value.value + + if (nextPageFunc || props.nextPage.value) { + const newDate = (nextPageFunc || props.nextPage.value)!(currentDate) + grid.value = createMonthGrid({ dateObj: newDate }) + props.placeholder.value = newDate.set({ day: 1 }) + return + } + + const newDate = currentDate.add({ years: 1 }) + grid.value = createMonthGrid({ dateObj: newDate }) + props.placeholder.value = newDate.set({ day: 1 }) + } + + const prevPage = (prevPageFunc?: (date: DateValue) => DateValue) => { + const currentDate = grid.value.value + + if (prevPageFunc || props.prevPage.value) { + const newDate = (prevPageFunc || props.prevPage.value)!(currentDate) + grid.value = createMonthGrid({ dateObj: newDate }) + props.placeholder.value = newDate.set({ day: 1 }) + return + } + + const newDate = currentDate.subtract({ years: 1 }) + grid.value = createMonthGrid({ dateObj: newDate }) + props.placeholder.value = newDate.set({ day: 1 }) + } + + watch(props.placeholder, (value) => { + if (value.year === grid.value.value.year) + return + grid.value = createMonthGrid({ dateObj: value }) + }) + + watch(props.locale, () => { + formatter.setLocale(props.locale.value) + grid.value = createMonthGrid({ dateObj: props.placeholder.value }) + }) + + const headingValue = computed(() => { + if (props.locale.value !== formatter.getLocale()) + formatter.setLocale(props.locale.value) + + return formatter.fullYear(toDate(grid.value.value), headingFormatOptions.value) + }) + + const fullCalendarLabel = computed(() => `${props.calendarLabel.value ?? 'Month Picker'}, ${headingValue.value}`) + + return { + isMonthDisabled, + isMonthUnavailable, + isNextButtonDisabled, + isPrevButtonDisabled, + grid, + formatter, + nextPage, + prevPage, + headingValue, + fullCalendarLabel, + } +} diff --git a/packages/core/src/MonthRangePicker/MonthRangePicker.test.ts b/packages/core/src/MonthRangePicker/MonthRangePicker.test.ts new file mode 100644 index 0000000000..172936592a --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePicker.test.ts @@ -0,0 +1,480 @@ +import type { DateValue } from '@internationalized/date' +import type { MonthRangePickerRootProps } from './MonthRangePickerRoot.vue' +import type { DateRange } from '@/shared/date' +import { CalendarDate, CalendarDateTime, toZoned } from '@internationalized/date' +import userEvent from '@testing-library/user-event' +import { render } from '@testing-library/vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import { useTestKbd } from '@/shared' +import MonthRangePicker from './story/_MonthRangePicker.vue' + +const calendarDateRange = { + start: new CalendarDate(1980, 1, 20), + end: new CalendarDate(1980, 3, 25), +} + +const updatedCalendarDateRange = { + start: new CalendarDate(1980, 4, 5), + end: new CalendarDate(1980, 6, 5), +} + +const calendarDateTimeRange = { + start: new CalendarDateTime(1980, 1, 20, 12, 30, 0, 0), + end: new CalendarDateTime(1980, 3, 25, 12, 30, 0, 0), +} + +const zonedDateTimeRange = { + start: toZoned(calendarDateTimeRange.start, 'America/New_York'), + end: toZoned(calendarDateTimeRange.end, 'America/New_York'), +} + +const kbd = useTestKbd() + +function setup(props: { pickerProps?: MonthRangePickerRootProps, emits?: { 'onUpdate:modelValue'?: (data: DateRange) => void } } = {}) { + const user = userEvent.setup() + const returned = render(MonthRangePicker, { props }) + const picker = returned.getByTestId('month-range-picker') + expect(picker).toBeVisible() + return { ...returned, user, picker } +} + +function getSelectedMonths(picker: HTMLElement) { + return Array.from(picker.querySelectorAll('[data-selected]')) +} + +it('should pass axe accessibility tests', async () => { + const wrapper = mount(MonthRangePicker) + expect(await axe(wrapper.element)).toHaveNoViolations() +}) + +describe('month range picker', () => { + it('respects a default value if provided - `CalendarDate`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: calendarDateRange } }) + const selectedMonths = getSelectedMonths(picker) + expect(selectedMonths).toHaveLength(3) + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('respects a default value if provided - `CalendarDateTime`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: calendarDateTimeRange } }) + const selectedMonths = getSelectedMonths(picker) + expect(selectedMonths).toHaveLength(3) + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('respects a default value if provided - `ZonedDateTime`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: zonedDateTimeRange } }) + const selectedMonths = getSelectedMonths(picker) + expect(selectedMonths).toHaveLength(3) + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('does not crash when modelValue is null', async () => { + const { picker, rerender } = setup({ pickerProps: { modelValue: null } }) + + expect(getSelectedMonths(picker)).toHaveLength(0) + + await rerender({ + pickerProps: { + modelValue: calendarDateRange, + }, + }) + + expect(getSelectedMonths(picker)).toHaveLength(3) + }) + + it('resets range on select when a range is already selected', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { modelValue: calendarDateRange }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + let startValue = picker.querySelector('[data-selection-start]') + let endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toHaveTextContent('Jan') + expect(endValue).toHaveTextContent('Mar') + + const mayMonth = getByTestId('month-5') + await user.click(mayMonth) + + const selectedMonths = getSelectedMonths(picker) + expect(selectedMonths).toHaveLength(1) + + startValue = picker.querySelector('[data-selection-start]') + endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toBeInTheDocument() + expect(endValue).not.toBeInTheDocument() + + const julyMonth = getByTestId('month-7') + await user.click(julyMonth) + expect(getSelectedMonths(picker)).toHaveLength(3) + }) + + it('keeps controlled end when parent preserves it after start edit', async () => { + const preservedEnd = new CalendarDate(1980, 8, 1) + const controlledRange = { + start: new CalendarDate(1980, 1, 1), + end: preservedEnd, + } + + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { modelValue: controlledRange }, + emits: { + 'onUpdate:modelValue': (data) => { + rerender({ + pickerProps: { + modelValue: { + start: data.start ?? controlledRange.start, + end: data.end ?? preservedEnd, + }, + }, + }) + }, + }, + }) + + const aprilMonth = getByTestId('month-4') + await user.click(aprilMonth) + + expect(getByTestId('month-4')).toHaveAttribute('data-selection-start') + expect(getByTestId('month-8')).toHaveAttribute('data-selection-end') + expect(getByTestId('month-5')).toHaveAttribute('data-selected') + expect(getByTestId('month-7')).toHaveAttribute('data-selected') + expect(getSelectedMonths(picker)).toHaveLength(5) + }) + + it('allows same month selection', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { placeholder: calendarDateRange.start }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + const janMonth = getByTestId('month-1') + await user.click(janMonth) + await user.click(janMonth) + + expect(getSelectedMonths(picker)).toHaveLength(1) + expect(picker.querySelector('[data-selection-start]')).toBeInTheDocument() + expect(picker.querySelector('[data-selection-end]')).toBeInTheDocument() + }) + + it('allows deselection', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { placeholder: calendarDateRange.start }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + const janMonth = getByTestId('month-1') + await user.click(janMonth) + await user.click(janMonth) + + expect(getSelectedMonths(picker)).toHaveLength(1) + + await user.click(janMonth) + expect(getSelectedMonths(picker)).toHaveLength(0) + }) + + it('resets range selection when pressing Escape', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { modelValue: calendarDateRange }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + let startValue = picker.querySelector('[data-selection-start]') + let endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toHaveTextContent('Jan') + expect(endValue).toHaveTextContent('Mar') + + const mayMonth = getByTestId('month-5') + await user.click(mayMonth) + + const selectedMonths = getSelectedMonths(picker) + expect(selectedMonths).toHaveLength(1) + + await user.keyboard(kbd.ESCAPE) + + startValue = picker.querySelector('[data-selection-start]') + endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toHaveTextContent('Jan') + expect(endValue).toHaveTextContent('Mar') + }) + + it('resets to latest externally controlled complete range when pressing Escape', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { modelValue: calendarDateRange }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + await rerender({ pickerProps: { modelValue: updatedCalendarDateRange } }) + + let startValue = picker.querySelector('[data-selection-start]') + let endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toHaveTextContent('Apr') + expect(endValue).toHaveTextContent('Jun') + + await user.click(getByTestId('month-2')) + expect(getSelectedMonths(picker)).toHaveLength(1) + + await user.keyboard(kbd.ESCAPE) + + startValue = picker.querySelector('[data-selection-start]') + endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toHaveTextContent('Apr') + expect(endValue).toHaveTextContent('Jun') + }) + + it('navigates years forward using the next button', async () => { + const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDateRange } }) + + const heading = getByTestId('heading') + const nextBtn = getByTestId('next-button') + + expect(heading).toHaveTextContent('1980') + await user.click(nextBtn) + expect(heading).toHaveTextContent('1981') + }) + + it('navigates years backwards using the prev button', async () => { + const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDateRange } }) + + const heading = getByTestId('heading') + const prevBtn = getByTestId('prev-button') + + expect(heading).toHaveTextContent('1980') + await user.click(prevBtn) + expect(heading).toHaveTextContent('1979') + }) + + it('handles fixedDate with start correctly', async () => { + const { getByTestId, picker, user } = setup({ + pickerProps: { + defaultValue: calendarDateRange, + fixedDate: 'start', + }, + }) + + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1980') + + const mayMonth = getByTestId('month-5') + await user.click(mayMonth) + + expect(getByTestId('month-4')).toHaveAttribute('data-selected') + expect(getByTestId('month-1')).toHaveAttribute('data-selection-start') + expect(getByTestId('month-5')).toHaveAttribute('data-selection-end') + }) + + it('handles fixedDate with end correctly', async () => { + const { getByTestId, picker, user } = setup({ + pickerProps: { + defaultValue: calendarDateRange, + fixedDate: 'end', + }, + }) + + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1980') + + const mayMonth = getByTestId('month-5') + await user.click(mayMonth) + + expect(getByTestId('month-4')).toHaveAttribute('data-selected') + expect(getByTestId('month-1')).toHaveAttribute('data-selection-start') + expect(getByTestId('month-5')).toHaveAttribute('data-selection-end') + + const febMonth = getByTestId('month-2') + await user.click(febMonth) + expect(getByTestId('month-2')).toHaveAttribute('data-selection-start') + expect(getByTestId('month-5')).toHaveAttribute('data-selection-end') + }) + + it('allows non-contiguous ranges', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDateRange.start, + allowNonContiguousRanges: true, + isMonthUnavailable: (date: DateValue) => { + return date.month === 3 + }, + }, + }) + + const janMonth = getByTestId('month-1') + const aprilMonth = getByTestId('month-4') + const mayMonth = getByTestId('month-5') + await user.click(janMonth) + await user.click(mayMonth) + expect(aprilMonth).toHaveAttribute('data-selected') + }) +}) + +describe('month range picker - maximumMonths', () => { + it('limits the maximum number of months that can be selected', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: new CalendarDate(1980, 3, 15), + maximumMonths: 3, + }, + }) + + const marchMonth = getByTestId('month-3') + await user.click(marchMonth) + expect(marchMonth).toHaveAttribute('data-selection-start') + + const juneMonth = getByTestId('month-6') + await user.click(juneMonth) + + expect(juneMonth).toHaveAttribute('data-disabled') + expect(juneMonth).not.toHaveAttribute('data-selected') + + const mayMonth = getByTestId('month-5') + expect(mayMonth).not.toHaveAttribute('data-disabled') + + await user.click(mayMonth) + expect(getByTestId('month-3')).toHaveAttribute('data-selected') + expect(getByTestId('month-4')).toHaveAttribute('data-selected') + expect(getByTestId('month-5')).toHaveAttribute('data-selected') + }) + + it('highlights backwards within maximumMonths without inverting', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: new CalendarDate(1980, 3, 15), + maximumMonths: 3, + }, + }) + + const marchMonth = getByTestId('month-3') + await user.click(marchMonth) + expect(marchMonth).toHaveAttribute('data-selection-start') + + const janMonth = getByTestId('month-1') + await user.hover(janMonth) + + expect(janMonth).toHaveAttribute('data-highlighted-start') + expect(getByTestId('month-2')).toHaveAttribute('data-highlighted') + expect(marchMonth).toHaveAttribute('data-highlighted-end') + expect(getByTestId('month-4')).not.toHaveAttribute('data-highlighted') + }) + + it('enforces maximumMonths for out-of-bounds controlled ranges with fixedDate="start"', async () => { + const outOfBoundsRange = { + start: new CalendarDate(1980, 1, 1), + end: new CalendarDate(1980, 6, 1), + } + + const { getByTestId, user, rerender } = setup({ + pickerProps: { + modelValue: outOfBoundsRange, + fixedDate: 'start', + maximumMonths: 3, + }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data, fixedDate: 'start', maximumMonths: 3 } }) }, + }) + + expect(getByTestId('month-5')).toHaveAttribute('data-disabled') + + await user.click(getByTestId('month-5')) + expect(getByTestId('month-6')).toHaveAttribute('data-selection-end') + + await user.click(getByTestId('month-3')) + expect(getByTestId('month-1')).toHaveAttribute('data-selection-start') + expect(getByTestId('month-2')).toHaveAttribute('data-selected') + expect(getByTestId('month-3')).toHaveAttribute('data-selection-end') + expect(getByTestId('month-4')).not.toHaveAttribute('data-selected') + }) + + it('enforces maximumMonths for out-of-bounds controlled ranges with fixedDate="end"', async () => { + const outOfBoundsRange = { + start: new CalendarDate(1980, 1, 1), + end: new CalendarDate(1980, 6, 1), + } + + const { getByTestId, user, rerender } = setup({ + pickerProps: { + modelValue: outOfBoundsRange, + fixedDate: 'end', + maximumMonths: 3, + }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data, fixedDate: 'end', maximumMonths: 3 } }) }, + }) + + expect(getByTestId('month-2')).toHaveAttribute('data-disabled') + + await user.click(getByTestId('month-2')) + expect(getByTestId('month-1')).toHaveAttribute('data-selection-start') + + await user.click(getByTestId('month-4')) + expect(getByTestId('month-4')).toHaveAttribute('data-selection-start') + expect(getByTestId('month-5')).toHaveAttribute('data-selected') + expect(getByTestId('month-6')).toHaveAttribute('data-selection-end') + expect(getByTestId('month-3')).not.toHaveAttribute('data-selected') + }) +}) + +describe('month range picker - keyboard navigation', () => { + it('navigates with arrow keys within the grid', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDateRange.start }, + }) + + const janMonth = getByTestId('month-1') + janMonth.focus() + expect(janMonth).toHaveFocus() + + await user.keyboard(kbd.ARROW_RIGHT) + expect(getByTestId('month-2')).toHaveFocus() + + await user.keyboard(kbd.ARROW_DOWN) + expect(getByTestId('month-6')).toHaveFocus() + + await user.keyboard(kbd.ARROW_LEFT) + expect(getByTestId('month-5')).toHaveFocus() + + await user.keyboard(kbd.ARROW_UP) + expect(getByTestId('month-1')).toHaveFocus() + }) + + it('navigates to next/prev year with PageDown/PageUp', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDateRange.start }, + }) + + const janMonth = getByTestId('month-1') + janMonth.focus() + expect(janMonth).toHaveFocus() + expect(getByTestId('heading')).toHaveTextContent('1980') + + await user.keyboard(kbd.PAGE_DOWN) + expect(getByTestId('heading')).toHaveTextContent('1981') + + await user.keyboard(kbd.PAGE_UP) + expect(getByTestId('heading')).toHaveTextContent('1980') + }) + + it('skips disabled candidate month when paging by year', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDateRange.start, + isMonthDisabled: (date: DateValue) => date.year === 1981 && date.month === 1, + }, + }) + + const janMonth = getByTestId('month-1') + janMonth.focus() + expect(janMonth).toHaveFocus() + + await user.keyboard(kbd.PAGE_DOWN) + expect(getByTestId('heading')).toHaveTextContent('1981') + expect(getByTestId('month-1')).toHaveAttribute('data-disabled') + expect(getByTestId('month-2')).toHaveFocus() + }) +}) diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerCell.vue b/packages/core/src/MonthRangePicker/MonthRangePickerCell.vue new file mode 100644 index 0000000000..6ac54d0a64 --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerCell.vue @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerCellTrigger.vue b/packages/core/src/MonthRangePicker/MonthRangePickerCellTrigger.vue new file mode 100644 index 0000000000..4d1f602a3d --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerCellTrigger.vue @@ -0,0 +1,309 @@ + + + + + + + + {{ shortMonthValue }} + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerGrid.vue b/packages/core/src/MonthRangePicker/MonthRangePickerGrid.vue new file mode 100644 index 0000000000..d367b65abe --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerGrid.vue @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerGridBody.vue b/packages/core/src/MonthRangePicker/MonthRangePickerGridBody.vue new file mode 100644 index 0000000000..2815542de4 --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerGridBody.vue @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerGridRow.vue b/packages/core/src/MonthRangePicker/MonthRangePickerGridRow.vue new file mode 100644 index 0000000000..734f4ed6b1 --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerGridRow.vue @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerHeader.vue b/packages/core/src/MonthRangePicker/MonthRangePickerHeader.vue new file mode 100644 index 0000000000..5fa523fd03 --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerHeader.vue @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerHeading.vue b/packages/core/src/MonthRangePicker/MonthRangePickerHeading.vue new file mode 100644 index 0000000000..59a5b5762a --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerHeading.vue @@ -0,0 +1,35 @@ + + + + + + + + {{ rootContext.headingValue.value }} + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerNext.vue b/packages/core/src/MonthRangePicker/MonthRangePickerNext.vue new file mode 100644 index 0000000000..ee223f6ac7 --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerNext.vue @@ -0,0 +1,52 @@ + + + + + + + + Next year + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerPrev.vue b/packages/core/src/MonthRangePicker/MonthRangePickerPrev.vue new file mode 100644 index 0000000000..86a74eb9e9 --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerPrev.vue @@ -0,0 +1,52 @@ + + + + + + + + Prev year + + + diff --git a/packages/core/src/MonthRangePicker/MonthRangePickerRoot.vue b/packages/core/src/MonthRangePicker/MonthRangePickerRoot.vue new file mode 100644 index 0000000000..fc5cf62f70 --- /dev/null +++ b/packages/core/src/MonthRangePicker/MonthRangePickerRoot.vue @@ -0,0 +1,391 @@ + + + + + + + + + {{ fullCalendarLabel }} + + + + + + diff --git a/packages/core/src/MonthRangePicker/index.ts b/packages/core/src/MonthRangePicker/index.ts new file mode 100644 index 0000000000..6a3ac32684 --- /dev/null +++ b/packages/core/src/MonthRangePicker/index.ts @@ -0,0 +1,42 @@ +export { + default as MonthRangePickerCell, + type MonthRangePickerCellProps, +} from './MonthRangePickerCell.vue' +export { + default as MonthRangePickerCellTrigger, + type MonthRangePickerCellTriggerProps, +} from './MonthRangePickerCellTrigger.vue' +export { + default as MonthRangePickerGrid, + type MonthRangePickerGridProps, +} from './MonthRangePickerGrid.vue' +export { + default as MonthRangePickerGridBody, + type MonthRangePickerGridBodyProps, +} from './MonthRangePickerGridBody.vue' +export { + default as MonthRangePickerGridRow, + type MonthRangePickerGridRowProps, +} from './MonthRangePickerGridRow.vue' +export { + default as MonthRangePickerHeader, + type MonthRangePickerHeaderProps, +} from './MonthRangePickerHeader.vue' +export { + default as MonthRangePickerHeading, + type MonthRangePickerHeadingProps, +} from './MonthRangePickerHeading.vue' +export { + default as MonthRangePickerNext, + type MonthRangePickerNextProps, +} from './MonthRangePickerNext.vue' +export { + default as MonthRangePickerPrev, + type MonthRangePickerPrevProps, +} from './MonthRangePickerPrev.vue' +export { + injectMonthRangePickerRootContext, + default as MonthRangePickerRoot, + type MonthRangePickerRootEmits, + type MonthRangePickerRootProps, +} from './MonthRangePickerRoot.vue' diff --git a/packages/core/src/MonthRangePicker/story/MonthRangePickerDefault.story.vue b/packages/core/src/MonthRangePicker/story/MonthRangePickerDefault.story.vue new file mode 100644 index 0000000000..05ed59b44f --- /dev/null +++ b/packages/core/src/MonthRangePicker/story/MonthRangePickerDefault.story.vue @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/packages/core/src/MonthRangePicker/story/_MonthRangePicker.vue b/packages/core/src/MonthRangePicker/story/_MonthRangePicker.vue new file mode 100644 index 0000000000..4f3679de65 --- /dev/null +++ b/packages/core/src/MonthRangePicker/story/_MonthRangePicker.vue @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/MonthRangePicker/useRangeMonthPicker.ts b/packages/core/src/MonthRangePicker/useRangeMonthPicker.ts new file mode 100644 index 0000000000..3b747fc6dd --- /dev/null +++ b/packages/core/src/MonthRangePicker/useRangeMonthPicker.ts @@ -0,0 +1,165 @@ +import type { DateValue } from '@internationalized/date' +import type { Ref } from 'vue' +import type { Matcher } from '@/date' +import { computed } from 'vue' +import { areAllMonthsBetweenValid, compareYearMonth, getMonthsBetween, isSameYearMonth } from '@/date' + +export type UseRangeMonthPickerProps = { + start: Ref + end: Ref + isMonthDisabled: Matcher + isMonthUnavailable: Matcher + focusedValue: Ref + allowNonContiguousRanges: Ref + fixedDate: Ref<'start' | 'end' | undefined> + maximumMonths?: Ref +} + +export function useRangeMonthPickerState(props: UseRangeMonthPickerProps) { + const isStartInvalid = computed(() => { + if (!props.start.value) + return false + if (props.isMonthDisabled(props.start.value)) + return true + return false + }) + + const isEndInvalid = computed(() => { + if (!props.end.value) + return false + if (props.isMonthDisabled(props.end.value)) + return true + return false + }) + + const isInvalid = computed(() => { + if (isStartInvalid.value || isEndInvalid.value) + return true + if (props.start.value && props.end.value && compareYearMonth(props.end.value, props.start.value) < 0) + return true + return false + }) + + const isSelectionStart = (date: DateValue) => { + if (!props.start.value) + return false + return isSameYearMonth(props.start.value, date) + } + + const isSelectionEnd = (date: DateValue) => { + if (!props.end.value) + return false + return isSameYearMonth(props.end.value, date) + } + + const isSelected = (date: DateValue) => { + if (props.start.value && isSameYearMonth(props.start.value, date)) + return true + if (props.end.value && isSameYearMonth(props.end.value, date)) + return true + if (props.end.value && props.start.value) { + return compareYearMonth(date, props.start.value) > 0 && compareYearMonth(date, props.end.value) < 0 + } + return false + } + + const rangeIsMonthDisabled = (date: DateValue) => { + if (props.isMonthDisabled(date)) + return true + + if (props.maximumMonths?.value) { + const maximumMonths = props.maximumMonths.value + + if (props.start.value && props.end.value) { + if (props.fixedDate.value) { + const diff = getMonthsBetween(props.start.value, props.end.value) + if (diff <= maximumMonths) { + const monthsLeft = maximumMonths - diff + const startLimit = props.start.value.subtract({ months: monthsLeft }) + const endLimit = props.end.value.add({ months: monthsLeft }) + return compareYearMonth(date, startLimit) < 0 || compareYearMonth(date, endLimit) > 0 + } + + const fixedValue = props.fixedDate.value === 'start' ? props.start.value : props.end.value + const maxDate = fixedValue.add({ months: maximumMonths - 1 }) + const minDate = fixedValue.subtract({ months: maximumMonths - 1 }) + return compareYearMonth(date, minDate) < 0 || compareYearMonth(date, maxDate) > 0 + } + return false + } + if (props.start.value) { + const maxDate = props.start.value.add({ months: maximumMonths - 1 }) + const minDate = props.start.value.subtract({ months: maximumMonths - 1 }) + return compareYearMonth(date, minDate) < 0 || compareYearMonth(date, maxDate) > 0 + } + } + + return false + } + + const highlightedRange = computed(() => { + if (props.start.value && props.end.value && !props.fixedDate.value) + return null + if (!props.start.value || !props.focusedValue.value) + return null + + const isStartBeforeFocused = compareYearMonth(props.start.value, props.focusedValue.value) < 0 + const start = isStartBeforeFocused ? props.start.value : props.focusedValue.value + const end = isStartBeforeFocused ? props.focusedValue.value : props.start.value + + if (isSameYearMonth(start, end)) { + return { start, end } + } + + if (props.maximumMonths?.value && !props.end.value) { + const maximumMonths = props.maximumMonths.value + const anchor = props.start.value + const focused = props.focusedValue.value + + if (compareYearMonth(focused, anchor) >= 0) { + const maxEnd = anchor.add({ months: maximumMonths - 1 }) + const cappedEnd = compareYearMonth(focused, maxEnd) > 0 ? maxEnd : focused + return { start: anchor, end: cappedEnd } + } + else { + const minStart = anchor.subtract({ months: maximumMonths - 1 }) + const cappedStart = compareYearMonth(focused, minStart) < 0 ? minStart : focused + return { start: cappedStart, end: anchor } + } + } + + const isValid = areAllMonthsBetweenValid( + start, + end, + props.allowNonContiguousRanges.value ? () => false : props.isMonthUnavailable, + rangeIsMonthDisabled, + ) + if (isValid) + return { start, end } + + return null + }) + + const isHighlightedStart = (date: DateValue) => { + if (!highlightedRange.value?.start) + return false + return isSameYearMonth(highlightedRange.value.start, date) + } + + const isHighlightedEnd = (date: DateValue) => { + if (!highlightedRange.value?.end) + return false + return isSameYearMonth(highlightedRange.value.end, date) + } + + return { + isInvalid, + isSelected, + highlightedRange, + isSelectionStart, + isSelectionEnd, + isHighlightedStart, + isHighlightedEnd, + isMonthDisabled: rangeIsMonthDisabled, + } +} diff --git a/packages/core/src/RangeCalendar/RangeCalendar.test.ts b/packages/core/src/RangeCalendar/RangeCalendar.test.ts index f7cc822ffc..9f1c3d00ef 100644 --- a/packages/core/src/RangeCalendar/RangeCalendar.test.ts +++ b/packages/core/src/RangeCalendar/RangeCalendar.test.ts @@ -930,6 +930,31 @@ describe('handles maximumDays', () => { expect(beyondMaximumDay).not.toHaveAttribute('data-highlighted') }) + it('keeps backward highlight and disabled boundaries coherent with maximumDays', async () => { + const { getByTestId, user } = setup({ + calendarProps: { + placeholder: new CalendarDate(1980, 1, 10), + maximumDays: 3, + }, + }) + + const startDay = getByTestId('date-1-10') + const day8 = getByTestId('date-1-8') + const day7 = getByTestId('date-1-7') + + await user.click(startDay) + expect(startDay).toHaveAttribute('data-selection-start') + expect(day7).toHaveAttribute('aria-disabled', 'true') + expect(day8).not.toHaveAttribute('aria-disabled', 'true') + + await user.hover(day8) + + expect(day8).toHaveAttribute('data-highlighted-start') + expect(getByTestId('date-1-9')).toHaveAttribute('data-highlighted') + expect(startDay).toHaveAttribute('data-highlighted-end') + expect(day7).not.toHaveAttribute('data-highlighted') + }) + describe('a11y', async () => { it('should pass axe accessibility tests when closed', async () => { const { calendar } = setup({ diff --git a/packages/core/src/RangeCalendar/RangeCalendarNext.vue b/packages/core/src/RangeCalendar/RangeCalendarNext.vue index ccd5577db1..4cfebbf3b4 100644 --- a/packages/core/src/RangeCalendar/RangeCalendarNext.vue +++ b/packages/core/src/RangeCalendar/RangeCalendarNext.vue @@ -26,18 +26,24 @@ defineSlots() const disabled = computed(() => rootContext.disabled.value || rootContext.isNextButtonDisabled(props.nextPage)) const rootContext = injectRangeCalendarRootContext() + +function handleClick() { + if (disabled.value) + return + rootContext.nextPage(props.nextPage) +} Next page diff --git a/packages/core/src/RangeCalendar/RangeCalendarPrev.vue b/packages/core/src/RangeCalendar/RangeCalendarPrev.vue index 47f658c569..90171e9272 100644 --- a/packages/core/src/RangeCalendar/RangeCalendarPrev.vue +++ b/packages/core/src/RangeCalendar/RangeCalendarPrev.vue @@ -26,18 +26,24 @@ defineSlots() const disabled = computed(() => rootContext.disabled.value || rootContext.isPrevButtonDisabled(props.prevPage)) const rootContext = injectRangeCalendarRootContext() + +function handleClick() { + if (disabled.value) + return + rootContext.prevPage(props.prevPage) +} Prev page diff --git a/packages/core/src/YearPicker/YearPicker.test.ts b/packages/core/src/YearPicker/YearPicker.test.ts new file mode 100644 index 0000000000..e6e351d2f4 --- /dev/null +++ b/packages/core/src/YearPicker/YearPicker.test.ts @@ -0,0 +1,410 @@ +import type { DateValue } from '@internationalized/date' +import type { YearPickerRootProps } from './YearPickerRoot.vue' +import { CalendarDate, CalendarDateTime, toZoned } from '@internationalized/date' +import userEvent from '@testing-library/user-event' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import { useTestKbd } from '@/shared' +import YearPicker from './story/_YearPicker.vue' + +const calendarDate = new CalendarDate(1980, 1, 20) +const calendarDateTime = new CalendarDateTime(1980, 1, 20, 12, 30, 0, 0) +const zonedDateTime = toZoned(calendarDateTime, 'America/New_York') + +const kbd = useTestKbd() + +function setup(props: { pickerProps?: YearPickerRootProps, emits?: { 'onUpdate:modelValue'?: (data: DateValue) => void } } = {}) { + const user = userEvent.setup() + const returned = render(YearPicker, { props }) + const picker = returned.getByTestId('year-picker') + expect(picker).toBeVisible() + return { ...returned, user, picker } +} + +function getSelectedYear(picker: HTMLElement) { + return picker.querySelector('[data-selected]') as HTMLElement +} + +function getSelectedYears(picker: HTMLElement) { + return Array.from(picker.querySelectorAll('[data-selected]')) +} + +it('should pass axe accessibility tests', async () => { + const { picker } = setup() + expect(await axe(picker)).toHaveNoViolations() +}) + +describe('year picker', async () => { + it('respects a default value if provided - `CalendarDate`', async () => { + const { getByTestId, picker } = setup({ pickerProps: { modelValue: calendarDate } }) + expect(getSelectedYear(picker)).toHaveTextContent('1980') + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + }) + + it('respects a default value if provided - `CalendarDateTime`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: calendarDateTime } }) + expect(getSelectedYear(picker)).toHaveTextContent('1980') + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + }) + + it('respects a default value if provided - `ZonedDateTime`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: zonedDateTime } }) + expect(getSelectedYear(picker)).toHaveTextContent('1980') + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + }) + + it('navigates to next decade using next button', async () => { + const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDate } }) + + const heading = getByTestId('heading') + const nextBtn = getByTestId('next-button') + + expect(heading).toHaveTextContent('1980 - 1991') + await user.click(nextBtn) + expect(heading).toHaveTextContent('1992 - 2003') + await user.click(nextBtn) + expect(heading).toHaveTextContent('2004 - 2015') + }) + + it('navigates to prev decade using prev button', async () => { + const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDate } }) + + const heading = getByTestId('heading') + const prevBtn = getByTestId('prev-button') + + expect(heading).toHaveTextContent('1980 - 1991') + await user.click(prevBtn) + expect(heading).toHaveTextContent('1968 - 1979') + await user.click(prevBtn) + expect(heading).toHaveTextContent('1956 - 1967') + }) + + it('allows years to be deselected by clicking the selected year', async () => { + const { user, picker, rerender } = setup({ + pickerProps: { modelValue: calendarDate }, + emits: { 'onUpdate:modelValue': (data: DateValue) => rerender({ pickerProps: { modelValue: data } }) }, + }) + + const selectedYear = getSelectedYear(picker) + expect(selectedYear).toHaveTextContent('1980') + await user.click(selectedYear) + expect(getSelectedYear(picker)).toBe(null) + }) + + it.each([kbd.ENTER, kbd.SPACE])('allows deselection with %s key', async (key) => { + const { user, picker, rerender } = setup({ + pickerProps: { modelValue: calendarDate }, + emits: { 'onUpdate:modelValue': (data: DateValue) => rerender({ pickerProps: { modelValue: data } }) }, + }) + + const selectedYear = getSelectedYear(picker) + expect(selectedYear).toHaveTextContent('1980') + selectedYear!.focus() + await user.keyboard(key) + expect(getSelectedYear(picker)).toBe(null) + }) + + it('allows selection with mouse', async () => { + const { getByTestId, user, picker } = setup({ + pickerProps: { placeholder: zonedDateTime }, + }) + + const year1985 = getByTestId('year-1985') + expect(year1985).toHaveTextContent('1985') + await user.click(year1985) + + const selectedYear = getSelectedYear(picker) + expect(selectedYear).toHaveTextContent('1985') + }) + + it.each([kbd.ENTER, kbd.SPACE])('allows selection with %s key', async (key) => { + const { getByTestId, user, picker } = setup({ + pickerProps: { placeholder: zonedDateTime }, + }) + + const year1985 = getByTestId('year-1985') + year1985.focus() + await user.keyboard(key) + + const selectedYear = getSelectedYear(picker) + expect(selectedYear).toHaveTextContent('1985') + }) + + it('should not allow navigation before the `minValue` (prev button)', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + modelValue: calendarDate, + minValue: new CalendarDate(1980, 1, 1), + }, + }) + + const prevBtn = getByTestId('prev-button') + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1980 - 1991') + expect(prevBtn).toHaveAttribute('aria-disabled', 'true') + expect(prevBtn).toHaveAttribute('data-disabled') + + await user.click(prevBtn) + expect(heading).toHaveTextContent('1980 - 1991') + }) + + it('should not allow navigation after the `maxValue` (next button)', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + modelValue: calendarDate, + maxValue: new CalendarDate(1991, 12, 31), + }, + }) + + const nextBtn = getByTestId('next-button') + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1980 - 1991') + expect(nextBtn).toHaveAttribute('aria-disabled', 'true') + expect(nextBtn).toHaveAttribute('data-disabled') + + await user.click(nextBtn) + expect(heading).toHaveTextContent('1980 - 1991') + }) + + it('handles unavailable years appropriately', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + isYearUnavailable: (date: DateValue) => { + return date.year === 1985 + }, + }, + }) + + const year1985 = getByTestId('year-1985') + expect(year1985).toHaveTextContent('1985') + expect(year1985).toHaveAttribute('data-unavailable') + expect(year1985).toHaveAttribute('aria-disabled', 'true') + await user.click(year1985) + expect(year1985).not.toHaveAttribute('data-selected') + }) + + it('handles disabled years appropriately', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + isYearDisabled: (date: DateValue) => { + return date.year === 1985 + }, + }, + }) + + const year1985 = getByTestId('year-1985') + expect(year1985).toHaveTextContent('1985') + expect(year1985).toHaveAttribute('data-disabled') + expect(year1985).toHaveAttribute('aria-disabled', 'true') + await user.click(year1985) + expect(year1985).not.toHaveAttribute('data-selected') + }) + + it('doesnt allow focus or interaction when `disabled` is `true`', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + disabled: true, + }, + }) + + const grid = getByTestId('grid') + expect(grid).toHaveAttribute('aria-disabled', 'true') + expect(grid).toHaveAttribute('data-disabled') + + const year1980 = getByTestId('year-1980') + expect(year1980).toHaveAttribute('aria-disabled', 'true') + expect(year1980).toHaveAttribute('data-disabled') + + await user.click(year1980) + expect(year1980).not.toHaveAttribute('data-selected') + year1980.focus() + expect(year1980).not.toHaveFocus() + + const prevButton = getByTestId('prev-button') + const nextButton = getByTestId('next-button') + expect(prevButton).toBeDisabled() + expect(nextButton).toBeDisabled() + }) + + it('prevents selection but allows focus when `readonly` is `true`', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + readonly: true, + }, + }) + + const grid = getByTestId('grid') + expect(grid).toHaveAttribute('aria-readonly', 'true') + expect(grid).toHaveAttribute('data-readonly') + + const year1980 = getByTestId('year-1980') + await user.click(year1980) + expect(year1980).not.toHaveAttribute('data-selected') + year1980.focus() + expect(year1980).toHaveFocus() + }) +}) + +describe('year picker - keyboard navigation', () => { + it('navigates with arrow keys within the grid', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDate }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + + await user.keyboard(kbd.ARROW_RIGHT) + expect(getByTestId('year-1981')).toHaveFocus() + + await user.keyboard(kbd.ARROW_DOWN) + expect(getByTestId('year-1985')).toHaveFocus() + + await user.keyboard(kbd.ARROW_LEFT) + expect(getByTestId('year-1984')).toHaveFocus() + + await user.keyboard(kbd.ARROW_UP) + expect(getByTestId('year-1980')).toHaveFocus() + }) + + it('navigates to next/prev page with PageDown/PageUp', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDate }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + + await user.keyboard(kbd.PAGE_DOWN) + expect(getByTestId('heading')).toHaveTextContent('1992 - 2003') + + await user.keyboard(kbd.PAGE_UP) + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + }) + + it('skips disabled candidate year when navigating to next page', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + isYearDisabled: (date: DateValue) => date.year === 1992, + }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + + await user.keyboard(kbd.PAGE_DOWN) + expect(getByTestId('heading')).toHaveTextContent('1992 - 2003') + expect(getByTestId('year-1992')).toHaveAttribute('data-disabled') + expect(getByTestId('year-1993')).toHaveFocus() + }) + + it('falls back to the nearest enabled year when paged candidate is missing on PageDown', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + nextPage: date => date.add({ years: 13 }), + }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + + await user.keyboard(kbd.PAGE_DOWN) + + expect(getByTestId('heading')).toHaveTextContent('1993 - 2004') + expect(getByTestId('year-1993')).toHaveFocus() + }) + + it('falls back to the nearest enabled year when paged candidate is missing on PageUp', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDate, + prevPage: date => date.subtract({ years: 24 }), + }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + + await user.keyboard(kbd.PAGE_UP) + + expect(getByTestId('heading')).toHaveTextContent('1956 - 1967') + expect(getByTestId('year-1967')).toHaveFocus() + }) + + it('wraps around years when navigating past boundaries', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDate }, + }) + + const year1991 = getByTestId('year-1991') + year1991.focus() + expect(year1991).toHaveFocus() + + await user.keyboard(kbd.ARROW_RIGHT) + expect(getByTestId('heading')).toHaveTextContent('1992 - 2003') + expect(getByTestId('year-1992')).toHaveFocus() + }) +}) + +describe('year picker - multiple', () => { + it('handles multiple selection', async () => { + const d1 = new CalendarDate(1980, 1, 1) + const d2 = new CalendarDate(1985, 1, 1) + + const { picker, getByTestId, user, rerender } = setup({ + pickerProps: { modelValue: [d1, d2], multiple: true }, + emits: { 'onUpdate:modelValue': (data: DateValue) => rerender({ pickerProps: { modelValue: data as any, multiple: true } }) }, + } as any) + + const selectedYears = getSelectedYears(picker) + expect(selectedYears.length).toBe(2) + + const year1988 = getByTestId('year-1988') + await user.click(year1988) + + expect(getSelectedYears(picker).length).toBe(3) + }) + + it('allows deselection in multiple mode', async () => { + const d1 = new CalendarDate(1980, 1, 1) + const d2 = new CalendarDate(1985, 1, 1) + + const { picker, user, rerender } = setup({ + pickerProps: { modelValue: [d1, d2], multiple: true }, + emits: { 'onUpdate:modelValue': (data: DateValue) => rerender({ pickerProps: { modelValue: data as any, multiple: true } }) }, + } as any) + + const selectedYears = getSelectedYears(picker) + expect(selectedYears.length).toBe(2) + + await user.click(selectedYears[0]) + expect(getSelectedYears(picker).length).toBe(1) + }) +}) + +describe('year picker - yearsPerPage', () => { + it('respects custom yearsPerPage prop', async () => { + const { getByTestId } = setup({ + pickerProps: { + placeholder: calendarDate, + yearsPerPage: 16, + }, + }) + + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1980 - 1995') + }) +}) diff --git a/packages/core/src/YearPicker/YearPickerCell.vue b/packages/core/src/YearPicker/YearPickerCell.vue new file mode 100644 index 0000000000..e34b084f15 --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerCell.vue @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/packages/core/src/YearPicker/YearPickerCellTrigger.vue b/packages/core/src/YearPicker/YearPickerCellTrigger.vue new file mode 100644 index 0000000000..9431e19bee --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerCellTrigger.vue @@ -0,0 +1,218 @@ + + + + + + + + {{ yearValue }} + + + diff --git a/packages/core/src/YearPicker/YearPickerGrid.vue b/packages/core/src/YearPicker/YearPickerGrid.vue new file mode 100644 index 0000000000..8604b30382 --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerGrid.vue @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/packages/core/src/YearPicker/YearPickerGridBody.vue b/packages/core/src/YearPicker/YearPickerGridBody.vue new file mode 100644 index 0000000000..ba442eee34 --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerGridBody.vue @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/packages/core/src/YearPicker/YearPickerGridRow.vue b/packages/core/src/YearPicker/YearPickerGridRow.vue new file mode 100644 index 0000000000..265063feb4 --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerGridRow.vue @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/core/src/YearPicker/YearPickerHeader.vue b/packages/core/src/YearPicker/YearPickerHeader.vue new file mode 100644 index 0000000000..328d1dfeba --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerHeader.vue @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/packages/core/src/YearPicker/YearPickerHeading.vue b/packages/core/src/YearPicker/YearPickerHeading.vue new file mode 100644 index 0000000000..4285fe44c1 --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerHeading.vue @@ -0,0 +1,35 @@ + + + + + + + + {{ rootContext.headingValue.value }} + + + diff --git a/packages/core/src/YearPicker/YearPickerNext.vue b/packages/core/src/YearPicker/YearPickerNext.vue new file mode 100644 index 0000000000..2079a39dec --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerNext.vue @@ -0,0 +1,52 @@ + + + + + + + + Next page + + + diff --git a/packages/core/src/YearPicker/YearPickerPrev.vue b/packages/core/src/YearPicker/YearPickerPrev.vue new file mode 100644 index 0000000000..8ba870a4da --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerPrev.vue @@ -0,0 +1,52 @@ + + + + + + + + Prev page + + + diff --git a/packages/core/src/YearPicker/YearPickerRoot.vue b/packages/core/src/YearPicker/YearPickerRoot.vue new file mode 100644 index 0000000000..ff0d251bd6 --- /dev/null +++ b/packages/core/src/YearPicker/YearPickerRoot.vue @@ -0,0 +1,314 @@ + + + + + + + + + + {{ fullCalendarLabel }} + + + + diff --git a/packages/core/src/YearPicker/index.ts b/packages/core/src/YearPicker/index.ts new file mode 100644 index 0000000000..64d8707922 --- /dev/null +++ b/packages/core/src/YearPicker/index.ts @@ -0,0 +1,42 @@ +export { + default as YearPickerCell, + type YearPickerCellProps, +} from './YearPickerCell.vue' +export { + default as YearPickerCellTrigger, + type YearPickerCellTriggerProps, +} from './YearPickerCellTrigger.vue' +export { + default as YearPickerGrid, + type YearPickerGridProps, +} from './YearPickerGrid.vue' +export { + default as YearPickerGridBody, + type YearPickerGridBodyProps, +} from './YearPickerGridBody.vue' +export { + default as YearPickerGridRow, + type YearPickerGridRowProps, +} from './YearPickerGridRow.vue' +export { + default as YearPickerHeader, + type YearPickerHeaderProps, +} from './YearPickerHeader.vue' +export { + default as YearPickerHeading, + type YearPickerHeadingProps, +} from './YearPickerHeading.vue' +export { + default as YearPickerNext, + type YearPickerNextProps, +} from './YearPickerNext.vue' +export { + default as YearPickerPrev, + type YearPickerPrevProps, +} from './YearPickerPrev.vue' +export { + injectYearPickerRootContext, + default as YearPickerRoot, + type YearPickerRootEmits, + type YearPickerRootProps, +} from './YearPickerRoot.vue' diff --git a/packages/core/src/YearPicker/story/YearPickerDefault.story.vue b/packages/core/src/YearPicker/story/YearPickerDefault.story.vue new file mode 100644 index 0000000000..5c0065edca --- /dev/null +++ b/packages/core/src/YearPicker/story/YearPickerDefault.story.vue @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/YearPicker/story/_YearPicker.vue b/packages/core/src/YearPicker/story/_YearPicker.vue new file mode 100644 index 0000000000..4eec012479 --- /dev/null +++ b/packages/core/src/YearPicker/story/_YearPicker.vue @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/YearPicker/useYearPicker.ts b/packages/core/src/YearPicker/useYearPicker.ts new file mode 100644 index 0000000000..577ef58b16 --- /dev/null +++ b/packages/core/src/YearPicker/useYearPicker.ts @@ -0,0 +1,202 @@ +import type { DateValue } from '@internationalized/date' +import type { Ref } from 'vue' +import type { Grid, Matcher } from '@/date' +import type { DateFormatterOptions } from '@/shared/useDateFormatter' +import { endOfYear, startOfYear } from '@internationalized/date' +import { computed, ref, watch } from 'vue' +import { createYearGrid, isAfter, isBefore, isSameYear, toDate } from '@/date' +import { useDateFormatter } from '@/shared' + +export type UseYearPickerProps = { + locale: Ref + placeholder: Ref + minValue: Ref + maxValue: Ref + disabled: Ref + yearsPerPage: Ref + isYearDisabled?: Matcher | Ref + isYearUnavailable?: Matcher | Ref + calendarLabel: Ref + nextPage: Ref<((placeholder: DateValue) => DateValue) | undefined> + prevPage: Ref<((placeholder: DateValue) => DateValue) | undefined> +} + +export type UseYearPickerStateProps = { + isYearDisabled: Matcher + isYearUnavailable: Matcher + date: Ref +} + +export function useYearPickerState(props: UseYearPickerStateProps) { + function isYearSelected(dateObj: DateValue) { + if (Array.isArray(props.date.value)) + return props.date.value.some(d => isSameYear(d, dateObj)) + else if (!props.date.value) + return false + else + return isSameYear(props.date.value, dateObj) + } + + const isInvalid = computed(() => { + if (Array.isArray(props.date.value)) { + if (!props.date.value.length) + return false + for (const dateObj of props.date.value) { + if (props.isYearDisabled?.(dateObj)) + return true + if (props.isYearUnavailable?.(dateObj)) + return true + } + } + else { + if (!props.date.value) + return false + if (props.isYearDisabled?.(props.date.value)) + return true + if (props.isYearUnavailable?.(props.date.value)) + return true + } + return false + }) + + return { isYearSelected, isInvalid } +} + +export function useYearPicker(props: UseYearPickerProps) { + const formatter = useDateFormatter(props.locale.value) + + const resolveMatcher = (matcher?: Matcher | Ref) => + typeof matcher === 'function' ? matcher : matcher?.value + + const headingFormatOptions = computed(() => { + const options: DateFormatterOptions = { + calendar: props.placeholder.value.calendar.identifier, + } + + if (props.placeholder.value.calendar.identifier === 'gregory' && props.placeholder.value.era === 'BC') + options.era = 'short' + + return options + }) + + const grid = ref>(createYearGrid({ + dateObj: props.placeholder.value, + yearsPerPage: props.yearsPerPage.value, + })) as Ref> + + function isYearDisabled(dateObj: DateValue) { + if (resolveMatcher(props.isYearDisabled)?.(dateObj) || props.disabled.value) + return true + if (props.maxValue.value && isAfter(startOfYear(dateObj), props.maxValue.value)) + return true + if (props.minValue.value && isBefore(endOfYear(dateObj), props.minValue.value)) + return true + return false + } + + const isYearUnavailable = (date: DateValue) => { + if (resolveMatcher(props.isYearUnavailable)?.(date)) + return true + return false + } + + const isNextButtonDisabled = (nextPageFunc?: (date: DateValue) => DateValue) => { + if (!props.maxValue.value) + return false + if (props.disabled.value) + return true + + const lastYearInView = grid.value.cells[grid.value.cells.length - 1] + if (nextPageFunc || props.nextPage.value) { + const nextDate = (nextPageFunc || props.nextPage.value)!(lastYearInView) + return isAfter(startOfYear(nextDate), props.maxValue.value) + } + + const nextPageStart = startOfYear(lastYearInView.add({ years: 1 })) + return isAfter(nextPageStart, props.maxValue.value) + } + + const isPrevButtonDisabled = (prevPageFunc?: (date: DateValue) => DateValue) => { + if (!props.minValue.value) + return false + if (props.disabled.value) + return true + + const firstYearInView = grid.value.value + if (prevPageFunc || props.prevPage.value) { + const prevDate = (prevPageFunc || props.prevPage.value)!(firstYearInView) + return isBefore(endOfYear(prevDate), props.minValue.value) + } + + const prevPageEnd = endOfYear(firstYearInView.subtract({ years: 1 })) + return isBefore(prevPageEnd, props.minValue.value) + } + + const nextPage = (nextPageFunc?: (date: DateValue) => DateValue) => { + const firstYearInGrid = grid.value.value + + if (nextPageFunc || props.nextPage.value) { + const newDate = (nextPageFunc || props.nextPage.value)!(firstYearInGrid) + grid.value = createYearGrid({ dateObj: newDate, yearsPerPage: props.yearsPerPage.value, decadeAligned: false }) + props.placeholder.value = newDate.set({ month: 1, day: 1 }) + return + } + + const newDate = firstYearInGrid.add({ years: props.yearsPerPage.value }) + grid.value = createYearGrid({ dateObj: newDate, yearsPerPage: props.yearsPerPage.value, decadeAligned: false }) + props.placeholder.value = newDate.set({ month: 1, day: 1 }) + } + + const prevPage = (prevPageFunc?: (date: DateValue) => DateValue) => { + const firstYearInGrid = grid.value.value + + if (prevPageFunc || props.prevPage.value) { + const newDate = (prevPageFunc || props.prevPage.value)!(firstYearInGrid) + grid.value = createYearGrid({ dateObj: newDate, yearsPerPage: props.yearsPerPage.value, decadeAligned: false }) + props.placeholder.value = newDate.set({ month: 1, day: 1 }) + return + } + + const newDate = firstYearInGrid.subtract({ years: props.yearsPerPage.value }) + grid.value = createYearGrid({ dateObj: newDate, yearsPerPage: props.yearsPerPage.value, decadeAligned: false }) + props.placeholder.value = newDate.set({ month: 1, day: 1 }) + } + + watch(props.placeholder, (value) => { + const firstYearInGrid = grid.value.value + const lastYearInGrid = grid.value.cells[grid.value.cells.length - 1] + if (value.year >= firstYearInGrid.year && value.year <= lastYearInGrid.year) + return + grid.value = createYearGrid({ dateObj: value, yearsPerPage: props.yearsPerPage.value }) + }) + + watch([props.locale, props.yearsPerPage], () => { + formatter.setLocale(props.locale.value) + grid.value = createYearGrid({ dateObj: props.placeholder.value, yearsPerPage: props.yearsPerPage.value }) + }) + + const headingValue = computed(() => { + if (props.locale.value !== formatter.getLocale()) + formatter.setLocale(props.locale.value) + + const firstYear = grid.value.cells[0] + const lastYear = grid.value.cells[grid.value.cells.length - 1] + + return `${formatter.fullYear(toDate(firstYear), headingFormatOptions.value)} - ${formatter.fullYear(toDate(lastYear), headingFormatOptions.value)}` + }) + + const fullCalendarLabel = computed(() => `${props.calendarLabel.value ?? 'Year Picker'}, ${headingValue.value}`) + + return { + isYearDisabled, + isYearUnavailable, + isNextButtonDisabled, + isPrevButtonDisabled, + grid, + formatter, + nextPage, + prevPage, + headingValue, + fullCalendarLabel, + } +} diff --git a/packages/core/src/YearRangePicker/YearRangePicker.test.ts b/packages/core/src/YearRangePicker/YearRangePicker.test.ts new file mode 100644 index 0000000000..deec3c4ed9 --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePicker.test.ts @@ -0,0 +1,413 @@ +import type { DateValue } from '@internationalized/date' +import type { YearRangePickerRootProps } from './YearRangePickerRoot.vue' +import type { DateRange } from '@/shared/date' +import { CalendarDate, CalendarDateTime, toZoned } from '@internationalized/date' +import userEvent from '@testing-library/user-event' +import { render } from '@testing-library/vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import { useTestKbd } from '@/shared' +import YearRangePicker from './story/_YearRangePicker.vue' + +const calendarDateRange = { + start: new CalendarDate(1980, 1, 20), + end: new CalendarDate(1983, 3, 25), +} + +const calendarDateTimeRange = { + start: new CalendarDateTime(1980, 1, 20, 12, 30, 0, 0), + end: new CalendarDateTime(1983, 3, 25, 12, 30, 0, 0), +} + +const zonedDateTimeRange = { + start: toZoned(calendarDateTimeRange.start, 'America/New_York'), + end: toZoned(calendarDateTimeRange.end, 'America/New_York'), +} + +const kbd = useTestKbd() + +function setup(props: { pickerProps?: YearRangePickerRootProps, emits?: { 'onUpdate:modelValue'?: (data: DateRange) => void } } = {}) { + const user = userEvent.setup() + const returned = render(YearRangePicker, { props }) + const picker = returned.getByTestId('year-range-picker') + expect(picker).toBeVisible() + return { ...returned, user, picker } +} + +function getSelectedYears(picker: HTMLElement) { + return Array.from(picker.querySelectorAll('[data-selected]')) +} + +it('should pass axe accessibility tests', async () => { + const wrapper = mount(YearRangePicker) + expect(await axe(wrapper.element)).toHaveNoViolations() +}) + +describe('year range picker', () => { + it('respects a default value if provided - `CalendarDate`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: calendarDateRange } }) + const selectedYears = getSelectedYears(picker) + expect(selectedYears).toHaveLength(4) + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + }) + + it('respects a default value if provided - `CalendarDateTime`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: calendarDateTimeRange } }) + const selectedYears = getSelectedYears(picker) + expect(selectedYears).toHaveLength(4) + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + }) + + it('respects a default value if provided - `ZonedDateTime`', async () => { + const { picker, getByTestId } = setup({ pickerProps: { modelValue: zonedDateTimeRange } }) + const selectedYears = getSelectedYears(picker) + expect(selectedYears).toHaveLength(4) + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + }) + + it('does not crash when modelValue is null', async () => { + const { picker, rerender } = setup({ pickerProps: { modelValue: null } }) + + expect(getSelectedYears(picker)).toHaveLength(0) + + await rerender({ + pickerProps: { + modelValue: calendarDateRange, + }, + }) + + expect(getSelectedYears(picker)).toHaveLength(4) + }) + + it('resets range on select when a range is already selected', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { modelValue: calendarDateRange }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + let startValue = picker.querySelector('[data-selection-start]') + let endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toHaveTextContent('1980') + expect(endValue).toHaveTextContent('1983') + + const year1985 = getByTestId('year-1985') + await user.click(year1985) + + const selectedYears = getSelectedYears(picker) + expect(selectedYears).toHaveLength(1) + + startValue = picker.querySelector('[data-selection-start]') + endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toBeInTheDocument() + expect(endValue).not.toBeInTheDocument() + + const year1987 = getByTestId('year-1987') + await user.click(year1987) + expect(getSelectedYears(picker)).toHaveLength(3) + }) + + it('keeps controlled end when parent preserves it after start edit', async () => { + const preservedEnd = new CalendarDate(1986, 1, 1) + const controlledRange = { + start: new CalendarDate(1980, 1, 1), + end: preservedEnd, + } + + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { modelValue: controlledRange }, + emits: { + 'onUpdate:modelValue': (data) => { + rerender({ + pickerProps: { + modelValue: { + start: data.start ?? controlledRange.start, + end: data.end ?? preservedEnd, + }, + }, + }) + }, + }, + }) + + const year1983 = getByTestId('year-1983') + await user.click(year1983) + + expect(getByTestId('year-1983')).toHaveAttribute('data-selection-start') + expect(getByTestId('year-1986')).toHaveAttribute('data-selection-end') + expect(getByTestId('year-1984')).toHaveAttribute('data-selected') + expect(getByTestId('year-1985')).toHaveAttribute('data-selected') + expect(getSelectedYears(picker)).toHaveLength(4) + }) + + it('allows same year selection', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { placeholder: calendarDateRange.start }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + const year1980 = getByTestId('year-1980') + await user.click(year1980) + await user.click(year1980) + + expect(getSelectedYears(picker)).toHaveLength(1) + expect(picker.querySelector('[data-selection-start]')).toBeInTheDocument() + expect(picker.querySelector('[data-selection-end]')).toBeInTheDocument() + }) + + it('allows deselection', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { placeholder: calendarDateRange.start }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + const year1980 = getByTestId('year-1980') + await user.click(year1980) + await user.click(year1980) + + expect(getSelectedYears(picker)).toHaveLength(1) + + await user.click(year1980) + expect(getSelectedYears(picker)).toHaveLength(0) + }) + + it('resets range selection when pressing Escape', async () => { + const { getByTestId, picker, user, rerender } = setup({ + pickerProps: { modelValue: calendarDateRange }, + emits: { 'onUpdate:modelValue': data => rerender({ pickerProps: { modelValue: data } }) }, + }) + + let startValue = picker.querySelector('[data-selection-start]') + let endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toHaveTextContent('1980') + expect(endValue).toHaveTextContent('1983') + + const year1985 = getByTestId('year-1985') + await user.click(year1985) + + const selectedYears = getSelectedYears(picker) + expect(selectedYears).toHaveLength(1) + + await user.keyboard(kbd.ESCAPE) + + startValue = picker.querySelector('[data-selection-start]') + endValue = picker.querySelector('[data-selection-end]') + + expect(startValue).toHaveTextContent('1980') + expect(endValue).toHaveTextContent('1983') + }) + + it('navigates pages forward using the next button', async () => { + const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDateRange } }) + + const heading = getByTestId('heading') + const nextBtn = getByTestId('next-button') + + expect(heading).toHaveTextContent('1980 - 1991') + await user.click(nextBtn) + expect(heading).toHaveTextContent('1992 - 2003') + }) + + it('navigates pages backwards using the prev button', async () => { + const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDateRange } }) + + const heading = getByTestId('heading') + const prevBtn = getByTestId('prev-button') + + expect(heading).toHaveTextContent('1980 - 1991') + await user.click(prevBtn) + expect(heading).toHaveTextContent('1968 - 1979') + }) + + it('handles fixedDate with start correctly', async () => { + const { getByTestId, picker, user } = setup({ + pickerProps: { + defaultValue: calendarDateRange, + fixedDate: 'start', + }, + }) + + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1980 - 1991') + + const year1985 = getByTestId('year-1985') + await user.click(year1985) + + expect(getByTestId('year-1984')).toHaveAttribute('data-selected') + expect(getByTestId('year-1980')).toHaveAttribute('data-selection-start') + expect(getByTestId('year-1985')).toHaveAttribute('data-selection-end') + }) + + it('handles fixedDate with end correctly', async () => { + const { getByTestId, picker, user } = setup({ + pickerProps: { + defaultValue: calendarDateRange, + fixedDate: 'end', + }, + }) + + const heading = getByTestId('heading') + expect(heading).toHaveTextContent('1980 - 1991') + + const year1985 = getByTestId('year-1985') + await user.click(year1985) + + expect(getByTestId('year-1984')).toHaveAttribute('data-selected') + expect(getByTestId('year-1980')).toHaveAttribute('data-selection-start') + expect(getByTestId('year-1985')).toHaveAttribute('data-selection-end') + + const year1982 = getByTestId('year-1982') + await user.click(year1982) + expect(getByTestId('year-1982')).toHaveAttribute('data-selection-start') + expect(getByTestId('year-1985')).toHaveAttribute('data-selection-end') + }) + + it('allows non-contiguous ranges', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDateRange.start, + allowNonContiguousRanges: true, + isYearUnavailable: (date: DateValue) => { + return date.year === 1982 + }, + }, + }) + + const year1980 = getByTestId('year-1980') + const year1983 = getByTestId('year-1983') + const year1984 = getByTestId('year-1984') + await user.click(year1980) + await user.click(year1984) + expect(year1983).toHaveAttribute('data-selected') + }) +}) + +describe('year range picker - maximumYears', () => { + it('limits the maximum number of years that can be selected', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: new CalendarDate(1983, 3, 15), + maximumYears: 3, + }, + }) + + const year1983 = getByTestId('year-1983') + await user.click(year1983) + expect(year1983).toHaveAttribute('data-selection-start') + + const year1986 = getByTestId('year-1986') + await user.click(year1986) + + expect(year1986).toHaveAttribute('data-disabled') + expect(year1986).not.toHaveAttribute('data-selected') + + const year1985 = getByTestId('year-1985') + expect(year1985).not.toHaveAttribute('data-disabled') + + await user.click(year1985) + expect(getByTestId('year-1983')).toHaveAttribute('data-selected') + expect(getByTestId('year-1984')).toHaveAttribute('data-selected') + expect(getByTestId('year-1985')).toHaveAttribute('data-selected') + }) + + it('highlights backwards within maximumYears without inverting', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: new CalendarDate(1983, 3, 15), + maximumYears: 3, + }, + }) + + const year1983 = getByTestId('year-1983') + await user.click(year1983) + expect(year1983).toHaveAttribute('data-selection-start') + + const year1981 = getByTestId('year-1981') + await user.hover(year1981) + + expect(year1981).toHaveAttribute('data-highlighted-start') + expect(getByTestId('year-1982')).toHaveAttribute('data-highlighted') + expect(year1983).toHaveAttribute('data-highlighted-end') + expect(getByTestId('year-1980')).not.toHaveAttribute('data-highlighted') + }) +}) + +describe('year range picker - keyboard navigation', () => { + it('navigates with arrow keys within the grid', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDateRange.start }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + + await user.keyboard(kbd.ARROW_RIGHT) + expect(getByTestId('year-1981')).toHaveFocus() + + await user.keyboard(kbd.ARROW_DOWN) + expect(getByTestId('year-1985')).toHaveFocus() + + await user.keyboard(kbd.ARROW_LEFT) + expect(getByTestId('year-1984')).toHaveFocus() + + await user.keyboard(kbd.ARROW_UP) + expect(getByTestId('year-1980')).toHaveFocus() + }) + + it('navigates to next/prev page with PageDown/PageUp', async () => { + const { getByTestId, user } = setup({ + pickerProps: { placeholder: calendarDateRange.start }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + + await user.keyboard(kbd.PAGE_DOWN) + expect(getByTestId('heading')).toHaveTextContent('1992 - 2003') + + await user.keyboard(kbd.PAGE_UP) + expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') + }) + + it('skips disabled candidate year when navigating to next page', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDateRange.start, + isYearDisabled: (date: DateValue) => date.year === 1992, + }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + + await user.keyboard(kbd.PAGE_DOWN) + expect(getByTestId('heading')).toHaveTextContent('1992 - 2003') + expect(getByTestId('year-1992')).toHaveAttribute('data-disabled') + expect(getByTestId('year-1993')).toHaveFocus() + }) + + it('falls back to the nearest enabled year when paged candidate is missing', async () => { + const { getByTestId, user } = setup({ + pickerProps: { + placeholder: calendarDateRange.start, + nextPage: date => date.add({ years: 13 }), + }, + }) + + const year1980 = getByTestId('year-1980') + year1980.focus() + expect(year1980).toHaveFocus() + + await user.keyboard(kbd.PAGE_DOWN) + + expect(getByTestId('heading')).toHaveTextContent('1993 - 2004') + expect(getByTestId('year-1993')).toHaveFocus() + }) +}) diff --git a/packages/core/src/YearRangePicker/YearRangePickerCell.vue b/packages/core/src/YearRangePicker/YearRangePickerCell.vue new file mode 100644 index 0000000000..48fae4cfe7 --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerCell.vue @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerCellTrigger.vue b/packages/core/src/YearRangePicker/YearRangePickerCellTrigger.vue new file mode 100644 index 0000000000..bb47fd2e25 --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerCellTrigger.vue @@ -0,0 +1,311 @@ + + + + + + + + {{ yearValue }} + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerGrid.vue b/packages/core/src/YearRangePicker/YearRangePickerGrid.vue new file mode 100644 index 0000000000..b9c2f033ec --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerGrid.vue @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerGridBody.vue b/packages/core/src/YearRangePicker/YearRangePickerGridBody.vue new file mode 100644 index 0000000000..810afe53e1 --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerGridBody.vue @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerGridRow.vue b/packages/core/src/YearRangePicker/YearRangePickerGridRow.vue new file mode 100644 index 0000000000..994c8c2cfc --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerGridRow.vue @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerHeader.vue b/packages/core/src/YearRangePicker/YearRangePickerHeader.vue new file mode 100644 index 0000000000..1555a617c9 --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerHeader.vue @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerHeading.vue b/packages/core/src/YearRangePicker/YearRangePickerHeading.vue new file mode 100644 index 0000000000..f23a02014c --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerHeading.vue @@ -0,0 +1,35 @@ + + + + + + + + {{ rootContext.headingValue.value }} + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerNext.vue b/packages/core/src/YearRangePicker/YearRangePickerNext.vue new file mode 100644 index 0000000000..6c4b5d7362 --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerNext.vue @@ -0,0 +1,52 @@ + + + + + + + + Next page + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerPrev.vue b/packages/core/src/YearRangePicker/YearRangePickerPrev.vue new file mode 100644 index 0000000000..a3cfdbccab --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerPrev.vue @@ -0,0 +1,52 @@ + + + + + + + + Prev page + + + diff --git a/packages/core/src/YearRangePicker/YearRangePickerRoot.vue b/packages/core/src/YearRangePicker/YearRangePickerRoot.vue new file mode 100644 index 0000000000..b2424d4633 --- /dev/null +++ b/packages/core/src/YearRangePicker/YearRangePickerRoot.vue @@ -0,0 +1,391 @@ + + + + + + + + + {{ fullCalendarLabel }} + + + + + + diff --git a/packages/core/src/YearRangePicker/index.ts b/packages/core/src/YearRangePicker/index.ts new file mode 100644 index 0000000000..6a0a8143dd --- /dev/null +++ b/packages/core/src/YearRangePicker/index.ts @@ -0,0 +1,42 @@ +export { + default as YearRangePickerCell, + type YearRangePickerCellProps, +} from './YearRangePickerCell.vue' +export { + default as YearRangePickerCellTrigger, + type YearRangePickerCellTriggerProps, +} from './YearRangePickerCellTrigger.vue' +export { + default as YearRangePickerGrid, + type YearRangePickerGridProps, +} from './YearRangePickerGrid.vue' +export { + default as YearRangePickerGridBody, + type YearRangePickerGridBodyProps, +} from './YearRangePickerGridBody.vue' +export { + default as YearRangePickerGridRow, + type YearRangePickerGridRowProps, +} from './YearRangePickerGridRow.vue' +export { + default as YearRangePickerHeader, + type YearRangePickerHeaderProps, +} from './YearRangePickerHeader.vue' +export { + default as YearRangePickerHeading, + type YearRangePickerHeadingProps, +} from './YearRangePickerHeading.vue' +export { + default as YearRangePickerNext, + type YearRangePickerNextProps, +} from './YearRangePickerNext.vue' +export { + default as YearRangePickerPrev, + type YearRangePickerPrevProps, +} from './YearRangePickerPrev.vue' +export { + injectYearRangePickerRootContext, + default as YearRangePickerRoot, + type YearRangePickerRootEmits, + type YearRangePickerRootProps, +} from './YearRangePickerRoot.vue' diff --git a/packages/core/src/YearRangePicker/story/YearRangePickerDefault.story.vue b/packages/core/src/YearRangePicker/story/YearRangePickerDefault.story.vue new file mode 100644 index 0000000000..0b5b5db381 --- /dev/null +++ b/packages/core/src/YearRangePicker/story/YearRangePickerDefault.story.vue @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/packages/core/src/YearRangePicker/story/_YearRangePicker.vue b/packages/core/src/YearRangePicker/story/_YearRangePicker.vue new file mode 100644 index 0000000000..63dcfc5faf --- /dev/null +++ b/packages/core/src/YearRangePicker/story/_YearRangePicker.vue @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/YearRangePicker/useRangeYearPicker.ts b/packages/core/src/YearRangePicker/useRangeYearPicker.ts new file mode 100644 index 0000000000..a6d6f10a61 --- /dev/null +++ b/packages/core/src/YearRangePicker/useRangeYearPicker.ts @@ -0,0 +1,165 @@ +import type { DateValue } from '@internationalized/date' +import type { Ref } from 'vue' +import type { Matcher } from '@/date' +import { computed } from 'vue' +import { areAllYearsBetweenValid, getYearsBetween, isSameYear } from '@/date' + +export type UseRangeYearPickerProps = { + start: Ref + end: Ref + isYearDisabled: Matcher + isYearUnavailable: Matcher + focusedValue: Ref + allowNonContiguousRanges: Ref + fixedDate: Ref<'start' | 'end' | undefined> + maximumYears?: Ref +} + +export function useRangeYearPickerState(props: UseRangeYearPickerProps) { + const isStartInvalid = computed(() => { + if (!props.start.value) + return false + if (props.isYearDisabled(props.start.value)) + return true + return false + }) + + const isEndInvalid = computed(() => { + if (!props.end.value) + return false + if (props.isYearDisabled(props.end.value)) + return true + return false + }) + + const isInvalid = computed(() => { + if (isStartInvalid.value || isEndInvalid.value) + return true + if (props.start.value && props.end.value && props.end.value.year < props.start.value.year) + return true + return false + }) + + const isSelectionStart = (date: DateValue) => { + if (!props.start.value) + return false + return isSameYear(props.start.value, date) + } + + const isSelectionEnd = (date: DateValue) => { + if (!props.end.value) + return false + return isSameYear(props.end.value, date) + } + + const isSelected = (date: DateValue) => { + if (props.start.value && isSameYear(props.start.value, date)) + return true + if (props.end.value && isSameYear(props.end.value, date)) + return true + if (props.end.value && props.start.value) { + return date.year > props.start.value.year && date.year < props.end.value.year + } + return false + } + + const rangeIsYearDisabled = (date: DateValue) => { + if (props.isYearDisabled(date)) + return true + + if (props.maximumYears?.value) { + const maximumYears = props.maximumYears.value + + if (props.start.value && props.end.value) { + if (props.fixedDate.value) { + const diff = getYearsBetween(props.start.value, props.end.value) + if (diff <= maximumYears) { + const yearsLeft = maximumYears - diff + const startLimit = props.start.value.subtract({ years: yearsLeft }) + const endLimit = props.end.value.add({ years: yearsLeft }) + return date.year < startLimit.year || date.year > endLimit.year + } + + const fixedValue = props.fixedDate.value === 'start' ? props.start.value : props.end.value + const maxDate = fixedValue.add({ years: maximumYears - 1 }) + const minDate = fixedValue.subtract({ years: maximumYears - 1 }) + return date.year < minDate.year || date.year > maxDate.year + } + return false + } + if (props.start.value) { + const maxDate = props.start.value.add({ years: maximumYears - 1 }) + const minDate = props.start.value.subtract({ years: maximumYears - 1 }) + return date.year < minDate.year || date.year > maxDate.year + } + } + + return false + } + + const highlightedRange = computed(() => { + if (props.start.value && props.end.value && !props.fixedDate.value) + return null + if (!props.start.value || !props.focusedValue.value) + return null + + const isStartBeforeFocused = props.start.value.year < props.focusedValue.value.year + const start = isStartBeforeFocused ? props.start.value : props.focusedValue.value + const end = isStartBeforeFocused ? props.focusedValue.value : props.start.value + + if (isSameYear(start, end)) { + return { start, end } + } + + if (props.maximumYears?.value && !props.end.value) { + const maximumYears = props.maximumYears.value + const anchor = props.start.value + const focused = props.focusedValue.value + + if (focused.year >= anchor.year) { + const maxEnd = anchor.add({ years: maximumYears - 1 }) + const cappedEnd = focused.year > maxEnd.year ? maxEnd : focused + return { start: anchor, end: cappedEnd } + } + else { + const minStart = anchor.subtract({ years: maximumYears - 1 }) + const cappedStart = focused.year < minStart.year ? minStart : focused + return { start: cappedStart, end: anchor } + } + } + + const isValid = areAllYearsBetweenValid( + start, + end, + props.allowNonContiguousRanges.value ? () => false : props.isYearUnavailable, + rangeIsYearDisabled, + ) + if (isValid) + return { start, end } + + return null + }) + + const isHighlightedStart = (date: DateValue) => { + if (!highlightedRange.value?.start) + return false + return isSameYear(highlightedRange.value.start, date) + } + + const isHighlightedEnd = (date: DateValue) => { + if (!highlightedRange.value?.end) + return false + return isSameYear(highlightedRange.value.end, date) + } + + return { + isInvalid, + isSelected, + highlightedRange, + isSelectionStart, + isSelectionEnd, + isHighlightedStart, + isHighlightedEnd, + isYearDisabled: rangeIsYearDisabled, + } +} diff --git a/packages/core/src/date/calendar.ts b/packages/core/src/date/calendar.ts index e626f3fda3..79c6a1d166 100644 --- a/packages/core/src/date/calendar.ts +++ b/packages/core/src/date/calendar.ts @@ -188,6 +188,35 @@ export function createMonths(props: SetMonthProps) { return months } +/** + * Creates a 3x4 grid of months for a given year. + */ +export function createMonthGrid(props: CreateSelectProps): Grid { + const { dateObj } = props + const months = createYear({ dateObj }) + return { value: dateObj, cells: months, rows: chunk(months, 4) } +} + +/** + * Creates a 3x4 grid of years (decade-aligned). + * The grid starts from the decade that contains the given date. + */ +export function createYearGrid(props: CreateSelectProps & { yearsPerPage?: number, decadeAligned?: boolean }): Grid { + const { dateObj, yearsPerPage = 12, decadeAligned = true } = props + + let startYear: number + if (decadeAligned) { + startYear = startOfDecade(dateObj).year + } + else { + startYear = dateObj.year + } + + const years = Array.from({ length: yearsPerPage }, (_, i) => startOfYear(dateObj.set({ year: startYear + i }))) + const firstYear = years[0] + return { value: firstYear, cells: years, rows: chunk(years, 4) } +} + export function createYearRange({ start, end }: DateRange): DateValue[] { const years: DateValue[] = [] diff --git a/packages/core/src/date/comparators.ts b/packages/core/src/date/comparators.ts index c56575ef57..4fe83c074b 100644 --- a/packages/core/src/date/comparators.ts +++ b/packages/core/src/date/comparators.ts @@ -184,6 +184,20 @@ export function getNextLastDayOfWeek( return date.add({ days: lastDayOfWeek - day }) as T } +/** + * Check if two dates are in the same year and month. + */ +export function isSameYearMonth(a: DateValue, b: DateValue): boolean { + return a.year === b.year && a.month === b.month +} + +/** + * Check if two dates are in the same year. + */ +export function isSameYear(a: DateValue, b: DateValue): boolean { + return a.year === b.year +} + export function areAllDaysBetweenValid( start: DateValue, end: DateValue, @@ -210,3 +224,86 @@ export function areAllDaysBetweenValid( } return true } + +/** + * Compare two dates by year and month only (ignoring day). + */ +export function compareYearMonth(a: DateValue, b: DateValue): number { + if (a.year !== b.year) + return a.year - b.year + return a.month - b.month +} + +/** + * Check if a date's month is between start and end (inclusive), comparing year+month only. + */ +export function isMonthBetweenInclusive(date: DateValue, start: DateValue, end: DateValue): boolean { + return compareYearMonth(date, start) >= 0 && compareYearMonth(date, end) <= 0 +} + +/** + * Check if a date's year is between start and end (inclusive), comparing year only. + */ +export function isYearBetweenInclusive(date: DateValue, start: DateValue, end: DateValue): boolean { + return date.year >= start.year && date.year <= end.year +} + +/** + * Get the number of months between two dates (inclusive). + */ +export function getMonthsBetween(start: DateValue, end: DateValue): number { + return (end.year - start.year) * 12 + (end.month - start.month) + 1 +} + +/** + * Get the number of years between two dates (inclusive). + */ +export function getYearsBetween(start: DateValue, end: DateValue): number { + return end.year - start.year + 1 +} + +/** + * Check if all months between start and end are valid (not unavailable/disabled). + */ +export function areAllMonthsBetweenValid( + start: DateValue, + end: DateValue, + isUnavailable: Matcher | undefined, + isDisabled: Matcher | undefined, +): boolean { + if (isUnavailable === undefined && isDisabled === undefined) + return true + + let current = start.set({ day: 1 }) + const endMonth = end.set({ day: 1 }) + + while (compareYearMonth(current, endMonth) <= 0) { + if (isDisabled?.(current) || isUnavailable?.(current)) + return false + current = current.add({ months: 1 }) + } + return true +} + +/** + * Check if all years between start and end are valid (not unavailable/disabled). + */ +export function areAllYearsBetweenValid( + start: DateValue, + end: DateValue, + isUnavailable: Matcher | undefined, + isDisabled: Matcher | undefined, +): boolean { + if (isUnavailable === undefined && isDisabled === undefined) + return true + + let current = start.set({ day: 1, month: 1 }) + const endYear = end.set({ day: 1, month: 1 }) + + while (current.year <= endYear.year) { + if (isDisabled?.(current) || isUnavailable?.(current)) + return false + current = current.add({ years: 1 }) + } + return true +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2ffb733cea..162fc6a5c2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,8 @@ export * from './HoverCard' export * from './Label' export * from './Listbox' export * from './Menubar' +export * from './MonthPicker' +export * from './MonthRangePicker' export * from './NavigationMenu' export * from './NumberField' export * from './Pagination' @@ -76,6 +78,8 @@ export * from './Tooltip' export * from './Tree' export * from './Viewport' export { VisuallyHidden, type VisuallyHiddenProps } from './VisuallyHidden' +export * from './YearPicker' +export * from './YearRangePicker' export { type ReferenceElement, } from '@floating-ui/vue'