From 4281de6f3fc1ecc3afb4e8e9f367a6aae9fcd3a6 Mon Sep 17 00:00:00 2001 From: web-padawan Date: Thu, 11 Sep 2025 13:54:41 +0300 Subject: [PATCH] feat: add initial vaadin-month-picker component implementation --- dev/month-picker.html | 17 + packages/month-picker/LICENSE | 190 +++++++ packages/month-picker/package.json | 57 ++ .../styles/vaadin-month-picker-base-styles.js | 17 + .../vaadin-month-picker-button-base-styles.js | 34 ++ ...vaadin-month-picker-overlay-base-styles.js | 16 + ...onth-picker-overlay-content-base-styles.js | 49 ++ .../src/vaadin-month-picker-button.js | 44 ++ .../src/vaadin-month-picker-helpers.js | 201 +++++++ .../src/vaadin-month-picker-mixin.js | 514 ++++++++++++++++++ .../vaadin-month-picker-overlay-content.js | 325 +++++++++++ .../src/vaadin-month-picker-overlay.js | 62 +++ .../month-picker/src/vaadin-month-picker.js | 179 ++++++ .../month-picker/vaadin-month-picker.d.ts | 0 packages/month-picker/vaadin-month-picker.js | 1 + .../vaadin-lumo-styles/components/index.css | 1 + .../components/month-picker.css | 46 ++ .../src/components/month-picker-button.css | 44 ++ .../month-picker-overlay-content.css | 38 ++ .../src/components/month-picker-overlay.css | 24 + .../src/components/month-picker.css | 35 ++ 21 files changed, 1894 insertions(+) create mode 100644 dev/month-picker.html create mode 100644 packages/month-picker/LICENSE create mode 100644 packages/month-picker/package.json create mode 100644 packages/month-picker/src/styles/vaadin-month-picker-base-styles.js create mode 100644 packages/month-picker/src/styles/vaadin-month-picker-button-base-styles.js create mode 100644 packages/month-picker/src/styles/vaadin-month-picker-overlay-base-styles.js create mode 100644 packages/month-picker/src/styles/vaadin-month-picker-overlay-content-base-styles.js create mode 100644 packages/month-picker/src/vaadin-month-picker-button.js create mode 100644 packages/month-picker/src/vaadin-month-picker-helpers.js create mode 100644 packages/month-picker/src/vaadin-month-picker-mixin.js create mode 100644 packages/month-picker/src/vaadin-month-picker-overlay-content.js create mode 100644 packages/month-picker/src/vaadin-month-picker-overlay.js create mode 100644 packages/month-picker/src/vaadin-month-picker.js create mode 100644 packages/month-picker/vaadin-month-picker.d.ts create mode 100644 packages/month-picker/vaadin-month-picker.js create mode 100644 packages/vaadin-lumo-styles/components/month-picker.css create mode 100644 packages/vaadin-lumo-styles/src/components/month-picker-button.css create mode 100644 packages/vaadin-lumo-styles/src/components/month-picker-overlay-content.css create mode 100644 packages/vaadin-lumo-styles/src/components/month-picker-overlay.css create mode 100644 packages/vaadin-lumo-styles/src/components/month-picker.css diff --git a/dev/month-picker.html b/dev/month-picker.html new file mode 100644 index 00000000000..e7a3c9ca54e --- /dev/null +++ b/dev/month-picker.html @@ -0,0 +1,17 @@ + + + + + + + Month Picker + + + + + + + + diff --git a/packages/month-picker/LICENSE b/packages/month-picker/LICENSE new file mode 100644 index 00000000000..7f452cad7c0 --- /dev/null +++ b/packages/month-picker/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Vaadin Ltd. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/month-picker/package.json b/packages/month-picker/package.json new file mode 100644 index 00000000000..275b733095d --- /dev/null +++ b/packages/month-picker/package.json @@ -0,0 +1,57 @@ +{ + "name": "@vaadin/month-picker", + "version": "25.0.0-beta1", + "publishConfig": { + "access": "public" + }, + "description": "vaadin-month-picker", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/vaadin/web-components.git", + "directory": "packages/month-picker" + }, + "author": "Vaadin Ltd", + "homepage": "https://vaadin.com/components", + "bugs": { + "url": "https://github.com/vaadin/web-components/issues" + }, + "main": "vaadin-month-picker.js", + "module": "vaadin-month-picker.js", + "type": "module", + "files": [ + "src", + "vaadin-*.d.ts", + "vaadin-*.js", + "web-types.json", + "web-types.lit.json" + ], + "keywords": [ + "Vaadin", + "vaadin-month-picker", + "web-components", + "web-component" + ], + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/a11y-base": "25.0.0-beta1", + "@vaadin/button": "25.0.0-beta1", + "@vaadin/component-base": "25.0.0-beta1", + "@vaadin/field-base": "25.0.0-beta1", + "@vaadin/input-container": "25.0.0-beta1", + "@vaadin/overlay": "25.0.0-beta1", + "@vaadin/vaadin-themable-mixin": "25.0.0-beta1", + "lit": "^3.0.0" + }, + "devDependencies": { + "@vaadin/chai-plugins": "25.0.0-beta1", + "@vaadin/test-runner-commands": "25.0.0-beta1", + "@vaadin/testing-helpers": "^2.0.0", + "@vaadin/vaadin-lumo-styles": "25.0.0-beta1", + "sinon": "^21.0.0" + }, + "web-types": [ + "web-types.json", + "web-types.lit.json" + ] +} diff --git a/packages/month-picker/src/styles/vaadin-month-picker-base-styles.js b/packages/month-picker/src/styles/vaadin-month-picker-base-styles.js new file mode 100644 index 00000000000..ed5fefba25a --- /dev/null +++ b/packages/month-picker/src/styles/vaadin-month-picker-base-styles.js @@ -0,0 +1,17 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import '@vaadin/component-base/src/styles/style-props.js'; +import { css } from 'lit'; + +export const monthPickerStyles = css` + :host([opened]) { + pointer-events: auto; + } + + [part~='toggle-button']::before { + mask-image: var(--_vaadin-icon-calendar); + } +`; diff --git a/packages/month-picker/src/styles/vaadin-month-picker-button-base-styles.js b/packages/month-picker/src/styles/vaadin-month-picker-button-base-styles.js new file mode 100644 index 00000000000..74af559507f --- /dev/null +++ b/packages/month-picker/src/styles/vaadin-month-picker-button-base-styles.js @@ -0,0 +1,34 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { css } from 'lit'; +import { buttonStyles } from '@vaadin/button/src/styles/vaadin-button-base-styles.js'; + +const monthPickerButton = css` + :host { + padding: 4px; + } + + :host(:not([dir='rtl'])[slot^='prev']) [part='icon'], + :host([dir='rtl'][slot^='next']) [part='icon'] { + rotate: 90deg; + } + + :host(:not([dir='rtl'])[slot^='next']) [part='icon'], + :host([dir='rtl'][slot^='next']) [part='icon'] { + rotate: -90deg; + } + + [part='icon']::before { + background: currentColor; + content: ''; + display: block; + height: var(--vaadin-icon-size, 1lh); + mask: var(--_vaadin-icon-chevron-down) 50% / var(--vaadin-icon-visual-size, 100%) no-repeat; + width: var(--vaadin-icon-size, 1lh); + } +`; + +export const monthPickerButtonStyles = [buttonStyles, monthPickerButton]; diff --git a/packages/month-picker/src/styles/vaadin-month-picker-overlay-base-styles.js b/packages/month-picker/src/styles/vaadin-month-picker-overlay-base-styles.js new file mode 100644 index 00000000000..2c35d03dc6a --- /dev/null +++ b/packages/month-picker/src/styles/vaadin-month-picker-overlay-base-styles.js @@ -0,0 +1,16 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { css } from 'lit'; + +export const monthPickerOverlayStyles = css` + [part='overlay'] { + min-width: var(--vaadin-field-default-width, 12em); + } + + [part='content'] { + padding: var(--vaadin-month-picker-overlay-padding, var(--vaadin-padding-s)); + } +`; diff --git a/packages/month-picker/src/styles/vaadin-month-picker-overlay-content-base-styles.js b/packages/month-picker/src/styles/vaadin-month-picker-overlay-content-base-styles.js new file mode 100644 index 00000000000..7a2c45a54fe --- /dev/null +++ b/packages/month-picker/src/styles/vaadin-month-picker-overlay-content-base-styles.js @@ -0,0 +1,49 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import '@vaadin/component-base/src/styles/style-props.js'; +import { css } from 'lit'; + +export const monthPickerOverlayContentStyles = css` + :host { + display: block; + } + + :host([hidden]) { + display: none !important; + } + + [part='header'] { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--vaadin-month-picker-header-padding, 0 0 var(--vaadin-padding-s)); + } + + [part='month-grid'] { + display: grid; + grid-template-columns: repeat(4, 1fr); + min-width: calc(var(--vaadin-month-picker-month-width, 2rem) * 4); + } + + [part~='month'] { + display: flex; + align-items: center; + justify-content: center; + cursor: var(--vaadin-clickable-cursor); + border-radius: var(--vaadin-month-picker-month-border-radius, var(--vaadin-radius-m)); + width: var(--vaadin-month-picker-month-width, 2rem); + height: var(--vaadin-month-picker-month-height, 2rem); + } + + [part~='month'][disabled] { + pointer-events: none; + } + + [part~='month']:focus-visible { + outline: var(--vaadin-focus-ring-width) solid var(--vaadin-focus-ring-color); + outline-offset: calc(var(--vaadin-focus-ring-width) * -1); + } +`; diff --git a/packages/month-picker/src/vaadin-month-picker-button.js b/packages/month-picker/src/vaadin-month-picker-button.js new file mode 100644 index 00000000000..08763aa67ea --- /dev/null +++ b/packages/month-picker/src/vaadin-month-picker-button.js @@ -0,0 +1,44 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { html, LitElement } from 'lit'; +import { ButtonMixin } from '@vaadin/button/src/vaadin-button-mixin.js'; +import { defineCustomElement } from '@vaadin/component-base/src/define.js'; +import { DirMixin } from '@vaadin/component-base/src/dir-mixin.js'; +import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; +import { LumoInjectionMixin } from '@vaadin/vaadin-themable-mixin/lumo-injection-mixin.js'; +import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import { monthPickerButtonStyles } from './styles/vaadin-month-picker-button-base-styles.js'; + +/** + * An element used internally by ``. Not intended to be used separately. + * + * @customElement + * @extends HTMLElement + * @mixes DirMixin + * @mixes ButtonMixin + * @mixes OverlayMixin + * @mixes PositionMixin + * @mixes ThemableMixin + * @private + */ +class MonthPickerButton extends ButtonMixin(DirMixin(ThemableMixin(PolylitMixin(LumoInjectionMixin(LitElement))))) { + static get is() { + return 'vaadin-month-picker-button'; + } + + static get styles() { + return monthPickerButtonStyles; + } + + /** @protected */ + render() { + return html``; + } +} + +defineCustomElement(MonthPickerButton); + +export { MonthPickerButton }; diff --git a/packages/month-picker/src/vaadin-month-picker-helpers.js b/packages/month-picker/src/vaadin-month-picker-helpers.js new file mode 100644 index 00000000000..5183f48c4bc --- /dev/null +++ b/packages/month-picker/src/vaadin-month-picker-helpers.js @@ -0,0 +1,201 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +/** + * @typedef YearMonth + * @type {object} + * @property {number} year - The year + * @property {number} month - The month + */ + +/** + * Formats year and month object to string. + * @param {YearMonth} yearObject + * @return {string} + */ +export function yearMonthToValue(yearMonthObject) { + const { year, month } = yearMonthObject; + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}`; +} + +/** + * Parses string to the year and month object. + * @param {string} yearString + * @return {YearMonth} + */ +export function valueToYearMonth(yearString) { + if (yearString && yearString.length) { + const parts = yearString.split('-'); + return { + year: parseInt(parts[0], 10), + month: parseInt(parts[1], 10), + }; + } + return null; +} + +/** + * Extracts the century from the given 4-digit year. + * The century will be "20", when "2025" is passed. + * @param {number} year 4-digit year + * @return {number} + */ +export function toRefCentury(year) { + return Math.trunc(year / 100); +} + +/** + * Applies the reference century onto the given 2-digit year. + * @param {number} year 2-digit year + * @param {number} century century (e.g. "20") + * @return {number} + */ +export function applyRefCentury(year, century) { + return year + century * 100; +} + +/** + * Checks if the given string is within given ranges. + * @param {string} value The value to check + * @param {string} min The minimum constraint + * @param {string} nax The maximum constraint + * @return {boolean} + */ +export function isInvalid(value, min, max) { + return (min != null && min.length > 0 && value < min) || (max != null && max.length > 0 && value > max); +} + +/** + * Checks if the given string should be disabled. + * @param {number} year The value to check + * @param {string} minYear The minimum constraint + * @param {string} maxYear The maximum constraint + * @return {boolean} + */ +export function isYearDisabled(year, minYear, maxYear) { + return ( + (minYear != null && minYear.length > 0 && year < valueToYearMonth(minYear).year) || + (maxYear != null && maxYear.length > 0 && year > valueToYearMonth(maxYear).year) + ); +} + +/** + * Checks if the given month is allowed for selection. + * @param {string} value The value to check + * @param {string} minYear The minimum constraint + * @param {string} maxYear The maximum constraint + * @return {boolean} + */ +export function monthAllowed(value, minYear, maxYear) { + const invalid = isInvalid(value, minYear, maxYear); + const yearMonth = valueToYearMonth(value); + const year = yearMonth ? yearMonth.year : null; + const disabled = isYearDisabled(year, minYear, maxYear); + return !invalid && !disabled; +} + +/** + * Formats a given YearMonth object into a string based on the defined format. + * Uses the first format in the `i18n.formats` array as the display format. + * @param {YearMonth} yearMonthObject + * @param {object} i18n + * @return {string} + */ +export function formatValue(yearMonthObject, i18n) { + const { year, month } = yearMonthObject; + + const format = i18n.formats[0]; // Use the first format to display + + let result; + + if (format.includes('MMMM')) { + result = format.replace(/MMMM/u, i18n.monthNames[month - 1].padStart(2, '0')); + } else if (!format.includes('MMMM') && format.includes('MMM')) { + result = format.replace(/MMM/u, i18n.monthNamesShort[month - 1].padStart(2, '0')); + } else { + result = format.replace(/MM/u, String(month).padStart(2, '0')).replace(/M/u, String(month)); // Match month (1 or 2 digits) + } + + return result + .replace(/YYYY/u, String(year)) + .replace(/YY/u, String(year - parseInt(year / 100) * 100).padStart(2, '0')); +} + +/** + * Parses a given string into a YearMonth object based on the available formats. + * Accepts multiple formats from `i18n.formats` and normalizes different separators. + * @param {string} inputValue + * @param {object} i18n + * @return {YearMonth} + */ +export function parseValue(inputValue, i18n) { + const { formats } = i18n; + + // Iterate over each format + for (const format of formats) { + // Handle no separator (i.e., continuous format like MMYYYY) + const separator = format.includes('.') + ? '.' + : format.includes('/') + ? '/' + : format.includes('-') + ? '-' + : format.includes(' ') + ? ' ' + : ''; // Handle common separators and space + + const formatUsesLongMonthName = format.includes('MMMM'); + const formatUsesShortMonthName = !formatUsesLongMonthName && format.includes('MMM'); + + // Adjust the regex based on the presence of the separator + let regex; + + // we have to explicitly separate the patterns, otherwise replacing /M/ could lead to issues + // in month names, like "March". + if (formatUsesLongMonthName) { + regex = format.replace(/MMMM/u, `(${i18n.monthNames.join('|')})`); + } else if (formatUsesShortMonthName) { + regex = format.replace(/MMM/u, `(${i18n.monthNamesShort.join('|')})`); + } else { + regex = format.replace(/MM/u, '(\\d{1,2})').replace(/M/u, '(\\d{1})'); // Match month (1 or 2 digits) + } + + // applying year pattern + regex = regex.replace(/YYYY/u, '(\\d{4})').replace(/YY/u, '(\\d{2})'); + + if (separator) { + // Escape the separator for regex if present + regex = regex.replace(new RegExp(`\\${separator}`, 'gu'), `\\${separator}`); + } + + // Check if the input matches the format with the correct separator (if any) + const match = inputValue.match(new RegExp(`^${regex}$`, 'iu')); + + if (match) { + // Get month and year indexes based on format + const monthIndex = format.indexOf('M') < format.indexOf('YY') ? 1 : 2; + const yearIndex = monthIndex === 1 ? 2 : 1; + + let month; + + if (formatUsesLongMonthName) { + month = i18n.monthNames.map((s) => s.toLowerCase()).indexOf(match[monthIndex].toLowerCase()) + 1; + } else if (formatUsesShortMonthName) { + month = i18n.monthNamesShort.map((s) => s.toLowerCase()).indexOf(match[monthIndex].toLowerCase()) + 1; + } else { + month = parseInt(match[monthIndex], 10); + } + + const year = parseInt(match[yearIndex], 10); + // Validate that the parsed month is within the valid range (1-12) + if (month >= 1 && month <= 12) { + return { month, year }; + } + } + } + + return null; +} diff --git a/packages/month-picker/src/vaadin-month-picker-mixin.js b/packages/month-picker/src/vaadin-month-picker-mixin.js new file mode 100644 index 00000000000..99c397f7730 --- /dev/null +++ b/packages/month-picker/src/vaadin-month-picker-mixin.js @@ -0,0 +1,514 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js'; +import { InputMixin } from '@vaadin/field-base/src/input-mixin.js'; +import { + applyRefCentury, + formatValue, + monthAllowed, + parseValue, + toRefCentury, + valueToYearMonth, + yearMonthToValue, +} from './vaadin-month-picker-helpers.js'; + +const DEFAULT_REF_CENTURY = toRefCentury(new Date().getFullYear()); + +const DEFAULT_I18N = { + prevYear: 'Previous year', + nextYear: 'Next year', + monthNames: [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + formats: ['MM.YYYY'], +}; + +/** + * A mixin providing common month picker functionality. + * + * @polymerMixin + */ +export const MonthPickerMixin = (superClass) => + class MonthPickerMixinClass extends I18nMixin(DEFAULT_I18N, InputMixin(superClass)) { + static get properties() { + return { + /** + * Set true to prevent the overlay from opening automatically. + * @attr {boolean} auto-open-disabled + */ + autoOpenDisabled: { + type: Boolean, + sync: true, + }, + + /** + * The earliest year and month that can be selected. + * All earlier years and months will be disabled. + */ + min: { + type: String, + sync: true, + }, + + /** + * The latest year and month that can be selected. + * All later years and months will be disabled. + */ + max: { + type: String, + sync: true, + }, + + /** + * Set true to open the month selector overlay. + */ + opened: { + type: Boolean, + reflectToAttribute: true, + notify: true, + sync: true, + }, + + /** + * When present, it specifies that the field is read-only. + */ + readonly: { + type: Boolean, + value: false, + reflectToAttribute: true, + }, + + /** + * The selected month in `YYYY-MM` format. + */ + value: { + type: String, + notify: true, + value: '', + sync: true, + }, + + /** @private */ + _overlayContent: { + type: Object, + sync: true, + }, + + /** @private */ + _selectedValue: { + type: String, + }, + + /** @private */ + _selectedYearMonth: { + type: Object, + }, + }; + } + + constructor() { + super(); + + this._boundOnClick = this._onClick.bind(this); + } + + /** + * The object used to localize this component. To change the default + * localization, replace this with an object that provides all properties, or + * just the individual properties you want to change. + * + * The object has the following JSON structure and default values: + * ```js + * { + * // An array with the full names of months starting + * // with January. + * monthNames: [ + * 'January', 'February', 'March', 'April', 'May', + * 'June', 'July', 'August', 'September', + * 'October', 'November', 'December' + * ], + * // An array with the short names of months starting + * // with January. + * monthNames: [ + * 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + * 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + * ], + * // Allowed formats. The first one is used as a primary format + * formats: ['MM.YYYY'], + * } + * ``` + * @return {!MonthPickerI18n} + */ + get i18n() { + return super.i18n; + } + + set i18n(value) { + super.i18n = value; + } + + /** @protected */ + ready() { + super.ready(); + + this.addEventListener('click', this._boundOnClick); + } + + /** @protected */ + disconnectedCallback() { + super.disconnectedCallback(); + + this.opened = false; + } + + /** @protected */ + willUpdate(props) { + super.willUpdate(props); + + if (props.has('opened') && this.opened) { + this.__ensureContent(); + } + } + + /** @protected */ + updated(props) { + super.updated(props); + + if ( + props.has('_overlayContent') || + props.has('min') || + props.has('max') || + props.has('i18n') || + props.has('value') + ) { + if (this._overlayContent) { + this._overlayContent.min = this.min; + this._overlayContent.max = this.max; + this._overlayContent.i18n = this.i18n; + this._overlayContent.value = this.value; + } + } + + if (props.has('opened') && this.inputElement) { + this.__updateOpenedYear(); + + this.inputElement.setAttribute('aria-expanded', !!this.opened); + } + } + + /** + * Opens the dropdown. + */ + open() { + if (!this.disabled && !this.readonly) { + this.opened = true; + } + } + + /** + * Closes the dropdown. + */ + close() { + this.opened = false; + } + + /** + * Returns true if the current input value satisfies all constraints (if any) + * + * Override the `checkValidity` method for custom validations. + * + * @return {boolean} True if the value is valid + */ + checkValidity() { + const inputValue = this.inputElement.value; + const selectedValue = this._selectedValue; + + const hasCorrectInput = + !!selectedValue && + this._selectedYearMonth && + inputValue === formatValue(this._selectedYearMonth, this.__effectiveI18n); + + const inputValid = !inputValue || hasCorrectInput; + + const isMonthValid = !selectedValue || monthAllowed(selectedValue, this.min, this.max); + return inputValid && isMonthValid; + } + + /** + * Override an observer from `InputMixin`. + * @protected + * @override + */ + _valueChanged(value, oldVal) { + if (value === '' && oldVal === undefined) { + // Initializing, no need to do anything + // See https://github.com/vaadin/vaadin-combo-box/issues/554 + return; + } + + this._selectedValue = value; + + if (value) { + // Forward value set programmatically to the input + const yearMonth = valueToYearMonth(value); + if (yearMonth) { + this._referenceCentury = toRefCentury(yearMonth.year); + this.inputElement.value = formatValue(yearMonth, this.i18n); + } + } else { + this.inputElement.value = ''; + } + + this.toggleAttribute('has-value', !!value); + } + + /** + * @protected + * @override + */ + _inputElementChanged(input) { + super._inputElementChanged(input); + if (input) { + input.autocomplete = 'off'; + input.setAttribute('role', 'combobox'); + input.setAttribute('aria-haspopup', 'dialog'); + input.setAttribute('aria-expanded', !!this.opened); + } + } + + /** + * Override an event listener from `KeyboardMixin`. + * + * @param {KeyboardEvent} event + * @protected + * @override + */ + _onKeyDown(event) { + super._onKeyDown(event); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.open(); + break; + case 'Tab': + this._onTab(event); + break; + default: + break; + } + } + + /** + * Override an event listener from `KeyboardMixin`. + * + * @param {!KeyboardEvent} event + * @protected + * @override + */ + _onEnter(event) { + // Ignore Enter keydown event bubbling from the overlay + if (event.composedPath().includes(this._overlayContent)) { + return; + } + + if (this.opened) { + event.preventDefault(); + this.opened = false; + } + } + + /** + * Override an event listener from `KeyboardMixin`. + * Do not call `super` in order to override clear + * button logic defined in `InputControlMixin`. + * + * @param {!KeyboardEvent} event + * @protected + * @override + */ + _onEscape(event) { + if (this.opened) { + this.opened = false; + return; + } + + if (this.clearButtonVisible && !!this.value && !this.readonly) { + // Stop event from propagating to the host element + // to avoid closing dialog when clearing on Esc + event.stopPropagation(); + this._onClearButtonClick(event); + } + } + + /** @private */ + _onTab(event) { + if (this.opened) { + event.preventDefault(); + event.stopPropagation(); + // TODO original implementation focused month instead + this._overlayContent._prevButton.focus(); + } + } + + /** @private */ + _onClick(event) { + if (!this._isClearButton(event) && !this.autoOpenDisabled) { + this.open(); + } + } + + /** @protected */ + _onOverlayOpened() { + // Store committed value on open + this._lastCommittedValue = this.value; + } + + /** + * Override an event listener from `InputMixin` to open overlay + * when typing and to not set `value` property immediately. + * @protected + */ + _onInput() { + if (!this.opened && this.inputElement.value && !this.autoOpenDisabled) { + this.open(); + } + } + + /** + * @protected + * @override + */ + _onChange(event) { + // Suppress the native change event fired on the native input. + // We use `_detectAndDispatchChange` to fire a custom event. + event.stopPropagation(); + + const inputValue = this.inputElement.value; + + const parsedYearMonth = parseValue(inputValue, this.i18n); + + if (parsedYearMonth && parsedYearMonth.year) { + if (parsedYearMonth.year < 100) { + parsedYearMonth.year = applyRefCentury(parsedYearMonth.year, this._referenceCentury); + } else { + this._referenceCentury = toRefCentury(parsedYearMonth.year); + } + } else { + // reset the reference century, when the text field is cleared + this._referenceCentury = DEFAULT_REF_CENTURY; + } + + const selectedValue = parsedYearMonth !== null ? yearMonthToValue(parsedYearMonth) : ''; + this.__commitChanges(selectedValue, parsedYearMonth); + + this.__updateOpenedYear(); + } + + /** @protected */ + _isClearButton(event) { + return event.composedPath()[0] === this.clearElement; + } + + /** @private */ + __detectAndDispatchChange() { + if (this.value !== this._lastCommittedValue) { + this.dispatchEvent(new CustomEvent('change', { bubbles: true })); + this._lastCommittedValue = this.value; + } + } + + /** @private */ + __commitChanges(selectedValue, yearMonth) { + if (selectedValue) { + this._selectedYearMonth = yearMonth; + this._selectedValue = selectedValue; + } + + this._requestValidation(); + + if (!this.invalid) { + this.__keepInputValue = false; + this.value = selectedValue || ''; + } else { + // TODO do not discard invalid value, add `unparsable-change` event + this.value = ''; + } + + this.__detectAndDispatchChange(); + } + + /** @private */ + __ensureContent() { + if (this._overlayContent) { + return; + } + + // Create and store document content element + const content = document.createElement('vaadin-month-picker-overlay-content'); + content.owner = this; + content.setAttribute('slot', 'overlay'); + content.i18n = this.i18n; + this.appendChild(content); + + content.addEventListener('month-click', (e) => { + this._onMonthClick(e); + }); + + content.addEventListener('click', (e) => e.stopPropagation()); + + this._overlayContent = content; + } + + /** @private */ + _onMonthClick(e) { + this.opened = false; + + const { value } = e.detail; + + if (this.value !== value) { + const yearMonth = valueToYearMonth(value); + this._referenceCentury = toRefCentury(yearMonth.year); + + this.inputElement.value = formatValue(yearMonth, this.i18n); + this.__commitChanges(value, yearMonth); + } + } + + /** @private */ + __updateOpenedYear() { + if (!this.opened) { + return; + } + + if (this._overlayContent) { + if (this.yearMonth) { + // The current value, if any, should be visible + this._overlayContent.currentYear = this.yearMonth.year; + } else { + // Otherwise, show current year, or the closest year with enabled values + const yearNow = new Date().getFullYear(); + + const adjustByMin = (year) => (this.min ? Math.max(year, valueToYearMonth(this.min).year) : year); + const adjustByMax = (year) => (this.max ? Math.min(year, valueToYearMonth(this.max).year) : year); + + this._overlayContent.currentYear = adjustByMax(adjustByMin(yearNow)); + } + } + } + }; diff --git a/packages/month-picker/src/vaadin-month-picker-overlay-content.js b/packages/month-picker/src/vaadin-month-picker-overlay-content.js new file mode 100644 index 00000000000..ed780628b1f --- /dev/null +++ b/packages/month-picker/src/vaadin-month-picker-overlay-content.js @@ -0,0 +1,325 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import './vaadin-month-picker-button.js'; +import { html, LitElement } from 'lit'; +import { KeyboardMixin } from '@vaadin/a11y-base/src/keyboard-mixin.js'; +import { defineCustomElement } from '@vaadin/component-base/src/define.js'; +import { DirMixin } from '@vaadin/component-base/src/dir-mixin.js'; +import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; +import { SlotController } from '@vaadin/component-base/src/slot-controller.js'; +import { LumoInjectionMixin } from '@vaadin/vaadin-themable-mixin/lumo-injection-mixin.js'; +import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import { monthPickerOverlayContentStyles } from './styles/vaadin-month-picker-overlay-content-base-styles.js'; +import { isInvalid, isYearDisabled, yearMonthToValue } from './vaadin-month-picker-helpers.js'; + +/** + * An element used internally by ``. Not intended to be used separately. + * + * @customElement + * @extends HTMLElement + * @mixes DirMixin + * @mixes KeyboardMixin + * @mixes ThemableMixin + * @private + */ +class MonthPickerOverlayContent extends KeyboardMixin( + DirMixin(ThemableMixin(PolylitMixin(LumoInjectionMixin(LitElement)))), +) { + static get is() { + return 'vaadin-month-picker-overlay-content'; + } + + static get styles() { + return monthPickerOverlayContentStyles; + } + + static get lumoInjector() { + return { + includeBaseStyles: true, + }; + } + + static get properties() { + return { + /** + * The selected month in YYYY-MM format. + */ + value: { + type: String, + }, + + /** + * The object to localize this component. + */ + i18n: { + type: Object, + }, + + /** + * The year currently shown in the calendar. + */ + currentYear: { + type: Number, + value: new Date().getFullYear(), + }, + + /** + * The minimum selectable year + */ + minYear: { + type: String, + value: null, + }, + + /** + * The minimum selectable year + */ + maxYear: { + type: String, + value: null, + }, + + /** + * Reference to the owner (month-picker). + */ + owner: { + type: Object, + }, + }; + } + + /** + * Returns the month button element that should be focused (tabindex=0). + */ + get focusedMonth() { + return this.shadowRoot.querySelector('[part~="month"][tabindex="0"]'); + } + + /** @protected */ + ready() { + super.ready(); + + this.setAttribute('role', 'dialog'); + + this.addController( + new SlotController(this, 'prev-button', 'vaadin-month-picker-button', { + observe: false, + initializer: (btn) => { + btn.addEventListener('click', this._onPrevYearClick.bind(this)); + this._prevButton = btn; + }, + }), + ); + + this._yearLabelController = new SlotController(this, 'year-label', 'span', { + useUniqueId: true, + initializer: (span) => { + const id = this._yearLabelController.defaultId; + span.id = id; + this.setAttribute('aria-labelledby', id); + this._yearLabel = span; + }, + }); + + this.addController(this._yearLabelController); + + this.addController( + new SlotController(this, 'next-button', 'vaadin-month-picker-button', { + observe: false, + initializer: (btn) => { + btn.addEventListener('click', this._onNextYearClick.bind(this)); + this._nextButton = btn; + }, + }), + ); + } + + /** @protected */ + render() { + return html` +
+ + + +
+ +
+ ${this.i18n.monthNamesShort.map((label, index) => { + const value = yearMonthToValue({ + year: this.currentYear, + month: index + 1, + }); + const disabled = isInvalid(value, this.minYear, this.maxYear); + const selected = this.value === value; + + return html` +
+ ${label} +
+ `; + })} +
+ `; + } + + /** @protected */ + updated(props) { + super.updated(props); + + if (props.has('currentYear')) { + this._yearLabel.textContent = this.currentYear; + } + + if (props.has('currentYear') || props.has('minYear') || props.has('maxYear')) { + this._prevButton.disabled = isYearDisabled(this.currentYear - 1, this.minYear, this.maxYear); + this._nextButton.disabled = isYearDisabled(this.currentYear + 1, this.minYear, this.maxYear); + } + + if (props.has('i18n')) { + this._prevButton.setAttribute('aria-label', this.i18n.prevYear); + this._nextButton.setAttribute('aria-label', this.i18n.nextYear); + } + } + + /** @private */ + __computeMonthPart(disabled, selected) { + const result = ['month']; + + if (disabled) { + result.push('disabled-month'); + } + + if (selected) { + result.push('selected-month'); + } + + return result.join(' '); + } + + /** + * @protected + * @override + */ + _onKeyDown(event) { + super._onKeyDown(event); + + if (event.key === 'Tab') { + // Prevent from bubbling to the host + event.stopPropagation(); + + const target = event.composedPath()[0]; + + // Tab / Shift + Tab on previous / next year button + if ((target === this._prevButton && !event.shiftKey) || (target === this._nextButton && event.shiftKey)) { + event.preventDefault(); + // TODO: current behavior is implemented based on the add-on + // where months are only focusable within the current year. + // This can be an a11y issue, consider changing the logic. + if (this.focusedMonth) { + this.focusedMonth.focus(); + } else if (target === this._prevButton && !event.shiftKey) { + this._nextButton.focus(); + } else if (target === this._nextButton && event.shiftKey) { + this._prevButton.focus(); + } + } + + // Tab on next year button, focus the input field + if (target === this._nextButton && !event.shiftKey) { + event.preventDefault(); + this.owner.focus(); + } + + // Tab / Shift + Tab on the month cell + if (target.getAttribute('role') === 'gridcell') { + event.preventDefault(); + event.stopPropagation(); + const button = event.shiftKey ? this._prevButton : this._nextButton; + button.focus(); + } + } + } + + /** @private */ + _onMonthClick(event) { + const { value } = event.target.dataset; + event.preventDefault(); + this.dispatchEvent(new CustomEvent('month-click', { detail: { value } })); + } + + /** @private */ + __onMonthKeyDown(event) { + if (event.key === 'Tab') { + // Tab is handled separately + return; + } + + const months = [...this.shadowRoot.querySelectorAll('[part~="month"]:not([disabled])')]; + + const focusedButton = this.shadowRoot.activeElement; + const currentIndex = months.indexOf(focusedButton); + + if (currentIndex === -1) { + return; + } + + let newIndex = currentIndex; + + // TODO: add RTL support for Arrow Left / Arrow Right + switch (event.key) { + case 'ArrowLeft': + newIndex = currentIndex > 0 ? currentIndex - 1 : currentIndex; + break; + case 'ArrowRight': + newIndex = currentIndex < months.length - 1 ? currentIndex + 1 : currentIndex; + break; + case 'ArrowUp': + newIndex = currentIndex - 4 >= 0 ? currentIndex - 4 : currentIndex; + break; + case 'ArrowDown': + newIndex = currentIndex + 4 < months.length ? currentIndex + 4 : currentIndex; + break; + case 'Enter': + this._onMonthClick(event); + break; + case ' ': + this._onMonthClick(event); + break; + default: + break; + } + + if (newIndex !== currentIndex) { + event.preventDefault(); + months[newIndex].focus(); + } + } + + /** @private */ + _onPrevYearClick() { + this.currentYear -= 1; + } + + /** @private */ + _onNextYearClick() { + this.currentYear += 1; + } +} + +defineCustomElement(MonthPickerOverlayContent); + +export { MonthPickerOverlayContent }; diff --git a/packages/month-picker/src/vaadin-month-picker-overlay.js b/packages/month-picker/src/vaadin-month-picker-overlay.js new file mode 100644 index 00000000000..2bae9ec8b15 --- /dev/null +++ b/packages/month-picker/src/vaadin-month-picker-overlay.js @@ -0,0 +1,62 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { html, LitElement } from 'lit'; +import { defineCustomElement } from '@vaadin/component-base/src/define.js'; +import { DirMixin } from '@vaadin/component-base/src/dir-mixin.js'; +import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; +import { overlayStyles } from '@vaadin/overlay/src/styles/vaadin-overlay-base-styles.js'; +import { OverlayMixin } from '@vaadin/overlay/src/vaadin-overlay-mixin.js'; +import { PositionMixin } from '@vaadin/overlay/src/vaadin-overlay-position-mixin.js'; +import { LumoInjectionMixin } from '@vaadin/vaadin-themable-mixin/lumo-injection-mixin.js'; +import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import { monthPickerOverlayStyles } from './styles/vaadin-month-picker-overlay-base-styles.js'; + +/** + * An element used internally by ``. Not intended to be used separately. + * + * @customElement + * @extends HTMLElement + * @mixes DirMixin + * @mixes OverlayMixin + * @mixes PositionMixin + * @mixes ThemableMixin + * @private + */ +class MonthPickerOverlay extends PositionMixin( + OverlayMixin(DirMixin(ThemableMixin(PolylitMixin(LumoInjectionMixin(LitElement))))), +) { + static get is() { + return 'vaadin-month-picker-overlay'; + } + + static get styles() { + return [overlayStyles, monthPickerOverlayStyles]; + } + + /** + * Override method from OverlayFocusMixin to use owner as content root + * @protected + * @override + */ + get _contentRoot() { + return this.owner; + } + + /** @protected */ + render() { + return html` +
+
+ +
+
+ `; + } +} + +defineCustomElement(MonthPickerOverlay); + +export { MonthPickerOverlay }; diff --git a/packages/month-picker/src/vaadin-month-picker.js b/packages/month-picker/src/vaadin-month-picker.js new file mode 100644 index 00000000000..7414abc5601 --- /dev/null +++ b/packages/month-picker/src/vaadin-month-picker.js @@ -0,0 +1,179 @@ +/** + * @license + * Copyright (c) 2016 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import '@vaadin/input-container/src/vaadin-input-container.js'; +import './vaadin-month-picker-overlay.js'; +import './vaadin-month-picker-overlay-content.js'; +import { html, LitElement } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { defineCustomElement } from '@vaadin/component-base/src/define.js'; +import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; +import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; +import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js'; +import { InputControlMixin } from '@vaadin/field-base/src/input-control-mixin.js'; +import { InputController } from '@vaadin/field-base/src/input-controller.js'; +import { LabelledInputController } from '@vaadin/field-base/src/labelled-input-controller.js'; +import { inputFieldShared } from '@vaadin/field-base/src/styles/input-field-shared-styles.js'; +import { LumoInjectionMixin } from '@vaadin/vaadin-themable-mixin/lumo-injection-mixin.js'; +import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import { monthPickerStyles } from './styles/vaadin-month-picker-base-styles.js'; +import { MonthPickerMixin } from './vaadin-month-picker-mixin.js'; + +/** + * `` is an input field that allows to select a month and a year. + * + * @customElement + * @extends HTMLElement + * @mixes ElementMixin + * @mixes InputControlMixin + * @mixes MonthPickerMixin + * @mixes ThemableMixin + */ +class MonthPicker extends MonthPickerMixin( + InputControlMixin(ThemableMixin(ElementMixin(PolylitMixin(LumoInjectionMixin(LitElement))))), +) { + static get is() { + return 'vaadin-month-picker'; + } + + static get styles() { + return [inputFieldShared, monthPickerStyles]; + } + + static get properties() { + return { + /** @private */ + _positionTarget: { + type: Object, + sync: true, + }, + }; + } + + /** + * Used by `ClearButtonMixin` as a reference to the clear button element. + * @protected + * @return {!HTMLElement} + */ + get clearElement() { + return this.$.clearButton; + } + + /** @protected */ + render() { + return html` +
+
+ + +
+ + + + + + + + +
+ +
+ +
+ +
+
+ + + + + + + `; + } + + /** @protected */ + ready() { + super.ready(); + + this.addController( + new InputController( + this, + (input) => { + this._setInputElement(input); + this._setFocusElement(input); + this.stateTarget = input; + this.ariaTarget = input; + }, + { + // The "search" word is a trick to prevent Safari from enabling AutoFill, + // which is causing click issues: + // https://github.com/vaadin/web-components/issues/6817#issuecomment-2268229567 + uniqueIdPrefix: 'search-input', + }, + ), + ); + this.addController(new LabelledInputController(this.inputElement, this._labelController)); + + this._tooltipController = new TooltipController(this); + this.addController(this._tooltipController); + this._tooltipController.setPosition('top'); + this._tooltipController.setAriaTarget(this.inputElement); + this._tooltipController.setShouldShow((target) => !target.opened); + + this._positionTarget = this.shadowRoot.querySelector('[part="input-field"]'); + + const toggleButton = this.shadowRoot.querySelector('[part~="toggle-button"]'); + toggleButton.addEventListener('mousedown', (e) => e.preventDefault()); + } + + /** @private */ + _onOpenedChanged(event) { + this.opened = event.detail.value; + } + + /** @private */ + _onVaadinOverlayClose(e) { + // Prevent closing the overlay on label element click + const event = e.detail.sourceEvent; + if (event && event.composedPath().includes(this) && !event.composedPath().includes(this._overlayElement)) { + e.preventDefault(); + } + } + + /** @private */ + _toggle(e) { + e.stopPropagation(); + if (this.$.overlay.opened) { + this.close(); + } else { + this.open(); + } + } +} + +defineCustomElement(MonthPicker); + +export { MonthPicker }; diff --git a/packages/month-picker/vaadin-month-picker.d.ts b/packages/month-picker/vaadin-month-picker.d.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/month-picker/vaadin-month-picker.js b/packages/month-picker/vaadin-month-picker.js new file mode 100644 index 00000000000..b83df237804 --- /dev/null +++ b/packages/month-picker/vaadin-month-picker.js @@ -0,0 +1 @@ +import './src/vaadin-month-picker.js'; diff --git a/packages/vaadin-lumo-styles/components/index.css b/packages/vaadin-lumo-styles/components/index.css index 1e3c00890c7..4945574db0d 100644 --- a/packages/vaadin-lumo-styles/components/index.css +++ b/packages/vaadin-lumo-styles/components/index.css @@ -47,6 +47,7 @@ @import './menu-bar.css'; @import './message-input.css'; @import './message-list.css'; +@import './month-picker.css'; @import './multi-select-combo-box.css'; @import './notification.css'; @import './number-field.css'; diff --git a/packages/vaadin-lumo-styles/components/month-picker.css b/packages/vaadin-lumo-styles/components/month-picker.css new file mode 100644 index 00000000000..a475782551d --- /dev/null +++ b/packages/vaadin-lumo-styles/components/month-picker.css @@ -0,0 +1,46 @@ +/** + * @license + * Copyright (c) 2017 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +@import '../src/mixins/field-base.css'; +@import '../src/mixins/field-button.css'; +@import '../src/mixins/field-error-message.css'; +@import '../src/mixins/field-helper.css'; +@import '../src/mixins/field-label.css'; +@import '../src/mixins/field-required.css'; +@import '../src/mixins/menu-overlay-core.css'; +@import '../src/mixins/overlay.css'; +@import '../src/components/button.css'; +@import '../src/components/month-picker-button.css'; +@import '../src/components/month-picker-overlay-content.css'; +@import '../src/components/month-picker-overlay.css'; +@import '../src/components/month-picker.css'; +@import './input-container.css'; + +:root::before, +:host::before { + --_lumo-vaadin-month-picker-inject: 1; + --_lumo-vaadin-month-picker-inject-modules: + lumo_mixins_field-label, + lumo_mixins_field-required, + lumo_mixins_field-error-message, + lumo_mixins_field-button, + lumo_mixins_field-helper, + lumo_mixins_field-base, + lumo_components_month-picker; + + --_lumo-vaadin-month-picker-button-inject: 1; + --_lumo-vaadin-month-picker-button-inject-modules: + lumo_components_button, + lumo_components_month-picker-button; + + --_lumo-vaadin-month-picker-overlay-inject: 1; + --_lumo-vaadin-month-picker-overlay-inject-modules: + lumo_mixins_overlay, + lumo_mixins_menu-overlay-core, + lumo_components_month-picker-overlay; + + --_lumo-vaadin-month-picker-overlay-content-inject: 1; + --_lumo-vaadin-month-picker-overlay-content-inject-modules: lumo_components_month-picker-overlay-content; +} diff --git a/packages/vaadin-lumo-styles/src/components/month-picker-button.css b/packages/vaadin-lumo-styles/src/components/month-picker-button.css new file mode 100644 index 00000000000..08a4004a446 --- /dev/null +++ b/packages/vaadin-lumo-styles/src/components/month-picker-button.css @@ -0,0 +1,44 @@ +/** + * @license + * Copyright (c) 2017 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +@media lumo_components_month-picker-button { + :host { + display: block; + min-width: auto; + margin: 0; + padding: 0; + font-size: var(--lumo-icon-size-m); + width: var(--lumo-size-m); + height: var(--lumo-size-m); + position: relative; + } + + [part='icon'] { + display: block; + font-family: 'lumo-icons'; + width: inherit; + height: inherit; + } + + [part='icon']::before { + display: block; + width: var(--lumo-size-m); + height: var(--lumo-size-m); + line-height: var(--lumo-size-m); + text-align: center; + position: absolute; + inset: 0; + } + + :host([slot^='prev']:not([dir='rtl'])) [part='icon']::before, + :host([slot^='next'][dir='rtl']) [part='icon']::before { + content: var(--lumo-icons-chevron-left); + } + + :host(:not([dir='rtl'])[slot^='next']) [part='icon']::before, + :host([dir='rtl'][slot^='prev']) [part='icon']::before { + content: var(--lumo-icons-chevron-right); + } +} diff --git a/packages/vaadin-lumo-styles/src/components/month-picker-overlay-content.css b/packages/vaadin-lumo-styles/src/components/month-picker-overlay-content.css new file mode 100644 index 00000000000..ffdbc6d272e --- /dev/null +++ b/packages/vaadin-lumo-styles/src/components/month-picker-overlay-content.css @@ -0,0 +1,38 @@ +/** + * @license + * Copyright (c) 2017 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +@media lumo_components_month-picker-overlay-content { + :host { + font-family: var(--lumo-font-family); + font-size: var(--lumo-font-size-m); + --_focus-ring-color: var(--vaadin-focus-ring-color, var(--lumo-primary-color-50pct)); + --_focus-ring-width: var(--vaadin-focus-ring-width, 2px); + } + + [part='month-grid'] { + min-width: var(--vaadin-month-picker-month-width, calc(var(--lumo-size-xl) * 4)); + gap: var(--lumo-space-xs); + } + + [part~='month'] { + border-radius: var(--lumo-border-radius-m); + width: var(--vaadin-month-picker-month-width, var(--lumo-size-xl)); + height: var(--vaadin-month-picker-month-height, var(--lumo-size-m)); + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + user-select: none; + } + + [part~='month']:focus-visible { + box-shadow: + 0 0 0 1px var(--lumo-base-color), + 0 0 0 calc(var(--_focus-ring-width) + 1px) var(--_focus-ring-color); + } + + [part~='selected-month'] { + color: var(--lumo-primary-contrast-color); + background-color: var(--vaadin-selection-color, var(--lumo-primary-color)); + } +} diff --git a/packages/vaadin-lumo-styles/src/components/month-picker-overlay.css b/packages/vaadin-lumo-styles/src/components/month-picker-overlay.css new file mode 100644 index 00000000000..94574a4c12c --- /dev/null +++ b/packages/vaadin-lumo-styles/src/components/month-picker-overlay.css @@ -0,0 +1,24 @@ +/** + * @license + * Copyright (c) 2017 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +@media lumo_components_month-picker-overlay { + [part='content'] { + padding: var(--lumo-space-s); + } + + :host([top-aligned]) [part='overlay'] { + margin-top: var(--lumo-space-xs); + } + + :host([bottom-aligned]) [part='overlay'] { + margin-bottom: var(--lumo-space-xs); + } + + @media (forced-colors: active) { + [part='overlay'] { + outline: 3px solid; + } + } +} diff --git a/packages/vaadin-lumo-styles/src/components/month-picker.css b/packages/vaadin-lumo-styles/src/components/month-picker.css new file mode 100644 index 00000000000..c353efe3f9a --- /dev/null +++ b/packages/vaadin-lumo-styles/src/components/month-picker.css @@ -0,0 +1,35 @@ +/** + * @license + * Copyright (c) 2017 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +@media lumo_components_month-picker { + [part~='toggle-button']::before { + content: var(--lumo-icons-calendar); + } + + [part~='clear-button']::before { + content: var(--lumo-icons-cross); + } + + :host([opened]) { + pointer-events: auto; + } + + :host([dir='rtl']) [part='input-field'] { + direction: ltr; + } + + :host([dir='rtl']) [part='input-field'] ::slotted(input)::placeholder { + direction: rtl; + text-align: left; + } + + :host([dir='rtl']) [part='input-field'] ::slotted(input) { + --_lumo-text-field-overflow-mask-image: linear-gradient(to left, transparent, #000 1.25em); + } + + :host([dir='rtl']) [part='input-field'] ::slotted(input:placeholder-shown) { + --_lumo-text-field-overflow-mask-image: none; + } +}