diff --git a/.circleci/config.yml b/.circleci/config.yml index 6278e07e253..bb45cbc278d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ executors: parameters: current_golden_images_hash: type: string - default: c70f3ed57c4e3536313872fd3d23510caa801a44 + default: 07a5cba6726f993d5ddc38a9c30e9c9e3e94ebc7 wireit_cache_name: type: string default: wireit diff --git a/packages/calendar/package.json b/packages/calendar/package.json index 843908042fb..14614ba5a82 100644 --- a/packages/calendar/package.json +++ b/packages/calendar/package.json @@ -69,7 +69,8 @@ "@spectrum-web-components/reactive-controllers": "^0.41.2" }, "devDependencies": { - "@spectrum-css/calendar": "^4.2.4" + "@spectrum-css/calendar": "^4.2.4", + "@spectrum-web-components/story-decorator": "^0.41.2" }, "types": "./src/index.d.ts", "customElements": "custom-elements.json", diff --git a/packages/calendar/src/Calendar.ts b/packages/calendar/src/Calendar.ts index 61f18ca4cab..cc48871e492 100644 --- a/packages/calendar/src/Calendar.ts +++ b/packages/calendar/src/Calendar.ts @@ -162,6 +162,7 @@ export class Calendar extends SpectrumElement { class="spectrum-Calendar-title" aria-live="assertive" aria-atomic="true" + data-test-id="calendar-title" > ${monthAndYear} @@ -176,6 +177,7 @@ export class Calendar extends SpectrumElement { aria-label="Previous" title="Previous" class="spectrum-Calendar-prevMonth" + data-test-id="prev-btn" ?disabled=${this.disabled} @click=${this.handlePreviousMonth} > @@ -196,6 +198,7 @@ export class Calendar extends SpectrumElement { aria-label="Next" title="Next" class="spectrum-Calendar-nextMonth" + data-test-id="next-btn" ?disabled=${this.disabled} @click=${this.handleNextMonth} > @@ -311,12 +314,13 @@ export class Calendar extends SpectrumElement { class="spectrum-Calendar-tableCell" title=${currentDayTitle} tabindex=${ifDefined(!isOutsideMonth ? '-1' : undefined)} - aria-disabled=${isOutsideMonth || this.disabled} + aria-disabled=${isOutsideMonth || isDisabled} aria-selected=${isSelected} > this.handleDayClick(calendarDate)} > ${this.formatNumber(calendarDate.day)} @@ -361,8 +365,9 @@ export class Calendar extends SpectrumElement { * defined location (Sunday, Monday, etc.) */ private setWeekdays(): void { + const weekStart = startOfWeek(this.currentDate, this.locale); + this.weekdays = [...new Array(daysInWeek).keys()].map((dayIndex) => { - const weekStart = startOfWeek(this.currentDate, this.locale); const date = weekStart.add({ days: dayIndex }); return { diff --git a/packages/calendar/stories/calendar.stories.ts b/packages/calendar/stories/calendar.stories.ts index 73c2512e93e..1baadee86ce 100644 --- a/packages/calendar/stories/calendar.stories.ts +++ b/packages/calendar/stories/calendar.stories.ts @@ -9,112 +9,36 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { html, TemplateResult } from '@spectrum-web-components/base'; -import { ifDefined } from '@spectrum-web-components/base/src/directives.js'; +import { html, render, TemplateResult } from '@spectrum-web-components/base'; +import { defaultLocale } from '@spectrum-web-components/story-decorator/src/StoryDecorator.js'; import { spreadProps } from '../../../test/lit-helpers.js'; import '@spectrum-web-components/calendar/sp-calendar.js'; -import '@spectrum-web-components/theme/sp-theme.js'; - -const locales = [ - 'cs-CZ', - 'cy-GB', - 'da-DK', - 'de-DE', - 'en-GB', - 'en-US', - 'es-ES', - 'fi-FI', - 'fr-FR', - 'hu-HU', - 'it-IT', - 'ja-JP', - 'ko-KR', - 'nb-NO', - 'nl-NL', - 'pl-PL', - 'pt-BR', - 'ru-RU', - 'sv-SE', - 'tr-TR', - 'uk-UA', - 'zh-Hans-CN', - 'zh-Hans-CN-u-nu-hanidec', - 'zh-Hant-TW', - 'zz-ZY', - 'zz-ZZ', -] as const; - -const defaultLocale = 'en-US'; - -const hiddenProperty = { - table: { - disable: true, - }, -}; export default { title: 'Calendar', component: 'sp-calendar', - - argTypes: { - lang: { - options: locales, - control: { - type: 'select', - }, - table: { - defaultValue: { - summary: defaultLocale, - }, - }, - }, - - // Don't render private properties and getters in the Storybook UI - currentDate: { ...hiddenProperty }, - minDate: { ...hiddenProperty }, - maxDate: { ...hiddenProperty }, - weeksInCurrentMonth: { ...hiddenProperty }, - weekdays: { ...hiddenProperty }, - languageResolver: { ...hiddenProperty }, - timeZone: { ...hiddenProperty }, - locale: { ...hiddenProperty }, - today: { ...hiddenProperty }, - - // Inherited - _dirParent: { ...hiddenProperty }, - shadowRoot: { ...hiddenProperty }, - dir: { ...hiddenProperty }, - isLTR: { ...hiddenProperty }, - }, - - args: { - lang: defaultLocale, - }, - parameters: { - controls: { - // Hide "This story is not configured to handle controls" warning - hideNoControlsWarning: true, - }, actions: { handles: ['onChange'], }, }, }; -interface StoryArgs { - lang?: string; - - padded?: boolean; - disabled?: boolean; +type ComponentArgs = { selectedDate?: Date; min?: Date; max?: Date; + padded?: boolean; + disabled?: boolean; +}; - onChange?: (date: Date) => void; +type StoryArgs = ComponentArgs & { + onChange?: (dateTime: Date) => void; +}; +interface SpreadStoryArgs { [prop: string]: unknown; } @@ -122,64 +46,34 @@ const renderCalendar = ( title: string, args: StoryArgs = {} ): TemplateResult => { - return html` - -

${title}

-

- Locale: - ${args.lang} -

- -
- - -
+ const story = html` +

${title}

+
+ `; -}; -export const Default = (args: StoryArgs = {}): TemplateResult => { - return renderCalendar('Default', args); -}; + const randomId = Math.floor(Math.random() * 99999); -export const padded = (args: StoryArgs = {}): TemplateResult => { - return renderCalendar(`Padded? ${args.padded}`, args); -}; - -padded.argTypes = { - padded: { - control: 'boolean', - table: { - defaultValue: { - summary: true, - }, - }, - }, -}; - -padded.args = { - padded: true, -}; + requestAnimationFrame(() => { + const container = document.querySelector( + `.story-container-${randomId}` + ); -export const disabled = (args: StoryArgs = {}): TemplateResult => { - return renderCalendar(`Disabled? ${args.disabled}`, args); -}; + if (container) { + render(story, container as HTMLElement); + } + }); -disabled.argTypes = { - disabled: { - control: 'boolean', - table: { - defaultValue: { - summary: true, - }, - }, - }, + return html` +
+ `; }; -disabled.args = { - disabled: true, +export const Default = (args: StoryArgs = {}): TemplateResult => { + return renderCalendar('Default', args); }; export const selectedDate = (args: StoryArgs = {}): TemplateResult => { @@ -241,3 +135,41 @@ export const maximumDate = (args: StoryArgs = {}): TemplateResult => { return renderCalendar(`Maximum Date: ${formatted}`, args); }; + +export const disabled = (args: StoryArgs = {}): TemplateResult => { + return renderCalendar(`Disabled? ${args.disabled}`, args); +}; + +disabled.argTypes = { + disabled: { + control: 'boolean', + table: { + defaultValue: { + summary: true, + }, + }, + }, +}; + +disabled.args = { + disabled: true, +}; + +export const padded = (args: StoryArgs = {}): TemplateResult => { + return renderCalendar(`Padded? ${args.padded}`, args); +}; + +padded.argTypes = { + padded: { + control: 'boolean', + table: { + defaultValue: { + summary: true, + }, + }, + }, +}; + +padded.args = { + padded: true, +}; diff --git a/packages/calendar/test/calendar.test.ts b/packages/calendar/test/calendar.test.ts index 9bc72ba3af8..6b7e1b3b337 100644 --- a/packages/calendar/test/calendar.test.ts +++ b/packages/calendar/test/calendar.test.ts @@ -10,12 +10,24 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import sinon, { spy } from 'sinon'; -import '../sp-calendar.js'; -import { Calendar } from '..'; import { testForLitDevWarnings } from '../../../test/testing-helpers.js'; +import { Calendar } from '@spectrum-web-components/calendar'; + +import '@spectrum-web-components/calendar/sp-calendar.js'; +import '@spectrum-web-components/theme/sp-theme.js'; + +const DEFAULT_LOCALE = 'en-US'; +const CALENDAR_TITLE_SELECTOR = '[data-test-id="calendar-title"]'; +const NEXT_BUTTON_SELECTOR = '[data-test-id="next-btn"]'; +const PREV_BUTTON_SELECTOR = '[data-test-id="prev-btn"]'; +const FIRST_ACTIVE_DAY_SELECTOR = + '[data-test-id="calendar-day"]:not(:is(.is-disabled, .is-outsideMonth))'; describe('Calendar', () => { + const sandbox = sinon.createSandbox(); + testForLitDevWarnings( async () => await fixture( @@ -24,15 +36,136 @@ describe('Calendar', () => { ` ) ); + + async function getCalendar({ + locale = DEFAULT_LOCALE, + disabled = false, + } = {}): Promise { + const wrapped = await fixture(html` + + + + `); + const el = wrapped.querySelector('sp-calendar') as Calendar; + await elementUpdated(el); + return el; + } + + beforeEach(() => { + // Use this date as the current date for running the tests + sandbox.stub(Date, 'now').returns(new Date('2022-05-20').valueOf()); + }); + + afterEach(() => { + sandbox.restore(); + }); + it('loads default calendar accessibly', async () => { - const el = await fixture( - html` - - ` - ); + const el = await getCalendar(); await elementUpdated(el); await expect(el).to.be.accessible(); }); + + it('should render disabled calendar cells', async () => { + const el = await getCalendar({ disabled: true }); + + await elementUpdated(el); + + const items = el.shadowRoot.querySelectorAll('td span'); + + items.forEach((item) => { + expect(item.classList.contains('is-disabled')).to.be.true; + }); + }); + + it('should call "@change" event', async () => { + const changeSpy = spy(); + const el = await getCalendar(); + + const dayEl = el.shadowRoot.querySelector( + FIRST_ACTIVE_DAY_SELECTOR + ); + + el.addEventListener('change', changeSpy); + dayEl?.click(); + + await elementUpdated(el); + + expect(changeSpy).to.be.calledOnce; + }); + + const testCases = [ + { + locale: 'en-US', + current: 'May 2022', + next: 'June 2022', + prev: 'April 2022', + }, + { + locale: 'pt-BR', + current: 'maio de 2022', + next: 'junho de 2022', + prev: 'abril de 2022', + }, + { + locale: 'ko-KR', + current: '2022년 5월', + next: '2022년 6월', + prev: '2022년 4월', + }, + ]; + + testCases.forEach(({ locale, current, next, prev }) => { + describe(`given the locale is "${locale}"`, () => { + let el: Element; + let titleEl: HTMLElement | null | undefined; + + beforeEach(async () => { + el = await getCalendar({ locale }); + + titleEl = el.shadowRoot?.querySelector( + CALENDAR_TITLE_SELECTOR + ); + }); + + it('should render calendar with correct month and year', async () => { + expect( + titleEl?.innerHTML, + `Title of current month when locale is "${locale}"` + ).to.contain(current); + }); + + it('should update the title indicating the next month when clicking the "Next" button', async () => { + const nextBtn = + el.shadowRoot?.querySelector( + NEXT_BUTTON_SELECTOR + ); + + nextBtn?.click(); + await elementUpdated(el); + + expect( + titleEl?.innerHTML, + `Title of next month when locale is "${locale}"` + ).to.contain(next); + }); + + it('should update the title indicating the previous month when clicking the "Previous" button', async () => { + const prevBtn = + el.shadowRoot?.querySelector( + PREV_BUTTON_SELECTOR + ); + + prevBtn?.click(); + await elementUpdated(el); + + expect( + titleEl?.innerHTML, + `Title of previous month when locale is "${locale}"` + ).to.contain(prev); + }); + }); + }); }); diff --git a/packages/date-time-picker/README.md b/packages/date-time-picker/README.md new file mode 100644 index 00000000000..3552431090e --- /dev/null +++ b/packages/date-time-picker/README.md @@ -0,0 +1,36 @@ +## Description + +### Usage + +[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/date-time-picker?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/date-time-picker) +[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/date-time-picker?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/date-time-picker) + +``` +yarn add @spectrum-web-components/date-time-picker +``` + +Import the side effectful registration of `` via: + +``` +import '@spectrum-web-components/date-time-picker/sp-date-time-picker.js'; +``` + +When looking to leverage the `DateTimePicker` base class as a type and/or for extension purposes, do so via: + +``` +import { DateTimePicker } from '@spectrum-web-components/date-time-picker'; +``` + +## Example + +```html + +``` + +## To-do list + +- Enable "receives-focus" when calendar is navigable via keyboard +- Complete documentation +- Add/Review unit tests +- Review https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-datepicker/ - we likely want this to trap the tab order. +- Load dependencies lazily when possible diff --git a/packages/date-time-picker/exports.json b/packages/date-time-picker/exports.json new file mode 100644 index 00000000000..b97b68e0fc0 --- /dev/null +++ b/packages/date-time-picker/exports.json @@ -0,0 +1,4 @@ +{ + "./src/*": "./src/*.js", + "./sp-date-time-picker.js": "./sp-date-time-picker.js" +} diff --git a/packages/time-field/package.json b/packages/date-time-picker/package.json similarity index 58% rename from packages/time-field/package.json rename to packages/date-time-picker/package.json index 9f3b7b6d9da..557ee22e595 100644 --- a/packages/time-field/package.json +++ b/packages/date-time-picker/package.json @@ -1,18 +1,18 @@ { - "name": "@spectrum-web-components/time-field", + "name": "@spectrum-web-components/date-time-picker", "version": "0.0.1", "publishConfig": { "access": "public" }, - "description": "Web component implementation of a Spectrum design TimeField", + "description": "Web component implementation of a Spectrum design DateTimePicker", "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/adobe/spectrum-web-components.git", - "directory": "packages/time-field" + "directory": "packages/date-time-picker" }, "author": "", - "homepage": "https://adobe.github.io/spectrum-web-components/components/time-field", + "homepage": "https://adobe.github.io/spectrum-web-components/components/date-time-picker", "bugs": { "url": "https://github.com/adobe/spectrum-web-components/issues" }, @@ -25,18 +25,18 @@ "default": "./src/index.js" }, "./package.json": "./package.json", - "./src/TimeField.js": { - "development": "./src/TimeField.dev.js", - "default": "./src/TimeField.js" + "./src/DateTimePicker.js": { + "development": "./src/DateTimePicker.dev.js", + "default": "./src/DateTimePicker.js" }, + "./src/date-time-picker.css.js": "./src/date-time-picker.css.js", "./src/index.js": { "development": "./src/index.dev.js", "default": "./src/index.js" }, - "./src/time-field.css.js": "./src/time-field.css.js", - "./sp-time-field.js": { - "development": "./sp-time-field.dev.js", - "default": "./sp-time-field.js" + "./sp-date-time-picker.js": { + "development": "./sp-date-time-picker.dev.js", + "default": "./sp-date-time-picker.js" } }, "scripts": { @@ -57,7 +57,16 @@ "lit-html" ], "dependencies": { - "@spectrum-web-components/input-segments": "^0.0.1" + "@spectrum-web-components/calendar": "^0.0.1", + "@spectrum-web-components/field-label": "^0.41.2", + "@spectrum-web-components/icons-workflow": "^0.41.2", + "@spectrum-web-components/input-segments": "^0.0.1", + "@spectrum-web-components/overlay": "^0.41.2", + "@spectrum-web-components/picker-button": "^0.41.2", + "@spectrum-web-components/popover": "^0.41.2" + }, + "devDependencies": { + "@spectrum-web-components/story-decorator": "^0.41.2" }, "types": "./src/index.d.ts", "customElements": "custom-elements.json", diff --git a/packages/time-field/sp-time-field.ts b/packages/date-time-picker/sp-date-time-picker.ts similarity index 79% rename from packages/time-field/sp-time-field.ts rename to packages/date-time-picker/sp-date-time-picker.ts index 5f998e4506e..b8909a8951d 100644 --- a/packages/time-field/sp-time-field.ts +++ b/packages/date-time-picker/sp-date-time-picker.ts @@ -9,12 +9,12 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { TimeField } from './src/TimeField.js'; +import { DateTimePicker } from './src/DateTimePicker.js'; -customElements.define('sp-time-field', TimeField); +customElements.define('sp-date-time-picker', DateTimePicker); declare global { interface HTMLElementTagNameMap { - 'sp-time-field': TimeField; + 'sp-date-time-picker': DateTimePicker; } } diff --git a/packages/date-time-picker/src/DateTimePicker.ts b/packages/date-time-picker/src/DateTimePicker.ts new file mode 100644 index 00000000000..1295d2794ee --- /dev/null +++ b/packages/date-time-picker/src/DateTimePicker.ts @@ -0,0 +1,159 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { + CSSResultArray, + html, + TemplateResult, +} from '@spectrum-web-components/base'; +import { + property, + state, +} from '@spectrum-web-components/base/src/decorators.js'; +import { InputSegments } from '@spectrum-web-components/input-segments'; + +import styles from './date-time-picker.css.js'; + +// TODO: Load dependencies lazily when possible +import '@spectrum-web-components/calendar/sp-calendar.js'; +import '@spectrum-web-components/icons-workflow/icons/sp-icon-calendar.js'; +import '@spectrum-web-components/overlay/sp-overlay.js'; +import '@spectrum-web-components/picker-button/sp-picker-button.js'; +import '@spectrum-web-components/popover/sp-popover.js'; + +/** + * @element sp-date-time-picker + * + * @event change - Announces when a new date/time is defined by emitting a `Date` object + * + * @slot calendar-icon - The icon used in the calendar button + * @slot help-text - Default or non-negative help text to associate to your form element + * @slot negative-help-text - Negative help text to associate to your form element when `invalid` + */ +export class DateTimePicker extends InputSegments { + public static override get styles(): CSSResultArray { + return [...super.styles, styles]; + } + + /** + * Indicates whether the picker should be displayed or not + */ + @property({ type: Boolean, reflect: true }) + public open = false; + + @state() + override includeDate = true; + + @state() + override includeTime = true; + + @state() + private pickerDate?: Date; + + protected override renderField(): TemplateResult { + return html` + ${this.renderInputContent()} ${this.renderPicker()} + `; + } + + public renderPicker(): TemplateResult { + const isDisabled = this.disabled || this.readonly; + + return html` +
+ ${this.renderStateIcons()} + + + + + + + + + + +
+ +
+
+
+
+ `; + } + + public showPicker(): void { + this.pickerDate = this.getDateFromSegments(); + this.open = true; + } + + public hidePicker(): void { + this.setNewDateTime(); + this.emitNewDateTime(); + + this.pickerDate = undefined; + this.open = false; + } + + /** + * Updates the value of the internal property used to define the date used by the calendar with the value received + * + * @param event - Event with the value emitted by the calendar + */ + private handleDate(event: CustomEvent): void { + event.stopPropagation(); + + const dateTime = event.detail; + + if (dateTime) { + this.updateCurrentDate(dateTime); + } + } + + /** + * Updates the segments using the `Date` object emitted by the calendar inside the picker + */ + private updateCurrentDate(dateTime: Date): void { + this.pickerDate = dateTime; + + const { year, month, day } = this.dateToCalendarDateTime(dateTime); + + if (!this.yearSegment || !this.monthSegment || !this.daySegment) { + return; + } + + this.yearSegment.value = year; + this.formatValue(this.yearSegment); + + this.monthSegment.value = month; + this.formatValue(this.monthSegment); + + this.daySegment.value = day; + this.formatValue(this.daySegment); + + this.requestUpdate(); + } +} diff --git a/packages/date-time-picker/src/date-time-picker.css b/packages/date-time-picker/src/date-time-picker.css new file mode 100644 index 00000000000..7170e84b69a --- /dev/null +++ b/packages/date-time-picker/src/date-time-picker.css @@ -0,0 +1,104 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +:host { + --status-icon-size: var( + --mod-textfield-icon-size-invalid, + var(--spectrum-textfield-icon-size-invalid) + ); + --picker-button-size: var( + --mod-textfield-height, + var(--spectrum-textfield-height) + ); + --input-spacing: var( + --mod-textfield-spacing-inline, + var(--spectrum-textfield-spacing-inline) + ); + --input-border: var( + --mod-textfield-border-width, + var(--spectrum-textfield-border-width) + ); + + inline-size: auto; +} + +:host([valid]) #textfield .icon, +:host([invalid]) #textfield .icon { + position: relative; + inset: initial; +} + +:host .input, +:host([quiet]) .input, +:host([valid]) .input, +:host([invalid]) .input, +:host([quiet][valid]) #textfield .input, +:host([quiet][invalid]) #textfield .input { + padding-inline-end: calc( + var(--status-icon-size) + var(--picker-button-size) + + (var(--input-spacing) - var(--input-border)) * 2 + ); +} + +.picker { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + display: flex; + align-items: center; + justify-content: flex-end; + height: 100%; + gap: calc(var(--input-spacing) - var(--input-border)); +} + +.picker-button[invalid] { + --mod-picker-button-border-color: var( + --spectrum-textfield-border-color-invalid-default + ); +} + +.picker-button[invalid]:focus { + --mod-picker-button-border-color: var( + --spectrum-textfield-border-color-invalid-focus + ); +} + +.picker-button[invalid]:focus-visible { + --mod-picker-button-border-color: var( + --spectrum-textfield-border-color-invalid-keyboard-focus + ); +} + +.picker-button[invalid]:hover { + --mod-picker-button-border-color: var( + --spectrum-textfield-border-color-hover + ); +} + +.picker-button[invalid]:focus:hover { + --mod-picker-button-border-color: var( + --spectrum-textfield-border-color-invalid-focus-hover + ); +} + +:host([invalid]:hover) .picker-button[invalid] { + --mod-picker-button-border-color: var( + --spectrum-textfield-border-color-hover + ); +} + +.popover { + overflow-y: auto; +} + +.popover-content { + padding: var(--spectrum-spacing-300); +} diff --git a/packages/time-field/src/index.ts b/packages/date-time-picker/src/index.ts similarity index 94% rename from packages/time-field/src/index.ts rename to packages/date-time-picker/src/index.ts index 2c5490778da..543d0a07f8b 100644 --- a/packages/time-field/src/index.ts +++ b/packages/date-time-picker/src/index.ts @@ -9,4 +9,4 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -export * from './TimeField.js'; +export * from './DateTimePicker.js'; diff --git a/packages/time-field/stories/time-field.stories.ts b/packages/date-time-picker/stories/date-time-picker.stories.ts similarity index 50% rename from packages/time-field/stories/time-field.stories.ts rename to packages/date-time-picker/stories/date-time-picker.stories.ts index a176b56ec3f..4a5d46f54e7 100644 --- a/packages/time-field/stories/time-field.stories.ts +++ b/packages/date-time-picker/stories/date-time-picker.stories.ts @@ -9,182 +9,132 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { html, nothing, TemplateResult } from '@spectrum-web-components/base'; +import { + css, + CSSResult, + html, + nothing, + render, + TemplateResult, + unsafeCSS, +} from '@spectrum-web-components/base'; +import { + ifDefined, + when, +} from '@spectrum-web-components/base/src/directives.js'; import { TimeGranularity } from '@spectrum-web-components/input-segments/src/types.js'; +import { defaultLocale } from '@spectrum-web-components/story-decorator/src/StoryDecorator.js'; import { spreadProps } from '../../../test/lit-helpers.js'; +import '@spectrum-web-components/date-time-picker/sp-date-time-picker.js'; import '@spectrum-web-components/help-text/sp-help-text.js'; -import '@spectrum-web-components/time-field/sp-time-field.js'; -import '@spectrum-web-components/theme/sp-theme.js'; - -const locales = [ - 'cs-CZ', - 'cy-GB', - 'da-DK', - 'de-DE', - 'en-GB', - 'en-US', - 'es-ES', - 'fi-FI', - 'fr-FR', - 'hu-HU', - 'it-IT', - 'ja-JP', - 'ko-KR', - 'nb-NO', - 'nl-NL', - 'pl-PL', - 'pt-BR', - 'ru-RU', - 'sv-SE', - 'tr-TR', - 'uk-UA', - 'zh-Hans-CN', - 'zh-Hans-CN-u-nu-hanidec', - 'zh-Hant-TW', - 'zz-ZY', - 'zz-ZZ', -] as const; - -const defaultLocale = 'en-US'; - -const timeGranularities: TimeGranularity[] = ['hour', 'minute', 'second']; - -const hiddenProperty = { - table: { - disable: true, - }, -}; +import '@spectrum-web-components/icons-workflow/icons/sp-icon-alert.js'; export default { - title: 'Time Field', - component: 'sp-time-field', - + title: 'Date∕Time Picker', + component: 'sp-date-time-picker', + parameters: { + actions: { + handles: ['onChange'], + }, + }, argTypes: { - lang: { - options: locales, - control: { - type: 'select', + valid: { + control: 'boolean', + table: { + defaultValue: { + summary: false, + }, }, + }, + invalid: { + control: 'boolean', table: { defaultValue: { - summary: defaultLocale, + summary: false, }, }, }, - - // Don't render private properties and getters in the Storybook UI - firstEditableSegment: { ...hiddenProperty }, - includeDate: { ...hiddenProperty }, - includeTime: { ...hiddenProperty }, - previousLocale: { ...hiddenProperty }, - currentDateTime: { ...hiddenProperty }, - newDateTime: { ...hiddenProperty }, - segments: { ...hiddenProperty }, - createSegments: { ...hiddenProperty }, - languageResolver: { ...hiddenProperty }, - timeZone: { ...hiddenProperty }, - formatter: { ...hiddenProperty }, - locale: { ...hiddenProperty }, - daySegment: { ...hiddenProperty }, - monthSegment: { ...hiddenProperty }, - yearSegment: { ...hiddenProperty }, - hourSegment: { ...hiddenProperty }, - minuteSegment: { ...hiddenProperty }, - secondSegment: { ...hiddenProperty }, - dayPeriodSegment: { ...hiddenProperty }, - is12HourClock: { ...hiddenProperty }, - - // Inherited - _dirParent: { ...hiddenProperty }, - shadowRoot: { ...hiddenProperty }, - dir: { ...hiddenProperty }, - isLTR: { ...hiddenProperty }, - 'allowed-keys': { ...hiddenProperty }, - allowedKeys: { ...hiddenProperty }, - autocomplete: { ...hiddenProperty }, - displayValue: { ...hiddenProperty }, - focused: { ...hiddenProperty }, - focusElement: { ...hiddenProperty }, - grows: { ...hiddenProperty }, - inputElement: { ...hiddenProperty }, - label: { ...hiddenProperty }, - maxlength: { ...hiddenProperty }, - minlength: { ...hiddenProperty }, - multiline: { ...hiddenProperty }, - pattern: { ...hiddenProperty }, - placeholder: { ...hiddenProperty }, - renderInput: { ...hiddenProperty }, - renderMultiline: { ...hiddenProperty }, - type: { ...hiddenProperty }, - value: { ...hiddenProperty }, - _type: { ...hiddenProperty }, - _value: { ...hiddenProperty }, - input: { ...hiddenProperty }, }, - args: { - lang: defaultLocale, - }, - - parameters: { - controls: { - // Hide "This story is not configured to handle controls" warning - hideNoControlsWarning: true, - }, - actions: { - handles: ['onChange'], - }, + valid: false, + invalid: false, }, }; -interface StoryArgs { - lang?: string; +const timeGranularities: TimeGranularity[] = ['hour', 'minute', 'second']; +type ComponentArgs = { selectedDateTime?: Date; timeGranularity?: TimeGranularity; quiet?: boolean; disabled?: boolean; readonly?: boolean; + autofocus?: boolean; valid?: boolean; invalid?: boolean; +}; +type StoryArgs = ComponentArgs & { onChange?: (dateTime: Date) => void; +}; +interface SpreadStoryArgs { [prop: string]: unknown; } -const renderTimeField = ( +const renderDateTimePicker = ( title: string, args: StoryArgs = {}, - content: TemplateResult | typeof nothing = nothing + content: TemplateResult | typeof nothing = nothing, + id: string | undefined = undefined, + styles: CSSResult | typeof nothing = nothing ): TemplateResult => { + const story = html` + ${when( + styles, + () => html` + + ` + )} + +

${title}

+
+ + ${content} + + `; + + const randomId = String(Math.floor(Math.random() * 99999)); + + requestAnimationFrame(() => { + const container = document.querySelector( + `.story-container-${randomId}` + ); + + if (container) { + render(story, container as HTMLElement); + } + }); + return html` - - - -

${title}

-

Locale: ${args.lang}

-
- - ${content} - -
+
`; }; export const Default = (args: StoryArgs = {}): TemplateResult => { - return renderTimeField('Default', args); + return renderDateTimePicker('Default', args); }; export const selectedDateTime = (args: StoryArgs = {}): TemplateResult[] => { - const formatter = Intl.DateTimeFormat(args.lang ?? defaultLocale, { + const formatter = Intl.DateTimeFormat(defaultLocale, { day: 'numeric', month: 'short', year: 'numeric', @@ -205,7 +155,7 @@ export const selectedDateTime = (args: StoryArgs = {}): TemplateResult[] => { selectedDateTime: dateTime, }; - return renderTimeField(title, args); + return renderDateTimePicker(title, args); }); }; @@ -213,9 +163,13 @@ export const timeGranularity = (args: StoryArgs = {}): TemplateResult => { args = { ...args, timeGranularity: args.timeGranularity, + selectedDateTime: new Date(2021, 10, 2, 16, 1, 54), }; - return renderTimeField(`Time Granularity: ${args.timeGranularity}`, args); + return renderDateTimePicker( + `Time Granularity: ${args.timeGranularity}`, + args + ); }; timeGranularity.argTypes = { @@ -237,7 +191,7 @@ timeGranularity.args = { }; export const disabled = (args: StoryArgs = {}): TemplateResult => { - return renderTimeField(`Disabled? ${args.disabled}`, args); + return renderDateTimePicker(`Disabled? ${args.disabled}`, args); }; disabled.argTypes = { @@ -256,7 +210,7 @@ disabled.args = { }; export const quiet = (args: StoryArgs = {}): TemplateResult => { - return renderTimeField(`Quiet? ${args.quiet}`, args); + return renderDateTimePicker(`Quiet? ${args.quiet}`, args); }; quiet.argTypes = { @@ -275,7 +229,7 @@ quiet.args = { }; export const readonly = (args: StoryArgs = {}): TemplateResult => { - return renderTimeField(`Read only? ${args.readonly}`, args); + return renderDateTimePicker(`Read only? ${args.readonly}`, args); }; readonly.argTypes = { @@ -299,20 +253,17 @@ export const autoFocus = (args: StoryArgs = {}): TemplateResult => { autofocus: true, }; - return renderTimeField('Auto focus', args); + return renderDateTimePicker('Auto focus', args); }; export const valid = (args: StoryArgs = {}): TemplateResult => { - return renderTimeField(`Is valid? ${args.valid}`, args); + return renderDateTimePicker(`Is valid? ${args.valid}`, args); }; valid.argTypes = { - valid: { - control: 'boolean', + invalid: { table: { - defaultValue: { - summary: false, - }, + disable: true, }, }, }; @@ -322,16 +273,13 @@ valid.args = { }; export const invalid = (args: StoryArgs = {}): TemplateResult => { - return renderTimeField(`Is invalid? ${args.invalid}`, args); + return renderDateTimePicker(`Is invalid? ${args.invalid}`, args); }; invalid.argTypes = { - invalid: { - control: 'boolean', + valid: { table: { - defaultValue: { - summary: false, - }, + disable: true, }, }, }; @@ -345,7 +293,7 @@ export const helpText = (args: StoryArgs = {}): TemplateResult => { My default help text `; - return renderTimeField(`With help text`, args, content); + return renderDateTimePicker(`With help text`, args, content); }; export const negativeHelpText = (args: StoryArgs = {}): TemplateResult => { @@ -358,7 +306,7 @@ export const negativeHelpText = (args: StoryArgs = {}): TemplateResult => { `; - return renderTimeField(`With negative help text`, args, content); + return renderDateTimePicker('With negative help text', args, content); }; negativeHelpText.argTypes = { @@ -375,3 +323,30 @@ negativeHelpText.argTypes = { negativeHelpText.args = { invalid: true, }; + +export const customIcon = (args: StoryArgs = {}): TemplateResult => { + const content = html` + + `; + + return renderDateTimePicker('Custom icon', args, content); +}; + +export const customWidth = (args: StoryArgs = {}): TemplateResult[] => { + return ['100%', '50%', '350px', 'auto'].map((width, index) => { + const id = `date-time-picker--${index}`; + const styles = css` + sp-date-time-picker#${unsafeCSS(id)} { + inline-size: ${unsafeCSS(width)}; + } + `; + + return renderDateTimePicker( + `Custom width: ${width}`, + args, + undefined, + id, + styles + ); + }); +}; diff --git a/packages/time-field/test/benchmark/basic-test.ts b/packages/date-time-picker/test/benchmark/basic-test.ts similarity index 80% rename from packages/time-field/test/benchmark/basic-test.ts rename to packages/date-time-picker/test/benchmark/basic-test.ts index 5192687fdc7..19533cc15eb 100644 --- a/packages/time-field/test/benchmark/basic-test.ts +++ b/packages/date-time-picker/test/benchmark/basic-test.ts @@ -9,10 +9,12 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import '@spectrum-web-components/time-field/sp-time-field.js'; +import '@spectrum-web-components/date-time-picker/sp-date-time-picker.js'; import { html } from '@spectrum-web-components/base'; import { measureFixtureCreation } from '../../../../test/benchmark/helpers.js'; measureFixtureCreation(html` - + `); diff --git a/packages/time-field/test/time-field.test.ts b/packages/date-time-picker/test/date-time-picker.test.ts similarity index 71% rename from packages/time-field/test/time-field.test.ts rename to packages/date-time-picker/test/date-time-picker.test.ts index 56127d9e2bb..2fd57e6b359 100644 --- a/packages/time-field/test/time-field.test.ts +++ b/packages/date-time-picker/test/date-time-picker.test.ts @@ -9,25 +9,26 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; -import '../sp-time-field.js'; -import { TimeField } from '..'; import { testForLitDevWarnings } from '../../../test/testing-helpers.js'; +import { DateTimePicker } from '@spectrum-web-components/date-time-picker'; -describe('TimeField', () => { +describe('DateTimePicker', () => { testForLitDevWarnings( async () => - await fixture( + await fixture( html` - + ` ) ); - it('loads default time-field accessibly', async () => { - const el = await fixture( + + it('loads default sp-date-time-picker accessibly', async () => { + const el = await fixture( html` - + ` ); diff --git a/packages/time-field/tsconfig.json b/packages/date-time-picker/tsconfig.json similarity index 100% rename from packages/time-field/tsconfig.json rename to packages/date-time-picker/tsconfig.json diff --git a/packages/picker-button/src/PickerButton.ts b/packages/picker-button/src/PickerButton.ts index 9fa2e11305a..f4794c649cb 100644 --- a/packages/picker-button/src/PickerButton.ts +++ b/packages/picker-button/src/PickerButton.ts @@ -58,6 +58,7 @@ export class PickerButton extends SizedMixin( uiicononly: !this.hasText, textuiicon: this.hasText, }; + return html`
diff --git a/packages/picker-button/src/spectrum-config.js b/packages/picker-button/src/spectrum-config.js index b555a8a7246..682418c3632 100644 --- a/packages/picker-button/src/spectrum-config.js +++ b/packages/picker-button/src/spectrum-config.js @@ -211,7 +211,7 @@ const config = { 'rounded' ), converter.classToAttribute( - 'spectrum-PickerButton--low', + 'spectrum-PickerButton--quiet', 'quiet' ), converter.classToClass('spectrum-PickerButton--uiicononly'), diff --git a/packages/picker-button/src/spectrum-picker-button.css b/packages/picker-button/src/spectrum-picker-button.css index 2848dc62799..229ec2009d7 100644 --- a/packages/picker-button/src/spectrum-picker-button.css +++ b/packages/picker-button/src/spectrum-picker-button.css @@ -106,7 +106,7 @@ governing permissions and limitations under the License. ); } -.root.spectrum-PickerButton--quiet { +:host([quiet]) .root { --mod-picker-button-background-color: var( --mod-picker-button-background-color-quiet, transparent diff --git a/packages/picker-button/stories/picker-button-sizes.stories.ts b/packages/picker-button/stories/picker-button-sizes.stories.ts index f97155b4f19..15459cbdbf2 100644 --- a/packages/picker-button/stories/picker-button-sizes.stories.ts +++ b/packages/picker-button/stories/picker-button-sizes.stories.ts @@ -12,6 +12,7 @@ governing permissions and limitations under the License. import { TemplateResult } from '@spectrum-web-components/base'; import { argTypes, StoryArgs, Template } from './index.js'; + import '@spectrum-web-components/picker-button/sp-picker-button.js'; export default { diff --git a/packages/picker-button/stories/picker-button.stories.ts b/packages/picker-button/stories/picker-button.stories.ts index e1d9864467a..5510287005f 100644 --- a/packages/picker-button/stories/picker-button.stories.ts +++ b/packages/picker-button/stories/picker-button.stories.ts @@ -13,9 +13,9 @@ governing permissions and limitations under the License. import { html, TemplateResult } from '@spectrum-web-components/base'; import { argTypes, StoryArgs, Template } from './index.js'; -import '@spectrum-web-components/picker-button/sp-picker-button.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-add.js'; +import '@spectrum-web-components/picker-button/sp-picker-button.js'; export default { title: 'Picker Button', @@ -39,6 +39,9 @@ customIcon.args = { export const invalid = (args: StoryArgs): TemplateResult => Template(args); invalid.args = { invalid: true }; +export const quiet = (args: StoryArgs): TemplateResult => Template(args); +quiet.args = { quiet: true }; + export const label = (args: StoryArgs): TemplateResult => Template(args); label.args = { label: true }; diff --git a/packages/time-field/.npmignore b/packages/time-field/.npmignore deleted file mode 100644 index c50cbe188c0..00000000000 --- a/packages/time-field/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -stories -test \ No newline at end of file diff --git a/packages/time-field/README.md b/packages/time-field/README.md deleted file mode 100644 index 808d31e2ac8..00000000000 --- a/packages/time-field/README.md +++ /dev/null @@ -1,33 +0,0 @@ -## Description - -### Usage - -[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/time-field?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/time-field) -[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/time-field?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/time-field) - -``` -yarn add @spectrum-web-components/time-field -``` - -Import the side effectful registration of `` via: - -``` -import '@spectrum-web-components/time-field/sp-time-field.js'; -``` - -When looking to leverage the `TimeField` base class as a type and/or for extension purposes, do so via: - -``` -import { TimeField } from '@spectrum-web-components/time-field'; -``` - -## Example - -```html - -``` - -## To-do list - -- Complete documentation -- Add/Review unit tests diff --git a/packages/time-field/exports.json b/packages/time-field/exports.json deleted file mode 100644 index 925298d825a..00000000000 --- a/packages/time-field/exports.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "./src/*": "./src/*.js", - "./sp-time-field.js": "./sp-time-field.js" -} diff --git a/packages/time-field/src/TimeField.ts b/packages/time-field/src/TimeField.ts deleted file mode 100644 index 6847b2dbc3c..00000000000 --- a/packages/time-field/src/TimeField.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright 2023 Adobe. All rights reserved. -This file is licensed to you 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 REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ -import { CSSResultArray } from '@spectrum-web-components/base'; -import { state } from '@spectrum-web-components/base/src/decorators.js'; -import { InputSegments } from '@spectrum-web-components/input-segments'; - -import styles from './time-field.css.js'; - -/** - * @element sp-time-field - * - * @event change - Announces when a new time is defined by emitting a `Date` object - * - * @slot help-text - Default or non-negative help text to associate to your form element - * @slot negative-help-text - Negative help text to associate to your form element when `invalid` - */ -export class TimeField extends InputSegments { - public static override get styles(): CSSResultArray { - return [...super.styles, styles]; - } - - @state() - override includeTime = true; -} diff --git a/packages/time-field/src/time-field.css b/packages/time-field/src/time-field.css deleted file mode 100644 index 26ef92385f0..00000000000 --- a/packages/time-field/src/time-field.css +++ /dev/null @@ -1,11 +0,0 @@ -/* -Copyright 2023 Adobe. All rights reserved. -This file is licensed to you 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 REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ diff --git a/projects/story-decorator/src/StoryDecorator.ts b/projects/story-decorator/src/StoryDecorator.ts index a3f5a8044b8..45dc8cce050 100644 --- a/projects/story-decorator/src/StoryDecorator.ts +++ b/projects/story-decorator/src/StoryDecorator.ts @@ -95,6 +95,34 @@ const reduceMotionProperties = css` --swc-test-duration: 1ms; `; +export const locales = [ + 'cs-CZ', + 'cy-GB', + 'da-DK', + 'de-DE', + 'en-GB', + 'en-US', + 'es-ES', + 'fi-FI', + 'fr-FR', + 'hu-HU', + 'it-IT', + 'ja-JP', + 'ko-KR', + 'nb-NO', + 'nl-NL', + 'pl-PL', + 'pt-BR', + 'ru-RU', + 'sv-SE', + 'tr-TR', + 'uk-UA', + 'zh-Hans-CN', + 'zh-Hant-TW', +] as const; + +export const defaultLocale = 'en-US'; + export class StoryDecorator extends SpectrumElement { static override get styles() { return [ @@ -190,6 +218,9 @@ export class StoryDecorator extends SpectrumElement { @property({ type: Boolean, reflect: true }) public screenshot = screenshot; + @property({ reflect: true }) + public override lang: typeof locales[number] = defaultLocale; + @queryAsync('sp-theme') private themeRoot!: Theme; @@ -242,6 +273,10 @@ export class StoryDecorator extends SpectrumElement { } } + private updateLocale({ target }: Event & { target: Picker }): void { + this.lang = target.value as typeof locales[number]; + } + protected handleKeydown(event: KeyboardEvent): void { const path = event.composedPath(); const hasInput = path.some( @@ -262,6 +297,7 @@ export class StoryDecorator extends SpectrumElement { color=${this.color} scale=${this.scale} dir=${this.direction} + lang=${this.lang} part="container" @keydown=${this.handleKeydown} > @@ -306,7 +342,8 @@ export class StoryDecorator extends SpectrumElement { return html`
${this.themeControl} ${this.colorControl} ${this.scaleControl} - ${this.dirControl} ${this.reduceMotionControl} + ${this.dirControl} ${this.localeControl} + ${this.reduceMotionControl}
`; } @@ -387,6 +424,26 @@ export class StoryDecorator extends SpectrumElement { `; } + private get localeControl(): TemplateResult { + return html` + Locale + + ${locales.map( + (locale) => html` + ${locale} + ` + )} + + `; + } + private get reduceMotionControl(): TemplateResult { return html` segment.placeholder ?? '', + () => segment.formatted ?? '' + ); + } + + public renderInputContent(): TemplateResult { + return html`
{ this.handleKeydown(segment, event); }} - > - ${when( - isPlaceholderVisible, - () => html` - - `, - () => segment.formatted - )} -
+ @beforeinput=${(event: InputEvent) => { + this.handleBeforeInput(segment, event); + }} + @input=${(event: InputEvent) => { + this.handleInputEvent(segment, event); + }} + .innerText=${this.renderSegmentText(segment)} + >
`; } /** - * Indicates the parent component when a segment is focused, this way we can apply all styles to the "fake" input + * Indicates the parent component when a segment is focused, this way we can apply all styles to the “fake” input * (wrapper) as if it were a standard input */ - public handleFocusIn(): void { + protected handleFocusIn(): void { super.onFocus(); } /** * Indicates the parent component when a segment is blurred, this way we can remove all styles that were applied to - * the "fake" input (wrapper) while one of the segments was focused + * the “fake” input (wrapper) while one of the segments was focused */ - public handleFocusOut(event: FocusEvent): void { + protected handleFocusOut(event: FocusEvent): void { super.onBlur(event); } /** * Detects the pressed key and performs the correct action accordingly * - * @param segment - Segment on which the event was fired - * @param event - Event details + * @param segment - Segment on which the event was triggered (the segment being changed) + * @param event - Triggered event details */ - public handleKeydown(segment: Segment, event: KeyboardEvent): void { + protected handleKeydown(segment: Segment, event: KeyboardEvent): void { switch (event.code) { case 'ArrowUp': { - this.incrementValue(segment); + this.incrementValue(segment, event); break; } case 'ArrowRight': { @@ -321,65 +346,154 @@ export class InputSegments extends TextfieldBase { break; } case 'ArrowDown': { - this.decrementValue(segment); + this.decrementValue(segment, event); break; } case 'ArrowLeft': { this.focusPreviousSegment(event); break; } - default: { - // To determine what character corresponds with the key event, we use the `KeyboardEvent.key` property - const key = event.key; - const isNumberKey = this.numberParser.isValidPartialNumber(key); - const isClearKey = ['Backspace', 'Delete'].includes(key); - const isAllowedKey = ['Tab'].includes(key); - - if (isNumberKey) { - this.handleTypedValue(segment, event); - } + } - if (isClearKey) { - this.handleClear(segment); - } + // The “AM/PM” segment value can be changed by pressing the “A” (for “AM”) or “P” (for “PM”) keys + if (segment.type === 'dayPeriod') { + if (event.code === 'KeyA') { + this.setAmPmSegmentValue(AM); + this.valueChanged(segment, event); + } - if (isNumberKey || isClearKey || !isAllowedKey) { - event.preventDefault(); - event.stopPropagation(); - } + if (event.code === 'KeyP') { + this.setAmPmSegmentValue(PM); + this.valueChanged(segment, event); } } } /** - * Sets new segment value after user types some number + * When the `input` event is triggered, we can use the `beforeinput` event to execute some things before * - * @param segment - The segment being changed - * @param event - Event details + * @param segment - Segment on which the event was triggered (the segment being changed) + * @param event - Triggered event details */ - public handleTypedValue(segment: Segment, event: KeyboardEvent): void { + protected handleBeforeInput(segment: Segment, event: InputEvent): void { + switch (event.inputType) { + case 'deleteContentBackward': + case 'deleteContentForward': + event.preventDefault(); + this.clearContent(segment, event); + break; + + case 'insertParagraph': // “Enter” key + case 'insertLineBreak': // Shift + “Enter” keys + event.preventDefault(); + break; + } + } + + /** + * Sets new segment value after the user types something + * + * @param segment - Segment on which the event was triggered (the segment being changed) + * @param event - Triggered event details + */ + protected handleInputEvent(segment: Segment, event: InputEvent): void { const details = this.extractDetails(segment); + const data: string | null = event.data; - if (details === undefined) { + if (details === undefined || data === null) { return; } - const typedValue = this.numberParser.parse(event.key); + const typedValue = this.numberParser.parse(data); + + if ( + !this.numberParser.isValidPartialNumber(data) || + isNaN(typedValue) + ) { + this.updateContent(segment, event); + return; + } + + const isDate = dateSegmentTypes.includes(segment.type); const isAmPmHour = this.is12HourClock && segment.type === 'hour'; segment.value = isAmPmHour ? this.getNewValueForAmPmHourSegment(details, typedValue) - : this.getNewValueForOtherSegments(details, typedValue); + : this.getNewValueForOtherSegments(details, typedValue, isDate); + + this.valueChanged(segment, event); + } + + /** + * Increments the segment value respecting the minimum and maximum limits + * + * @param segment - Segment on which the event was triggered (the segment being changed) + * @param event - Triggered event details + */ + protected incrementValue(segment: Segment, event: KeyboardEvent): void { + const min = segment.minValue; + const max = segment.maxValue; + + if (min === undefined || max === undefined) { + return; + } + + if (segment.value === undefined) { + segment.value = + segment.type === 'year' ? this.currentDateTime.year : min; + } else if (segment.type === 'dayPeriod') { + segment.value = this.toggleAmPm(segment.value); + } else { + segment.value += 1; - this.valueChanged(segment); + if (segment.value > max) { + segment.value = min; + } + } + + this.valueChanged(segment, event); + } + + /** + * Decrements the segment value respecting the minimum and maximum limits + * + * @param segment - Segment on which the event was triggered (the segment being changed) + * @param event - Triggered event details + */ + protected decrementValue(segment: Segment, event: KeyboardEvent): void { + const min = segment.minValue; + const max = segment.maxValue; + + if (min === undefined || max === undefined) { + return; + } + + if (segment.value === undefined) { + segment.value = + segment.type === 'year' ? this.currentDateTime.year : max; + } else if (segment.type === 'dayPeriod') { + segment.value = this.toggleAmPm(segment.value); + } else { + segment.value -= 1; + + if (segment.value < min) { + segment.value = max; + } + } + + this.valueChanged(segment, event); } /** * Sets the new segment value after the user clears the content * - * @param segment - The segment being changed + * @param segment - Segment on which the event was triggered (the segment being changed) + * @param event - Triggered event details */ - public handleClear(segment: Segment): void { + protected clearContent( + segment: Segment, + event: InputEvent | KeyboardEvent + ): void { const details = this.extractDetails(segment); if (details?.value === undefined) { @@ -415,140 +529,373 @@ export class InputSegments extends TextfieldBase { (newValue !== undefined && this.numberParser.parse(newValue)) || undefined; - this.valueChanged(segment); + this.valueChanged(segment, event); } /** - * Returns data from the editable segment that corresponds to the given type + * After defining the new segment value, it formats the values that will be displayed on the screen and prepares the + * object that will be emitted by the component, if it is ready/defined * - * @param type - Segment type + * @param segment - Segment on which the event was triggered (the segment being changed) + * @param event - Triggered event details */ - protected segment(type: EditableSegmentType): Segment | undefined { - return this.segments.find((segment) => segment.type === type); + protected valueChanged( + segment: Segment, + event: InputEvent | KeyboardEvent + ): void { + if (this.is12HourClock) { + if (segment.type === 'hour') { + this.updateAmPm(); + } else if (segment.type === 'dayPeriod') { + this.updateHour(); + } + } + + const hasDay = isNumber(this.daySegment?.value); + const hasMonth = isNumber(this.monthSegment?.value); + + if ( + segment.type === 'month' || + (segment.type === 'day' && hasMonth) || + (segment.type === 'year' && hasDay && hasMonth) + ) { + this.updateDay(); + } + + this.formatValue(segment); + this.updateContent(segment, event); + this.setNewDateTime(); + this.emitNewDateTime(); } /** - * Defines the formatter that will be used in the creation of segments + * Sets the new date/time object according to the configuration parameters and if the minimum required values for + * each type (date only, time only or date and time together) were defined */ - private setFormatter(): void { - let dateOptions: Intl.DateTimeFormatOptions = {}; - let timeOptions: Intl.DateTimeFormatOptions = {}; + protected setNewDateTime(): void { + this.newDateTime = undefined; - if (this.includeDate) { - dateOptions = { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }; + // If none of the date/time segments are being used, there is nothing to do here + if (!this.includeDate && !this.includeTime) { + return; } - if (this.includeTime) { - const useMinutes: TimeGranularity[] = ['minute', 'second']; + const date = this.getDateFromSegments(); + const time = this.getTimeFromSegments(); - const includeMinutes = useMinutes.includes(this.timeGranularity); - const includeSeconds = this.timeGranularity === 'second'; + // When only date segments are being used + if (this.includeDate && !this.includeTime) { + if (date !== undefined) { + this.newDateTime = this.dateToCalendarDateTime(date); + } - timeOptions = { - hour: '2-digit', - ...(includeMinutes && { minute: '2-digit' }), - ...(includeSeconds && { second: '2-digit' }), - }; + return; } - this.formatter = new DateFormatter(this.locale, { - ...dateOptions, - ...timeOptions, - }); + if (time !== undefined) { + this.newDateTime = this.dateToCalendarDateTime(time); + } + + // If date segments are being used, we need to change the date part to use the value of these segments + if (this.includeDate && date !== undefined) { + const dateCalendar = this.dateToCalendarDateTime(date); + + this.newDateTime = this.newDateTime?.set({ + year: dateCalendar.year, + month: dateCalendar.month, + day: dateCalendar.day, + }); + } } /** - * * Defines the number parser using the defined locale + * Emits the new value for date/time if it is already defined */ - private setNumberParser(): void { - this.numberParser = new NumberParser(this.locale, { - maximumFractionDigits: 0, - }); + protected emitNewDateTime(): void { + const dateTime = this.newDateTime + ? this.newDateTime.toDate(this.timeZone) + : undefined; + + this.dispatchEvent( + new CustomEvent('change', { + bubbles: true, + composed: true, + detail: dateTime, + }) + ); } /** - * If a datetime is received by the component via property, it will use it as the current datetime to render the - * input + * The parts returned by the `formatToParts()` function of `Intl.DateTimeFormat` have only two properties, `type` + * and `value`, but we need more information for each segment, so we convert it to the type we need + * + * @param part - Part/segment to be “translated” (mapped) */ - private setCurrentDateTime(): void { - if (!this.selectedDateTime) { - return; + protected mapToSegment(part: Intl.DateTimeFormatPart): Segment { + const type = part.type; + const formatted = part.value; + + if (type === 'literal') { + return { + type, + formatted, + }; } - this.selectedDateTime = new Date(this.selectedDateTime); + const placeholder = this.getPlaceholder(type, part.value); - if (!this.isValidTime(this.selectedDateTime)) { - this.selectedDateTime = undefined; - return; + const segment: Segment = { + type, + formatted, + ...(placeholder !== undefined && { placeholder }), + ...this.getValueAndLimits(type), + }; + + this.formatValue(segment); + + return segment; + } + + /** + * Extracts the segment details, validating that the limits have been defined. The value currently assigned to the + * segment remains optional + * + * @param segment - The segment to extract the details + */ + protected extractDetails(segment: Segment): SegmentDetails | undefined { + const min = segment.minValue; + const max = segment.maxValue; + + if (min === undefined || max === undefined) { + return undefined; } - this.currentDateTime = this.dateToCalendarDateTime( - this.selectedDateTime + return { + value: segment.value, + minValue: min, + maxValue: max, + }; + } + + /** + * For the hour segment whose clock format is 12 hours, we need to perform some checks before defining what will be + * the new value associated with the segment. This is necessary because the time the user sees might not match the + * value we need to store in the segment + * + * For example, if “10” is the value displayed in the field, the actual value could be “22” if it's PM, so we need + * to identify when we have to change the “actual value” + * + * @param details - Segment value and limits + * @param typedValue - The value typed by the user + */ + protected getNewValueForAmPmHourSegment( + details: SegmentDetails, + typedValue: number + ): number { + const isAmPmHour = true; + + let newValue = this.mergePreviousValueWithTypedValue( + details, + typedValue, + isAmPmHour ); + + const min = details.minValue; + const max = details.maxValue; + const isPM = this.isPM(min); + + if (isPM && newValue !== min && newValue > maxHourAM) { + newValue = this.numberParser.parse(String(newValue).slice(1)); + } else if (newValue > max) { + const useMinHourAM = !isPM && newValue === PM; + + newValue = useMinHourAM + ? minHourAM + : this.useTypedValueOrMax(typedValue, max); + } + + if (isPM && newValue !== min) { + newValue += PM; + } + + return newValue; } /** - * Sets the new date/time object according to the configuration parameters and if the minimum required values for - * each type (date only, time only or date and time together) were defined + * Defines the new value that will be associated with the segment, with the exception of the hour segment for + * 12-hour clocks, whose value is defined in another method + * + * @param details - Segment value and limits + * @param typedValue - The value typed by the user + * @param isDateSegment - Indicates if it is a date segment */ - private setNewDateTime(): void { - this.newDateTime = undefined; + protected getNewValueForOtherSegments( + details: SegmentDetails, + typedValue: number, + isDateSegment: boolean + ): number | undefined { + let newValue = this.mergePreviousValueWithTypedValue( + details, + typedValue + ); - // If none of the date/time segments are being used, there is nothing to do here - if (!this.includeDate && !this.includeTime) { + if (isDateSegment && newValue === 0) { + return undefined; + } + + const min = details.minValue; + const max = details.maxValue; + + if (String(newValue).length > String(max).length) { + newValue = this.numberParser.parse(String(newValue).slice(1)); + } + + if (newValue < min) { + newValue = this.useTypedValueOrMin(typedValue, min); + } else if (newValue > max) { + newValue = this.useTypedValueOrMax(typedValue, max); + } + + return newValue; + } + + /** + * If the segment has a `value`, it defines the text used in the UI formatted according to the locale. At this + * moment we are formatting the value of a specific segment, but it is not possible to generate a valid Date object + * with just one piece of information (day, month, year, etc.), so we need to define a "base date" to be used + * together with the value of the segment. + * + * For example, if the current segment is the day segment, but the month and year segment have not yet been defined, + * we need to choose a month and a year to be used in composing the date that will be used in formatting, after all, + * there is no day without a month and a year. + * + * @param segment - Segment to format the value + */ + protected formatValue(segment: Segment): void { + if (segment.value === undefined) { return; } - let year = this.yearSegment?.value; - let month = this.monthSegment?.value; - let day = this.daySegment?.value; + // We always use the first day of the month unless a specific day is specified + let day = + this.daySegment?.value ?? + getMinimumDayInMonth(this.currentDateTime); - // When only date segments are being used - if (this.includeDate && !this.includeTime) { - if (isNumber(year) && isNumber(month) && isNumber(day)) { - this.newDateTime = new CalendarDateTime(year, month, day); + // We always use the first month of the year unless a specific month is specified + let month = + this.monthSegment?.value ?? + getMinimumMonthInYear(this.currentDateTime); + + let year = this.yearSegment?.value ?? this.currentDateTime.year; + let hour = this.hourSegment?.value ?? this.currentDateTime.hour; + let minute = this.minuteSegment?.value ?? this.currentDateTime.minute; + let second = this.secondSegment?.value ?? this.currentDateTime.second; + + let padMaxLength = 2; + + switch (segment.type) { + case 'day': { + day = segment.value; + break; } + case 'month': { + month = segment.value; + break; + } + case 'year': { + year = segment.value; + break; + } + case 'hour': { + hour = segment.value; - return; + if (this.is12HourClock) { + padMaxLength = 1; + } + + break; + } + case 'minute': { + minute = segment.value; + break; + } + case 'second': { + second = segment.value; + break; + } + case 'dayPeriod': { + hour = (segment.value ?? 0) + 1; + padMaxLength = 0; + break; + } } - // When only time segments are being used, we need to set the date based on the current date - if (!this.includeDate) { - year = this.currentDateTime.year; - month = this.currentDateTime.month; - day = this.currentDateTime.day; + /** + * For the year we do not use the value returned by the formatter, to avoid that the typed year is displayed in + * an unexpected way. For example, when typing “2”, the year would be formatted as “1902”, but we keep it as it + * is being displayed on the screen. If the user wants to enter the year “1902”, he will enter number by number + */ + if (segment.type === 'year') { + segment.formatted = String(year); + return; } - const hour = this.hourSegment?.value; - const minute = this.minuteSegment?.value; - const second = this.secondSegment?.value; + /** + * If the day being formatted is February 29th but the year segment has not yet been filled, we need to use a + * leap year to allow the 29th to remain, otherwise, if we use the current year and it is not a leap year, the + * day that would be displayed would be March 1st, as February 29th would not exist and JavaScript “moves” the + * day to the next day. As this year is only used to format the day and month, we use the year 2000 as the "base + * year" for formatting + */ + if ( + !this.yearSegment?.value && + (['day', 'month'] as typeof dateSegmentTypes).includes(segment.type) + ) { + year = 2000; + } - const isHour = this.timeGranularity === 'hour'; - const isMinute = this.timeGranularity === 'minute'; - const isSecond = this.timeGranularity === 'second'; + const date = this.getDate(year, month, day); - const hasTime = - (isHour && isNumber(hour)) || - (isMinute && isNumber(hour) && isNumber(minute)) || - (isSecond && - isNumber(hour) && - isNumber(minute) && - isNumber(second)); - - if (isNumber(year) && isNumber(month) && isNumber(day) && hasTime) { - this.newDateTime = new CalendarDateTime( - year, - month, - day, - hour, - minute, - second - ); + if (!date) { + return; } + + date.setHours(hour); + date.setMinutes(minute); + date.setSeconds(second); + + const formatted = this.formatter + .formatToParts(date) + .find((part) => part.type === segment.type)?.value; + + segment.formatted = formatted?.padStart(padMaxLength, '0'); + } + + /** + * Returns data from the editable segment that corresponds to the given type + * + * @param type - Type of segment + */ + protected segment(type: EditableSegmentType): Segment | undefined { + return this.segments.find((segment) => segment.type === type); + } + + /** + * Indicates whether the hour entered is PM or not + * + * @param hour - The hour to check + */ + protected isPM(hour: number): boolean { + return hour >= PM; + } + + /** + * Returns the corresponding “modifier” (0 for “AM” and 12 for “PM”) for the given hour + * + * @param hour - The hour to identify the modifier + */ + protected getAmPmModifier(hour: number): typeof AM | typeof PM { + return this.isPM(hour) ? PM : AM; } /** @@ -556,22 +903,40 @@ export class InputSegments extends TextfieldBase { * * @param date - `Date` object to validate */ - private isValidTime(date: Date): boolean { + protected isValidTime(date: Date): boolean { return !isNaN(date.getTime()); } /** - * Converts an object of type `Date` to `CalendarDateTime` + * Checks if the time has been defined according to the granularity type + */ + protected hasTime(): boolean { + const hour = this.hourSegment?.value; + const minute = this.minuteSegment?.value; + const second = this.secondSegment?.value; + + const isHour = this.timeGranularity === 'hour'; + const isMinute = this.timeGranularity === 'minute'; + const isSecond = this.timeGranularity === 'second'; + + return ( + (isHour && isNumber(hour)) || + (isMinute && isNumber(hour) && isNumber(minute)) || + (isSecond && isNumber(hour) && isNumber(minute) && isNumber(second)) + ); + } + + /** + * Converts an object of type `Date` to `CalendarDateTime`. The month must be incremented by 1 to create a new + * `CalendarDateTime`, as it uses months ranging from 1 (January) to 12 (December), as opposed to `Date`, whose + * months range from 0 (January) to 11 ( December) * - * @param date - `Date` object to "convert" + * @param date - `Date` object to “convert” */ - private dateToCalendarDateTime(date: Date): CalendarDateTime { + protected dateToCalendarDateTime(date: Date): CalendarDateTime { return new CalendarDateTime( date.getFullYear(), - - // The month to create a new `CalendarDateTime` cannot be a zero-based index, unlike `Date` date.getMonth() + 1, - date.getDate(), date.getHours(), date.getMinutes(), @@ -580,127 +945,144 @@ export class InputSegments extends TextfieldBase { } /** - * Creates the segments that will be used by the input + * Returns a `Date` type object using information extracted from a `CalendarDateTime` type object. The month must be + * decremented by 1 because the `Date` object uses months ranging from 0 (January) to 11 (December) + * + * @param year - Year that will be used to create the new `Date` + * @param month - Month (1 to 12) that will be used to create the new `Date` + * @param day - Day that will be used to create the new `Date` */ - private setSegments(): void { + protected getDate( + year: number | undefined, + month: number | undefined, + day: number | undefined + ): Date | undefined { + return isNumber(year) && isNumber(month) && isNumber(day) + ? new Date(year, month - 1, day) + : undefined; + } + + /** + * Returns a `Date` object using the current values of the segments that make up the date, if they are filled + */ + protected getDateFromSegments(): Date | undefined { + return this.getDate( + this.yearSegment?.value, + this.monthSegment?.value, + this.daySegment?.value + ); + } + + /** + * Returns a `Date` object using the current values of the segments that make up the time, if they are filled. As it + * is not possible to have a `Date` object without an associated date, we use the current date defined internally + * instead of using the date defined in the date segments + */ + protected getTimeFromSegments(): Date | undefined { + if (!this.hasTime()) { + return undefined; + } + + const hour = this.hourSegment?.value; + const minute = this.minuteSegment?.value; + const second = this.secondSegment?.value; + const dateTime = this.currentDateTime.toDate(this.timeZone); - const segmentTypes = [ - ...(this.includeDate ? dateSegmentTypes : []), - ...(this.includeTime ? timeSegmentTypes : []), - ]; + if (isNumber(hour)) { + dateTime.setHours(hour); + } - this.segments = this.formatter - .formatToParts(dateTime) - .filter((part) => segmentTypes.includes(part.type)) - .map((part) => this.mapToSegment(part)); + if (isNumber(minute)) { + dateTime.setMinutes(minute); + } + + if (isNumber(second)) { + dateTime.setSeconds(second); + } + + return dateTime; } /** - * The parts returned by the `formatToParts()` function have only two properties, `type` and `value`, but we need - * more information for each segment, so we convert it to the type we need - * - * @param part - Part/segment to be "translated" (mapped) + * Defines the formatter that will be used in the creation of segments */ - private mapToSegment(part: Intl.DateTimeFormatPart): Segment { - const type = part.type; - const formatted = part.value; - const placeholder = this.getPlaceholder(type, part.value); - const { value, minValue, maxValue } = this.getValueAndLimits(type); + private setFormatter(): void { + let dateOptions: Intl.DateTimeFormatOptions = {}; + let timeOptions: Intl.DateTimeFormatOptions = {}; - const segment: Segment = { - type, - formatted, - ...(placeholder !== undefined && { placeholder }), - ...(value !== undefined && { value }), - ...(minValue !== undefined && { minValue }), - ...(maxValue !== undefined && { maxValue }), - }; + if (this.includeDate) { + dateOptions = { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }; + } - if (part.type !== 'literal') { - this.formatValue(segment); + if (this.includeTime) { + const useMinutes: TimeGranularity[] = ['minute', 'second']; + + const includeMinutes = useMinutes.includes(this.timeGranularity); + const includeSeconds = this.timeGranularity === 'second'; + + timeOptions = { + hour: '2-digit', + ...(includeMinutes && { minute: '2-digit' }), + ...(includeSeconds && { second: '2-digit' }), + }; } - return segment; + this.formatter = new DateFormatter(this.locale, { + ...dateOptions, + ...timeOptions, + }); + } + + /** + * Defines the number parser using the defined locale + */ + private setNumberParser(): void { + this.numberParser = new NumberParser(this.locale, { + maximumFractionDigits: 0, + }); } /** - * If the segment has a `value`, it defines the text used in the UI formatted according to the locale - * - * @param segment - Segment to be updated + * If a datetime is received by the component via property, it will use it as the current datetime to render the + * input */ - private formatValue(segment: Segment): void { - if (segment.value === undefined) { + private setCurrentDateTime(): void { + if (!this.selectedDateTime) { return; } - const options: Intl.DateTimeFormatOptions = {}; - - const year = this.yearSegment?.value ?? this.currentDateTime.year; - const month = this.monthSegment?.value ?? this.currentDateTime.month; - const day = this.daySegment?.value ?? this.currentDateTime.day; - - // The hour can be changed if we are formatting the "dayPeriod" segment - let hour = this.hourSegment?.value ?? this.currentDateTime.hour; - - const minute = this.minuteSegment?.value ?? this.currentDateTime.minute; - const second = this.secondSegment?.value ?? this.currentDateTime.second; + this.selectedDateTime = new Date(this.selectedDateTime); - /** - * For the year we do not use the value returned by the formatter, to avoid that the typed year is displayed in - * an unexpected way. For example, when typing "2", the year would be formatted as "1902", but we keep it as it - * is being displayed on the screen. If the user wants to enter the year "1902", he will enter number by number - */ - if (segment.type === 'year') { - segment.formatted = String(year); + if (!this.isValidTime(this.selectedDateTime)) { + this.selectedDateTime = undefined; return; } - let padMaxLength = 2; - - switch (segment.type) { - case 'month': { - options.month = '2-digit'; - break; - } - case 'day': { - options.day = '2-digit'; - break; - } - case 'hour': { - if (this.is12HourClock) { - padMaxLength = 1; - } + this.currentDateTime = this.dateToCalendarDateTime( + this.selectedDateTime + ); + } - options.hour = 'numeric'; - break; - } - case 'minute': { - options.minute = '2-digit'; - break; - } - case 'second': { - options.second = '2-digit'; - break; - } - case 'dayPeriod': { - hour = (segment.value || 0) + 1; - options.hour = 'numeric'; - padMaxLength = 0; - break; - } - } + /** + * Creates the segments that will be used by the input + */ + private setSegments(): void { + const dateTime = this.currentDateTime.toDate(this.timeZone); - /** - * As we use `CalendarDateTime`, we need to subtract 1 from the month before creating a new `Date` object, - * as this uses zero-based months, and `CalendarDateTime` does not - */ - const date = new Date(year, month - 1, day, hour, minute, second); - const formatted = new DateFormatter(this.locale, options) - .formatToParts(date) - .find((part) => part.type === segment.type)?.value; + const segmentTypes = [ + ...(this.includeDate ? dateSegmentTypes : []), + ...(this.includeTime ? timeSegmentTypes : []), + ]; - segment.formatted = formatted?.padStart(padMaxLength, '0'); + this.segments = this.formatter + .formatToParts(dateTime) + .filter((part) => segmentTypes.includes(part.type)) + .map((part) => this.mapToSegment(part)); } /** @@ -726,42 +1108,13 @@ export class InputSegments extends TextfieldBase { } /** - * Extracts the segment details, validating that the limits have been defined. The value currently assigned to the - * segment remains optional - * - * @param segment - The segment to extract the details - */ - private extractDetails(segment: Segment): SegmentDetails | undefined { - const min = segment.minValue; - const max = segment.maxValue; - - if (min === undefined || max === undefined) { - return undefined; - } - - return { - value: segment.value, - minValue: min, - maxValue: max, - }; - } - - /** - * Indicates whether the hour entered is PM or not - * - * @param hour - The hour to check - */ - private isPM(hour: number): boolean { - return hour >= PM; - } - - /** - * Returns the corresponding "modifier" (0 for "AM" and 12 for "PM") for the given hour - * - * @param hour - The hour to identify the modifier + * If the defined month is February but we don't yet have the year defined, we use 29 as the max limit, as we have + * no way of knowing whether it is a leap year or not until the year segment is filled */ - private getAmPmModifier(hour: number): typeof AM | typeof PM { - return this.isPM(hour) ? PM : AM; + private getFebruaryMaxValue(): number | undefined { + return this.monthSegment?.value === 2 && !this.yearSegment?.value + ? 29 + : undefined; } /** @@ -810,7 +1163,7 @@ export class InputSegments extends TextfieldBase { currentValue = this.currentDateTime[type]; break; case 'dayPeriod': - // To identify the current value of "AM/PM", we use the value of the hour, not the day period itself + // To identify the current value of “AM/PM”, we use the value of the hour, not the day period itself previousValue = this.hourSegment?.value && this.getAmPmModifier(this.hourSegment.value); @@ -852,15 +1205,34 @@ export class InputSegments extends TextfieldBase { ), value, }; - case 'day': + case 'day': { + let max = this.currentDateTime.calendar.getDaysInMonth( + this.currentDateTime + ); + + /** + * If we do not yet have a month defined by the user, we use the highest possible number as a maximum + * limit. When the month is set, if the day is outside the allowed range, it will be corrected + * automatically + */ + if (!this.monthSegment?.value) { + max = 31; + } + + // Check whether the maximum possible limit for the month of February should be used + const febMaxValue = this.getFebruaryMaxValue(); + + if (isNumber(febMaxValue)) { + max = febMaxValue; + } + return { minValue: getMinimumDayInMonth(this.currentDateTime), - maxValue: this.currentDateTime.calendar.getDaysInMonth( - this.currentDateTime - ), + maxValue: max, value, }; - case 'hour': + } + case 'hour': { let min = 0; let max = 23; @@ -878,6 +1250,7 @@ export class InputSegments extends TextfieldBase { maxValue: max, value, }; + } case 'minute': case 'second': return { @@ -897,70 +1270,40 @@ export class InputSegments extends TextfieldBase { } /** - * Increments the segment value respecting the minimum and maximum limits + * Switches the value of the AM/PM segment from `AM` to `PM` or vice versa * - * @param segment - The segment being changed + * @param value - Current value of segment `dayPeriod` */ - private incrementValue(segment: Segment): void { - const min = segment.minValue; - const max = segment.maxValue; - - if (min === undefined || max === undefined) { - return; - } - - if (segment.value === undefined) { - segment.value = - segment.type === 'year' ? this.currentDateTime.year : min; - } else if (segment.type === 'dayPeriod') { - segment.value = this.toggleDayPeriod(segment.value); - } else { - segment.value += 1; - - if (segment.value > max) { - segment.value = min; - } - } - - this.valueChanged(segment); + private toggleAmPm(value: number): typeof AM | typeof PM { + return value === AM ? PM : AM; } /** - * Decrements the segment value respecting the minimum and maximum limits + * Changes the value of the AM/PM segment to use the new value * - * @param segment - The segment being changed + * @param newValue - New value for the segment `dayPeriod` */ - private decrementValue(segment: Segment): void { - const min = segment.minValue; - const max = segment.maxValue; + private setAmPmSegmentValue(newValue: typeof AM | typeof PM): void { + if (this.amPmSegment) { + this.amPmSegment.value = newValue; + } + } - if (min === undefined || max === undefined) { + private updateAmPm(): void { + if (!this.hourSegment || !this.amPmSegment) { + this.resetHourAndAmPm(); return; } - if (segment.value === undefined) { - segment.value = - segment.type === 'year' ? this.currentDateTime.year : max; - } else if (segment.type === 'dayPeriod') { - segment.value = this.toggleDayPeriod(segment.value); - } else { - segment.value -= 1; - - if (segment.value < min) { - segment.value = max; - } + // If there is no hour or if AM/PM is already set, there is nothing to do + if ( + this.hourSegment.value === undefined || + this.amPmSegment.value !== undefined + ) { + return; } - this.valueChanged(segment); - } - - /** - * Switches the value of the `dayPeriod` segment from `AM` to `PM` or vice versa - * - * @param value - Current value of segment `dayPeriod` - */ - private toggleDayPeriod(value: number): typeof AM | typeof PM { - return value === AM ? PM : AM; + this.amPmSegment.value = this.getAmPmModifier(this.hourSegment.value); } /** @@ -969,7 +1312,7 @@ export class InputSegments extends TextfieldBase { */ private updateHour(): void { if (!this.hourSegment || !this.amPmSegment) { - this.resetHourAndDayPeriod(); + this.resetHourAndAmPm(); return; } @@ -995,16 +1338,16 @@ export class InputSegments extends TextfieldBase { } /** - * When the day period is cleared, we need to reset the min and max values of the day period and hour segments to - * their initial values + * When the “AM/PM” is cleared, we need to reset the min and max values of the AM/PM and hour segments to their + * initial values */ - private resetHourAndDayPeriod(): void { + private resetHourAndAmPm(): void { if (this.amPmSegment) { - const dayPeriod = this.getValueAndLimits('dayPeriod'); + const amPm = this.getValueAndLimits('dayPeriod'); - this.amPmSegment.value = dayPeriod.value; - this.amPmSegment.minValue = dayPeriod.minValue; - this.amPmSegment.maxValue = dayPeriod.maxValue; + this.amPmSegment.value = amPm.value; + this.amPmSegment.minValue = amPm.minValue; + this.amPmSegment.maxValue = amPm.maxValue; if (this.amPmSegment.value === undefined) { this.amPmSegment.formatted = this.amPmSegment.placeholder; @@ -1049,6 +1392,13 @@ export class InputSegments extends TextfieldBase { this.daySegment.maxValue = lastDayOfMonth.day; + // Check whether the maximum possible limit for the month of February should be used + const febMaxValue = this.getFebruaryMaxValue(); + + if (isNumber(febMaxValue)) { + this.daySegment.maxValue = febMaxValue; + } + if ( isNumber(this.daySegment.value) && this.daySegment.value > this.daySegment.maxValue @@ -1058,44 +1408,6 @@ export class InputSegments extends TextfieldBase { } } - /** - * After defining the new segment value, it formats the values that will be displayed on the screen and prepares the - * object that will be emitted by the component, if it is ready/defined - * - * @param segment - The segment that was changed - */ - private valueChanged(segment: Segment): void { - if (this.is12HourClock && segment.type === 'dayPeriod') { - this.updateHour(); - } - - const hasDay = isNumber(this.daySegment?.value); - const hasMonth = isNumber(this.monthSegment?.value); - - if ( - segment.type === 'month' || - (segment.type === 'day' && hasMonth) || - (segment.type === 'year' && hasDay && hasMonth) - ) { - this.updateDay(); - } - - this.formatValue(segment); - this.setNewDateTime(); - this.requestUpdate(); - - if (this.newDateTime) { - this.dispatchEvent( - new CustomEvent('change', { - bubbles: true, - composed: true, - cancelable: true, - detail: this.newDateTime.toDate(this.timeZone), - }) - ); - } - } - /** * Returns the value to be used if the typed value is less than the minimum allowed * @@ -1148,85 +1460,34 @@ export class InputSegments extends TextfieldBase { } /** - * For the hour segment whose clock format is 12 hours, we need to perform some checks before defining what will be - * the new value associated with the segment. This is necessary because the time the user sees might not match the - * value we need to store in the segment - * - * For example, if "10" is the value displayed in the field, the actual value could be "22" if it's PM, so we need - * to identify when we have to change the "actual value" - * - * @param details - Segment value and limits - * @param typedValue - The value typed by the user - */ - private getNewValueForAmPmHourSegment( - details: SegmentDetails, - typedValue: number - ): number { - const isAmPmHour = true; - - let newValue = this.mergePreviousValueWithTypedValue( - details, - typedValue, - isAmPmHour - ); - - const min = details.minValue; - const max = details.maxValue; - const isPM = this.isPM(min); - - if (isPM && newValue !== min && newValue > maxHourAM) { - newValue = this.numberParser.parse(String(newValue).slice(1)); - } else if (newValue > max) { - const useMinHourAM = !isPM && newValue === PM; - - newValue = useMinHourAM - ? minHourAM - : this.useTypedValueOrMax(typedValue, max); - } - - if (isPM && newValue !== min) { - newValue += PM; - } - - return newValue; - } - - /** - * Defines the new value that will be associated with the segment, with the exception of the hour segment for - * 12-hour clocks, whose value is defined in another method + * To define the content of elements with the `contenteditable` attribute with Lit we bind to the `.innerText` + * property of the element instead of using string interpolation * - * @param details - Segment value and limits - * @param typedValue - The value typed by the user + * @param segment - Segment on which the event was triggered (the segment being changed) + * @param event - Triggered event details */ - private getNewValueForOtherSegments( - details: SegmentDetails, - typedValue: number - ): number { - let newValue = this.mergePreviousValueWithTypedValue( - details, - typedValue - ); + private updateContent( + segment: Segment, + event: InputEvent | KeyboardEvent + ): void { + const segmentEl = event.target as HTMLElement; - const min = details.minValue; - const max = details.maxValue; + if (segmentEl) { + const content = + segment.value !== undefined + ? segment.formatted + : segment.placeholder; - if (String(newValue).length > String(max).length) { - newValue = this.numberParser.parse(String(newValue).slice(1)); - } + segmentEl.innerText = content ?? ''; - if (newValue < min) { - newValue = this.useTypedValueOrMin(typedValue, min); - } else if (newValue > max) { - newValue = this.useTypedValueOrMax(typedValue, max); + this.requestUpdate(); } - - return newValue; } /** * Focuses on the next editable segment, if any * - * @param event - Event details + * @param event - Triggered event details */ private focusNextSegment(event: KeyboardEvent): void { this.focusSegment(event.target as HTMLDivElement, 'next'); @@ -1235,7 +1496,7 @@ export class InputSegments extends TextfieldBase { /** * Focuses on the previous editable segment, if any * - * @param event - Event details + * @param event - Triggered event details */ private focusPreviousSegment(event: KeyboardEvent): void { this.focusSegment(event.target as HTMLDivElement, 'previous'); @@ -1244,7 +1505,7 @@ export class InputSegments extends TextfieldBase { /** * Focuses the segment according to the direction, if there is one to focus on * - * @param segment - Segment that is currently focused + * @param segment - Segment on which the event was triggered (the segment being changed) * @param elementToFocus - Defines which element will be focused: is it the previous one or the next one? */ private focusSegment( diff --git a/tools/input-segments/src/input-segments.css b/tools/input-segments/src/input-segments.css index 891e7f3ae72..9d3d5aa1d44 100644 --- a/tools/input-segments/src/input-segments.css +++ b/tools/input-segments/src/input-segments.css @@ -16,6 +16,7 @@ governing permissions and limitations under the License. height: 100%; overflow-x: auto; scrollbar-width: none; /* Firefox */ + line-height: normal; } .input-content::-webkit-scrollbar { @@ -29,7 +30,6 @@ governing permissions and limitations under the License. display: inline-block; height: 100%; color: var(--spectrum-textfield-text-color-default); - line-height: normal; } .editable-segment { @@ -41,37 +41,25 @@ governing permissions and limitations under the License. outline: none; } -.literal-segment { - white-space: pre; - user-select: none; -} - [dir='ltr'] .editable-segment { text-align: start; } -.placeholder { - display: block; - width: 100%; - height: 0; - visibility: hidden; - font-style: italic; - text-align: center; - pointer-events: none; +.literal-segment { + white-space: pre; + user-select: none; } -.editable-segment.is-placeholder, -.editable-segment.is-placeholder + .literal-segment { +.is-placeholder, +.is-placeholder + .literal-segment { color: var(--spectrum-gray-500); } -.editable-segment.is-placeholder .placeholder { - height: auto; - visibility: visible; +.is-placeholder:not(:is(:lang(ja), :lang(ko), :lang(zh))) { + font-style: italic; } -.editable-segment:focus, -.editable-segment:focus .placeholder { +.editable-segment:focus { color: var(--spectrum-white); background-color: var(--spectrum-accent-background-color-default); } @@ -80,7 +68,6 @@ governing permissions and limitations under the License. * Hide selection because there is no way to avoid it entirely in Firefox * https://bugzilla.mozilla.org/show_bug.cgi?id=1742153 */ -.editable-segment::selection, -.placeholder::selection { +.editable-segment::selection { background-color: transparent; } diff --git a/tools/input-segments/src/types.ts b/tools/input-segments/src/types.ts index 0567b241f2b..5c77880c5ab 100644 --- a/tools/input-segments/src/types.ts +++ b/tools/input-segments/src/types.ts @@ -9,14 +9,14 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -export const dateSegmentTypes: Intl.DateTimeFormatPartTypes[] = [ +export const dateSegmentTypes: ReadonlyArray = [ 'day', 'month', 'year', 'literal', ]; -export const timeSegmentTypes: Intl.DateTimeFormatPartTypes[] = [ +export const timeSegmentTypes: ReadonlyArray = [ 'hour', 'minute', 'second', diff --git a/tsconfig-all.json b/tsconfig-all.json index 3b4f9488df9..828e9ceaaba 100644 --- a/tsconfig-all.json +++ b/tsconfig-all.json @@ -40,6 +40,7 @@ { "path": "packages/color-slider" }, { "path": "packages/color-wheel" }, { "path": "packages/combobox" }, + { "path": "packages/date-time-picker" }, { "path": "packages/dialog" }, { "path": "packages/divider" }, { "path": "packages/dropzone" }, @@ -79,7 +80,6 @@ { "path": "packages/tags" }, { "path": "packages/textfield" }, { "path": "packages/thumbnail" }, - { "path": "packages/time-field" }, { "path": "packages/toast" }, { "path": "packages/tooltip" }, { "path": "packages/top-nav" }, diff --git a/yarn.lock b/yarn.lock index 1fecb3fa711..59c94c18411 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2219,7 +2219,7 @@ resolved "https://registry.yarnpkg.com/@import-maps/resolve/-/resolve-1.0.1.tgz#1e9fcadcf23aa0822256a329aabca241879d37c9" integrity sha512-tWZNBIS1CoekcwlMuyG2mr0a1Wo5lb5lEHwwWvZo+5GLgr3e9LLDTtmgtCWEwBpXMkxn9D+2W9j2FY6eZQq0tA== -"@internationalized/date@^3.2.1", "@internationalized/date@^3.5.1": +"@internationalized/date@^3.5.1": version "3.5.2" resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.5.2.tgz#d760ace32bb47e869b8c607a4a786c8b208aacc2" integrity sha512-vo1yOMUt2hzp63IutEaTUxROdvQg1qlMRsbCvbay2AK2Gai7wIgCyK5weEX3nHkiLgo4qCXHijFNC/ILhlRpOQ==