diff --git a/packages/calendar/README.md b/packages/calendar/README.md index 36045970735..b2d5ec0235b 100644 --- a/packages/calendar/README.md +++ b/packages/calendar/README.md @@ -38,3 +38,5 @@ import { Calendar } from '@spectrum-web-components/calendar'; - Implement keyboard navigation - Add support for other types of calendars - [React Calendar: International Calendars](https://react-spectrum.adobe.com/react-spectrum/Calendar.html#international-calendars) - Include `aria-label` in calendar for accessibility - [React Calendar: Labeling](https://react-spectrum.adobe.com/react-spectrum/Calendar.html#labeling) +- Complete documentation +- Add/Review unit tests diff --git a/packages/calendar/package.json b/packages/calendar/package.json index 5cbdc963345..bfc86e2ea11 100644 --- a/packages/calendar/package.json +++ b/packages/calendar/package.json @@ -61,7 +61,7 @@ "lit-html" ], "dependencies": { - "@internationalized/date": "^3.2.0", + "@internationalized/date": "^3.2.1", "@internationalized/number": "^3.1.0", "@spectrum-web-components/action-button": "^0.35.0", "@spectrum-web-components/base": "^0.35.0", @@ -69,7 +69,7 @@ "@spectrum-web-components/reactive-controllers": "^0.35.0" }, "devDependencies": { - "@spectrum-css/calendar": "^3.2.6" + "@spectrum-css/calendar": "^4.0.3" }, "types": "./src/index.d.ts", "customElements": "custom-elements.json", diff --git a/packages/calendar/src/Calendar.ts b/packages/calendar/src/Calendar.ts index 53da531a31a..61f18ca4cab 100644 --- a/packages/calendar/src/Calendar.ts +++ b/packages/calendar/src/Calendar.ts @@ -23,7 +23,7 @@ import { NumberFormatter } from '@internationalized/number'; import { CSSResultArray, html, - PropertyValueMap, + PropertyValues, SpectrumElement, TemplateResult, } from '@spectrum-web-components/base'; @@ -121,9 +121,7 @@ export class Calendar extends SpectrumElement { this.setInitialCalendarDate(); } - protected override willUpdate( - changedProperties: PropertyValueMap - ): void { + protected override willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has('selectedDate')) { this.setCurrentCalendarDate(); } @@ -339,7 +337,6 @@ export class Calendar extends SpectrumElement { public handleDayClick(calendarDate: CalendarDate): void { this.selectedDate = calendarDate.toDate(this.timeZone); - this.setCurrentCalendarDate(); this.dispatchEvent( new CustomEvent('change', { diff --git a/packages/calendar/src/spectrum-calendar.css b/packages/calendar/src/spectrum-calendar.css index eb03f0451f4..844309d8c6a 100644 --- a/packages/calendar/src/spectrum-calendar.css +++ b/packages/calendar/src/spectrum-calendar.css @@ -12,50 +12,85 @@ governing permissions and limitations under the License. /* THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ :host { + --spectrum-calendar-day-width: var(--spectrum-component-height-100); + --spectrum-calendar-day-height: var(--spectrum-component-height-100); + --spectrum-calendar-button-gap: var( + --spectrum-component-edge-to-visual-only-50 + ); --spectrum-calendar-border-radius-reset: 0; --spectrum-calendar-border-width-reset: 0; + --spectrum-calendar-day-border-size: var(--spectrum-border-width-200); --spectrum-calendar-margin-y: 24px; --spectrum-calendar-margin-x: 32px; + --spectrum-calendar-day-padding: 4px; --spectrum-calendar-width: calc( ( - var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ) + - var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ) * 2 + var(--spectrum-calendar-day-width) + + var(--spectrum-calendar-day-padding) * 2 ) * 7 ); - --spectrum-calendar-button-gap: var(--spectrum-global-dimension-size-40); - --spectrum-calendar-title-text-letter-spacing: var( - --spectrum-detail-m-text-letter-spacing, - var(--spectrum-global-font-letter-spacing-medium) - ); + --spectrum-calendar-title-text-letter-spacing: 0.06em; + --spectrum-calendar-title-height: 32px; + --spectrum-calendar-title-text-size: var(--spectrum-font-size-300); + --spectrum-calendar-day-title-text-font-weight: var( + --spectrum-bold-font-weight + ); + --spectrum-calendar-day-title-text-color: var(--spectrum-gray-700); + --spectrum-calendar-day-title-text-size: var(--spectrum-font-size-50); + --spectrum-calendar-day-text-size-han: var(--spectrum-font-size-50); + --spectrum-calendar-day-text-size: var(--spectrum-font-size-100); + --spectrum-calendar-day-text-color-selected: var(--spectrum-gray-900); + --spectrum-calendar-day-text-color-hover: var(--spectrum-gray-900); + --spectrum-calendar-day-text-color-cap-selected: var(--spectrum-gray-900); + --spectrum-calendar-day-text-font-weight-selected: var( + --spectrum-bold-font-weight + ); + --spectrum-calendar-day-text-font-weight-cap-selected: var( + --spectrum-bold-font-weight + ); + --spectrum-calendar-day-today-text-color: var(--spectrum-gray-800); + --spectrum-calendar-day-today-text-font-weight: var( + --spectrum-bold-font-weight + ); + --spectrum-calendar-day-today-border-color: var(--spectrum-gray-800); + --spectrum-calendar-button-icon-color: var(--spectrum-gray-700); + --spectrum-calendar-day-today-text-color-disabled: var(--spectrum-gray-500); + --spectrum-calendar-day-today-border-color-disabled: var( + --spectrum-gray-400 + ); + --spectrum-calendar-day-text-color-disabled: var(--spectrum-gray-500); + --spectrum-calendar-day-text-color-key-focus: var(--spectrum-gray-900); } :host { display: inline-block; - width: var(--spectrum-calendar-width, 250px); + inline-size: var(--mod-calendar-width, var(--spectrum-calendar-width)); } :host([padded]) { - margin: var(--spectrum-calendar-margin-x) var(--spectrum-calendar-margin-y); + margin: var(--mod-calendar-margin-x, var(--spectrum-calendar-margin-x)) + var(--mod-calendar-margin-y, var(--spectrum-calendar-margin-y)); } .spectrum-Calendar-header { align-items: center; display: flex; - width: 100%; + inline-size: 100%; } .spectrum-Calendar-title { + color: var( + --highcontrast-calendar-day-title-text-color, + var( + --mod-calendar-day-title-text-color, + var(--spectrum-calendar-day-title-text-color) + ) + ); flex-grow: 1; font-size: var( - --spectrum-calendar-title-text-size, - var(--spectrum-global-dimension-font-size-300) + --mod-calendar-title-text-size, + var(--spectrum-calendar-title-text-size) ); font-weight: 700; line-height: var( - --spectrum-calendar-title-height, - var(--spectrum-global-dimension-static-size-400) + --mod-calendar-title-height, + var(--spectrum-calendar-title-height) ); margin: 0; order: 1; @@ -64,16 +99,20 @@ governing permissions and limitations under the License. text-overflow: ellipsis; white-space: nowrap; } -:host([dir='ltr']) .spectrum-Calendar-nextMonth, -:host([dir='ltr']) .spectrum-Calendar-prevMonth, -:host([dir='rtl']) .spectrum-Calendar-nextMonth, -:host([dir='rtl']) .spectrum-Calendar-prevMonth { - margin: 0 var(--spectrum-calendar-button-gap); -} :host([dir='rtl']) .spectrum-Calendar-nextMonth, :host([dir='rtl']) .spectrum-Calendar-prevMonth { transform: matrix(-1, 0, 0, 1, 0, 0); } +.spectrum-Calendar-nextMonth, +.spectrum-Calendar-prevMonth { + color: var( + --highcontrast-calendar-button-icon-color, + var( + --mod-calendar-button-icon-color, + var(--spectrum-calendar-button-icon-color) + ) + ); +} .spectrum-Calendar-prevMonth { order: 0; } @@ -81,31 +120,41 @@ governing permissions and limitations under the License. order: 2; } .spectrum-Calendar-dayOfWeek { - border-bottom: none !important; + block-size: 100%; + border-block-end: none !important; + color: var( + --highcontrast-calendar-day-title-text-color, + var( + --mod-calendar-day-title-text-color, + var(--spectrum-calendar-day-title-text-color) + ) + ); cursor: default; display: flex; flex-direction: column; font-size: var( - --spectrum-calendar-day-title-text-size, - var(--spectrum-global-dimension-font-size-50) + --mod-calendar-day-title-text-size, + var(--spectrum-calendar-day-title-text-size) ); font-weight: var( - --spectrum-calendar-day-title-text-font-weight, - var(--spectrum-alias-detail-text-font-weight-regular) + --mod-calendar-day-title-text-font-weight, + var(--spectrum-calendar-day-title-text-font-weight) + ); + inline-size: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); - height: 100%; justify-content: flex-end; -webkit-text-decoration: none !important; text-decoration: none !important; text-transform: uppercase; - width: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); } :host([title]) .spectrum-Calendar-dayOfWeek { - border-bottom: none; - letter-spacing: var(--spectrum-calendar-title-text-letter-spacing); + border-block-end: none; + letter-spacing: var( + --mod-calendar-title-text-letter-spacing, + var(--spectrum-calendar-title-text-letter-spacing) + ); text-decoration: underline; -webkit-text-decoration: underline dotted; text-decoration: underline dotted; @@ -121,81 +170,73 @@ governing permissions and limitations under the License. user-select: none; } .spectrum-Calendar-tableCell { + block-size: var( + --mod-calendar-day-height, + var(--spectrum-calendar-day-height) + ); box-sizing: content-box; - height: var( - --spectrum-calendar-day-height, - var(--spectrum-global-dimension-size-400) + inline-size: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); padding: 0; padding: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) ); position: relative; text-align: center; - width: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); } .spectrum-Calendar-tableCell:focus { outline: 0; } -:host([dir='ltr']) .spectrum-Calendar-date { - left: 0; -} -:host([dir='rtl']) .spectrum-Calendar-date { - right: 0; -} .spectrum-Calendar-date { + block-size: var( + --mod-calendar-day-height, + var(--spectrum-calendar-day-height) + ); border: var( - --spectrum-calendar-day-border-size, - var(--spectrum-alias-border-size-thick) + --mod-calendar-day-border-size, + var(--spectrum-calendar-day-border-size) ) solid transparent; border-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); box-sizing: border-box; cursor: pointer; display: block; font-size: var( - --spectrum-calendar-day-text-size, - var(--spectrum-alias-font-size-default) + --mod-calendar-day-text-size, + var(--spectrum-calendar-day-text-size) ); - height: var( - --spectrum-calendar-day-height, - var(--spectrum-global-dimension-size-400) + inline-size: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); + inset-block-start: 0; + inset-inline-start: 0; line-height: calc( - var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ) - + var(--mod-calendar-day-width, var(--spectrum-calendar-day-width)) - var( - --spectrum-calendar-day-border-size, - var(--spectrum-alias-border-size-thick) + --mod-calendar-day-border-size, + var(--spectrum-calendar-day-border-size) ) * 2 ); margin: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) ); position: absolute; - top: 0; white-space: nowrap; - width: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); } .spectrum-Calendar-date:lang(ja), .spectrum-Calendar-date:lang(ko), .spectrum-Calendar-date:lang(zh) { font-size: var( - --spectrum-calendar-day-text-size-han, - var(--spectrum-global-dimension-font-size-50) + --mod-calendar-day-text-size-han, + var(--spectrum-calendar-day-text-size-han) ); } .spectrum-Calendar-date.is-disabled { @@ -205,57 +246,40 @@ governing permissions and limitations under the License. .spectrum-Calendar-date.is-outsideMonth { visibility: hidden; } -:host([dir='ltr']) .spectrum-Calendar-date:before { - left: calc( - 50% - - var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ) / 2 - ); -} -:host([dir='rtl']) .spectrum-Calendar-date:before { - right: calc( - 50% - - var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ) / 2 - ); -} .spectrum-Calendar-date:before { + block-size: var( + --mod-calendar-day-height, + var(--spectrum-calendar-day-height) + ); border: var( - --spectrum-calendar-day-border-size, - var(--spectrum-alias-border-size-thick) + --mod-calendar-day-border-size, + var(--spectrum-calendar-day-border-size) ) solid transparent; border-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); box-sizing: border-box; content: ''; - height: var( - --spectrum-calendar-day-height, - var(--spectrum-global-dimension-size-400) + inline-size: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); - position: absolute; - top: calc( - 50% - - var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ) / 2 + inset-block-start: calc( + 50% - var(--mod-calendar-day-width, var(--spectrum-calendar-day-width)) / + 2 ); - width: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) + inset-inline-start: calc( + 50% - var(--mod-calendar-day-width, var(--spectrum-calendar-day-width)) / + 2 ); + position: absolute; } .spectrum-Calendar-date.is-selected:not(.is-range-selection) { font-weight: var( - --spectrum-calendar-day-text-font-weight-selected, - var(--spectrum-global-font-weight-bold) + --mod-calendar-day-text-font-weight-selected, + var(--spectrum-calendar-day-text-font-weight-selected) ); } .spectrum-Calendar-date.is-selected:not(.is-range-selection):before { @@ -263,476 +287,423 @@ governing permissions and limitations under the License. } .spectrum-Calendar-date.is-today { font-weight: var( - --spectrum-calendar-day-today-text-font-weight, - var(--spectrum-global-font-weight-bold) + --mod-calendar-day-today-text-font-weight, + var(--spectrum-calendar-day-today-text-font-weight) ); } .spectrum-Calendar-date.is-range-selection { - border-radius: var(--spectrum-calendar-border-radius-reset); - border-width: var(--spectrum-calendar-border-width-reset); + border-radius: var( + --mod-calendar-border-radius-reset, + var(--spectrum-calendar-border-radius-reset) + ); + border-width: var( + --mod-calendar-border-width-reset, + var(--spectrum-calendar-border-width-reset) + ); + inline-size: calc( + var(--mod-calendar-day-width, var(--spectrum-calendar-day-width)) + + var( + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) + ) * 2 + ); line-height: var( - --spectrum-calendar-day-height, - var(--spectrum-global-dimension-size-400) + --mod-calendar-day-height, + var(--spectrum-calendar-day-height) ); margin: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) ) 0; - width: calc( - var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ) + - var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ) * 2 - ); } .spectrum-Calendar-date.is-range-selection.is-range-end, .spectrum-Calendar-date.is-range-selection.is-range-start, .spectrum-Calendar-date.is-range-selection.is-selection-end, .spectrum-Calendar-date.is-range-selection.is-selection-start { - width: calc( - var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ) + + inline-size: calc( + var(--mod-calendar-day-width, var(--spectrum-calendar-day-width)) + var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) ) ); } .spectrum-Calendar-date.is-range-selection.is-selection-end, .spectrum-Calendar-date.is-range-selection.is-selection-start { font-weight: var( - --spectrum-calendar-day-text-font-weight-cap-selected, - var(--spectrum-global-font-weight-bold) + --mod-calendar-day-text-font-weight-cap-selected, + var(--spectrum-calendar-day-text-font-weight-cap-selected) ); } .spectrum-Calendar-date.is-range-selection.is-selection-end:after, .spectrum-Calendar-date.is-range-selection.is-selection-start:after { + block-size: var( + --mod-calendar-day-height, + var(--spectrum-calendar-day-height) + ); border-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); content: ''; display: block; - height: var( - --spectrum-calendar-day-height, - var(--spectrum-global-dimension-size-400) + inline-size: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); + inset-block-start: 0; position: absolute; - top: 0; - width: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); -} -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-range-start, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-start { - padding-right: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ); -} -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-range-start, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-start { - padding-left: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ); -} -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-range-start, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-start { - margin-left: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ); -} -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-range-start, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-start { - margin-right: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ); -} -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-range-start, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-start { - border-top-left-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); } -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-range-start, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-start { - border-top-right-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); -} -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-range-start, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-start { - border-bottom-left-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); -} -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-range-start, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-start { - border-bottom-right-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); -} -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-range-start:after, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-range-start:before, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-start:after, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-start:before { - left: 0; -} -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-range-start:after, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-range-start:before, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-start:after, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-start:before { - right: 0; -} -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-range-end, -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-selection-end { - padding-left: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ); -} -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-range-end, -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-selection-end { - padding-right: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ); -} -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-range-end, -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-selection-end { - margin-right: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ); -} -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-range-end, -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-selection-end { - margin-left: var( - --spectrum-calendar-day-padding, - var(--spectrum-global-dimension-static-size-50) - ); -} -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-range-end, -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-selection-end { - border-top-right-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); -} -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-range-end, -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-selection-end { - border-top-left-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); -} -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-range-end, -:host([dir='ltr']) .spectrum-Calendar-date.is-range-selection.is-selection-end { - border-bottom-right-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) - ); -} -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-range-end, -:host([dir='rtl']) .spectrum-Calendar-date.is-range-selection.is-selection-end { - border-bottom-left-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) +.spectrum-Calendar-date.is-range-selection.is-range-start, +.spectrum-Calendar-date.is-range-selection.is-selection-start { + border-end-start-radius: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) + ); + border-start-start-radius: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) + ); + margin-inline-start: var( + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) + ); + padding-inline-end: var( + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) ); } -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-range-end:after, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-range-end:before, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-end:after, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-end:before { - left: auto; -} -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-range-end:after, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-range-end:before, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-end:after, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-end:before { - right: auto; -} -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-range-end:after, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-range-end:before, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-end:after, -:host([dir='ltr']) - .spectrum-Calendar-date.is-range-selection.is-selection-end:before { - right: 0; -} -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-range-end:after, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-range-end:before, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-end:after, -:host([dir='rtl']) - .spectrum-Calendar-date.is-range-selection.is-selection-end:before { - left: 0; -} +.spectrum-Calendar-date.is-range-selection.is-range-start:after, +.spectrum-Calendar-date.is-range-selection.is-range-start:before, +.spectrum-Calendar-date.is-range-selection.is-selection-start:after, +.spectrum-Calendar-date.is-range-selection.is-selection-start:before { + inset-inline-start: 0; +} +.spectrum-Calendar-date.is-range-selection.is-range-end, +.spectrum-Calendar-date.is-range-selection.is-selection-end { + border-end-end-radius: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) + ); + border-start-end-radius: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) + ); + margin-inline-end: var( + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) + ); + padding-inline-start: var( + --mod-calendar-day-padding, + var(--spectrum-calendar-day-padding) + ); +} +.spectrum-Calendar-date.is-range-selection.is-range-end:after, +.spectrum-Calendar-date.is-range-selection.is-range-end:before, +.spectrum-Calendar-date.is-range-selection.is-selection-end:after, +.spectrum-Calendar-date.is-range-selection.is-selection-end:before { + inset-inline: auto 0; +} .spectrum-Calendar-date.is-range-selection.is-range-start.is-range-end, .spectrum-Calendar-date.is-range-selection.is-selection-end.is-range-start, .spectrum-Calendar-date.is-range-selection.is-selection-start.is-range-end, .spectrum-Calendar-date.is-range-selection.is-selection-start.is-selection-end { border-radius: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); - width: var( - --spectrum-calendar-day-width, - var(--spectrum-global-dimension-size-400) + inline-size: var( + --mod-calendar-day-width, + var(--spectrum-calendar-day-width) ); } -.spectrum-Calendar-nextMonth, -.spectrum-Calendar-prevMonth { - color: var( - --spectrum-calendar-button-icon-color, - var(--spectrum-global-color-gray-700) - ); -} -.spectrum-Calendar-dayOfWeek { +.spectrum-Calendar-date { color: var( - --spectrum-calendar-day-title-text-color, - var(--spectrum-global-color-gray-700) + --highcontrast-calendar-day-title-text-color, + var( + --mod-calendar-day-title-text-color, + var(--spectrum-calendar-day-title-text-color) + ) ); } .spectrum-Calendar-date:hover { color: var( - --spectrum-calendar-day-text-color-hover, - var(--spectrum-alias-text-color-hover) + --highcontrast-calendar-day-text-color-hover, + var( + --mod-calendar-day-text-color-hover, + var(--spectrum-calendar-day-text-color-hover) + ) ); } .spectrum-Calendar-date:hover:not(.is-selection-end):not(.is-selection-start):before { background: var( - --spectrum-calendar-day-background-color-hover, - var(--spectrum-alias-highlight-hover) - ); -} -.spectrum-Calendar-date:hover.is-selected { - color: var( - --spectrum-calendar-day-text-color-selected-hover, - var(--spectrum-alias-text-color-hover) + --highcontrast-calendar-day-background-color-hover, + var( + --mod-calendar-day-background-color-hover, + var(--spectrum-calendar-day-background-color-hover) + ) ); } .spectrum-Calendar-date:hover.is-selected:not(.is-selection-end):not(.is-selection-start):before { background: var( - --spectrum-calendar-day-background-color-selected-hover, - var(--spectrum-alias-highlight-selected-hover) + --highcontrast-calendar-day-background-color-selected-hover, + var( + --mod-calendar-day-background-color-selected-hover, + var(--spectrum-calendar-day-background-color-selected-hover) + ) ); } .spectrum-Calendar-date:hover.is-range-selection:before { background: var( - --spectrum-calendar-day-background-color-selected-hover, - var(--spectrum-alias-highlight-selected-hover) + --highcontrast-calendar-day-background-color-selected-hover, + var( + --mod-calendar-day-background-color-selected-hover, + var(--spectrum-calendar-day-background-color-selected-hover) + ) ); } .spectrum-Calendar-date:active { background-color: var( - --spectrum-calendar-day-background-color-down, - var(--spectrum-alias-highlight-down) + --highcontrast-calendar-day-background-color-down, + var( + --mod-calendar-day-background-color-down, + var(--spectrum-calendar-day-background-color-down) + ) ); } .spectrum-Calendar-date.is-selected { background: var( - --spectrum-calendar-day-background-color-selected, - var(--spectrum-alias-highlight-selected) + --highcontrast-calendar-day-background-color-selected, + var( + --mod-calendar-day-background-color-selected, + var(--spectrum-calendar-day-background-color-selected) + ) ); color: var( - --spectrum-calendar-day-text-color-selected, - var(--spectrum-alias-text-color-hover) + --highcontrast-calendar-day-text-color-selected, + var( + --mod-calendar-day-text-color-selected, + var(--spectrum-calendar-day-text-color-selected) + ) ); } .spectrum-Calendar-date.is-selected:not(.is-range-selection) { background: var( - --spectrum-calendar-day-background-color-cap-selected, - var(--spectrum-alias-highlight-selected-hover) + --highcontrast-calendar-day-background-color-cap-selected, + var( + --mod-calendar-day-background-color-cap-selected, + var(--spectrum-calendar-day-background-color-cap-selected) + ) ); } .spectrum-Calendar-date.is-today { border-color: var( - --spectrum-calendar-day-today-border-color, - var(--spectrum-global-color-gray-800) + --highcontrast-calendar-day-today-border-color, + var( + --mod-calendar-day-today-border-color, + var(--spectrum-calendar-day-today-border-color) + ) ); color: var( - --spectrum-calendar-day-today-text-color, - var(--spectrum-alias-text-color) + --highcontrast-calendar-day-today-text-color, + var( + --mod-calendar-day-today-text-color, + var(--spectrum-calendar-day-today-text-color) + ) ); } .spectrum-Calendar-date.is-today:before { border-color: var( - --spectrum-calendar-day-today-border-color, - var(--spectrum-global-color-gray-800) + --highcontrast-calendar-day-today-border-color, + var( + --mod-calendar-day-today-border-color, + var(--spectrum-calendar-day-today-border-color) + ) ); } .spectrum-Calendar-date.is-today:hover.is-selected:not(.is-range-selection):before { background: var( - --spectrum-calendar-day-today-background-color-selected-hover, - var(--spectrum-alias-highlight-selected-hover) + --highcontrast-calendar-day-today-background-color-selected-hover, + var( + --mod-calendar-day-today-background-color-selected-hover, + var(--spectrum-calendar-day-today-background-color-selected-hover) + ) ); } .spectrum-Calendar-date.is-today.is-disabled { border-color: var( - --spectrum-calendar-day-today-border-color-disabled, - var(--spectrum-global-color-gray-400) + --highcontrast-calendar-day-today-border-color-disabled, + var( + --mod-calendar-day-today-border-color-disabled, + var(--spectrum-calendar-day-today-border-color-disabled) + ) ); color: var( - --spectrum-calendar-day-today-text-color-disabled, - var(--spectrum-alias-text-color-disabled) + --highcontrast-calendar-day-today-text-color-disabled, + var( + --mod-calendar-day-today-text-color-disabled, + var(--spectrum-calendar-day-today-text-color-disabled) + ) ); } .spectrum-Calendar-date.is-today.is-disabled:before { border-color: var( - --spectrum-calendar-day-today-border-color-disabled, - var(--spectrum-global-color-gray-400) + --highcontrast-calendar-day-today-border-color-disabled, + var( + --mod-calendar-day-today-border-color-disabled, + var(--spectrum-calendar-day-today-border-color-disabled) + ) ); } .spectrum-Calendar-date.is-focused:not(.is-range-selection) { background: var( - --spectrum-calendar-day-background-color-key-focus, - var(--spectrum-alias-highlight-hover) + --highcontrast-calendar-day-background-color-key-focus, + var( + --mod-calendar-day-background-color-key-focus, + var(--spectrum-calendar-day-background-color-key-focus) + ) ); border-color: var( - --spectrum-calendar-day-border-color-key-focus, - var(--spectrum-alias-focus-color) + --highcontrast-calendar-day-border-color-key-focus, + var( + --mod-calendar-day-border-color-key-focus, + var(--spectrum-calendar-day-border-color-key-focus) + ) ); color: var( - --spectrum-calendar-day-text-color-key-focus, - var(--spectrum-alias-text-color-hover) + --highcontrast-calendar-day-text-color-key-focus, + var( + --mod-calendar-day-text-color-key-focus, + var(--spectrum-calendar-day-text-color-key-focus) + ) ); } .spectrum-Calendar-date.is-focused:not(.is-range-selection).is-today { border-color: var( - --spectrum-calendar-day-border-color-key-focus, - var(--spectrum-alias-focus-color) + --highcontrast-calendar-day-border-color-key-focus, + var( + --mod-calendar-day-border-color-key-focus, + var(--spectrum-calendar-day-border-color-key-focus) + ) ); } .spectrum-Calendar-date.is-focused:not(.is-range-selection).is-selected, .spectrum-Calendar-date.is-focused:not(.is-range-selection):active { background: var( - --spectrum-calendar-day-background-color-cap-selected, - var(--spectrum-alias-highlight-selected-hover) + --highcontrast-calendar-day-background-color-cap-selected, + var( + --mod-calendar-day-background-color-cap-selected, + var(--spectrum-calendar-day-background-color-cap-selected) + ) ); border-color: var( - --spectrum-calendar-day-border-color-key-focus, - var(--spectrum-alias-focus-color) + --highcontrast-calendar-day-border-color-key-focus, + var( + --mod-calendar-day-border-color-key-focus, + var(--spectrum-calendar-day-border-color-key-focus) + ) ); color: var( - --spectrum-calendar-day-text-color-selected, - var(--spectrum-alias-text-color-hover) + --highcontrast-calendar-day-text-color-selected, + var( + --mod-calendar-day-text-color-selected, + var(--spectrum-calendar-day-text-color-selected) + ) ); } .spectrum-Calendar-date.is-focused.is-selected:before { background: var( - --spectrum-calendar-day-background-color-selected-hover, - var(--spectrum-alias-highlight-selected-hover) + --highcontrast-calendar-day-background-color-selected-hover, + var( + --mod-calendar-day-background-color-selected-hover, + var(--spectrum-calendar-day-background-color-selected-hover) + ) ); } .spectrum-Calendar-date.is-focused.is-range-selection:before { background: var( - --spectrum-calendar-day-background-color-selected-hover, - var(--spectrum-alias-highlight-selected-hover) + --highcontrast-calendar-day-background-color-selected-hover, + var( + --mod-calendar-day-background-color-selected-hover, + var(--spectrum-calendar-day-background-color-selected-hover) + ) ); } .spectrum-Calendar-date.is-focused:before { border-color: var( - --spectrum-calendar-day-border-color-key-focus, - var(--spectrum-alias-focus-color) + --highcontrast-calendar-day-border-color-key-focus, + var( + --mod-calendar-day-border-color-key-focus, + var(--spectrum-calendar-day-border-color-key-focus) + ) ); } .spectrum-Calendar-date.is-disabled { color: var( - --spectrum-calendar-day-text-color-disabled, - var(--spectrum-alias-text-color-disabled) + --highcontrast-calendar-day-text-color-disabled, + var( + --mod-calendar-day-text-color-disabled, + var(--spectrum-calendar-day-text-color-disabled) + ) ); } .spectrum-Calendar-date.is-selection-end, .spectrum-Calendar-date.is-selection-start { color: var( - --spectrum-calendar-day-text-color-cap-selected, - var(--spectrum-alias-text-color-hover) + --highcontrast-calendar-day-text-color-cap-selected, + var( + --mod-calendar-day-text-color-cap-selected, + var(--spectrum-calendar-day-text-color-cap-selected) + ) ); } .spectrum-Calendar-date.is-selection-end:after, .spectrum-Calendar-date.is-selection-start:after { background-color: var( - --spectrum-calendar-day-background-color-selected, - var(--spectrum-alias-highlight-selected) + --highcontrast-calendar-day-background-color-selected, + var( + --mod-calendar-day-background-color-selected, + var(--spectrum-calendar-day-background-color-selected) + ) ); } .spectrum-Calendar-date.is-selection-end.is-disabled, .spectrum-Calendar-date.is-selection-start.is-disabled { color: var( - --spectrum-calendar-day-text-color-disabled, - var(--spectrum-alias-text-color-disabled) + --highcontrast-calendar-day-text-color-disabled, + var( + --mod-calendar-day-text-color-disabled, + var(--spectrum-calendar-day-text-color-disabled) + ) ); } @media (forced-colors: active) { .spectrum-Calendar-date { - --spectrum-calendar-button-icon-color: ButtonText; - --spectrum-calendar-day-background-color-cap-selected: Highlight; - --spectrum-calendar-day-background-color-down: ButtonFace; - --spectrum-calendar-day-background-color-hover: transparent; - --spectrum-calendar-day-background-color-key-focus: ButtonFace; - --spectrum-calendar-day-background-color-selected-hover: transparent; - --spectrum-calendar-day-background-color-selected: Highlight; - --spectrum-calendar-day-border-color-key-focus: ButtonText; - --spectrum-calendar-day-text-color-cap-selected: HighlightText; - --spectrum-calendar-day-text-color-disabled: GrayText; - --spectrum-calendar-day-text-color-hover: ButtonText; - --spectrum-calendar-day-text-color-key-focus: ButtonText; - --spectrum-calendar-day-text-color-selected-hover: HighlightText; - --spectrum-calendar-day-text-color-selected: HighlightText; - --spectrum-calendar-day-title-text-color: CanvasText; - --spectrum-calendar-day-today-background-color-selected-hover: Highlight; - --spectrum-calendar-day-today-border-color-disabled: GrayText; - --spectrum-calendar-day-today-border-color: ButtonText; - --spectrum-calendar-day-today-text-color-disabled: GrayText; - --spectrum-calendar-day-today-text-color: ButtonText; + --highcontrast-calendar-button-icon-color: ButtonText; + --highcontrast-calendar-day-background-color-cap-selected: Highlight; + --highcontrast-calendar-day-background-color-down: ButtonFace; + --highcontrast-calendar-day-background-color-hover: transparent; + --highcontrast-calendar-day-background-color-key-focus: ButtonFace; + --highcontrast-calendar-day-background-color-selected-hover: transparent; + --highcontrast-calendar-day-background-color-selected: Highlight; + --highcontrast-calendar-day-border-color-key-focus: ButtonText; + --highcontrast-calendar-day-text-color-cap-selected: HighlightText; + --highcontrast-calendar-day-text-color-disabled: GrayText; + --highcontrast-calendar-day-text-color-hover: ButtonText; + --highcontrast-calendar-day-text-color-key-focus: ButtonText; + --highcontrast-calendar-day-text-color-selected-hover: HighlightText; + --highcontrast-calendar-day-text-color-selected: HighlightText; + --highcontrast-calendar-day-title-text-color: CanvasText; + --highcontrast-calendar-day-today-background-color-selected-hover: Highlight; + --highcontrast-calendar-day-today-border-color-disabled: GrayText; + --highcontrast-calendar-day-today-border-color: ButtonText; + --highcontrast-calendar-day-today-text-color-disabled: GrayText; + --highcontrast-calendar-day-today-text-color: ButtonText; color: CanvasText; forced-color-adjust: none; } - .spectrum-Calendar-date.is-range-selection { + .spectrum-Calendar-date.is-range-selection.is-today { color: HighlightText; } .spectrum-Calendar-date.is-range-selection.is-selection-end:after, @@ -744,7 +715,4 @@ governing permissions and limitations under the License. background: Highlight; color: HighlightText; } - .spectrum-Calendar-date:hover.is-today { - color: ButtonText; - } } diff --git a/packages/textfield/src/spectrum-config.js b/packages/textfield/src/spectrum-config.js index 730a3934e39..d404d7e06b7 100644 --- a/packages/textfield/src/spectrum-config.js +++ b/packages/textfield/src/spectrum-config.js @@ -73,6 +73,7 @@ export default { converter.classToAttribute('is-invalid', 'invalid'), converter.classToAttribute('is-disabled', 'disabled'), converter.classToAttribute('is-readOnly', 'readonly'), + converter.pseudoToAttribute('read-only', 'readonly'), ], }, ], diff --git a/packages/textfield/src/spectrum-textfield.css b/packages/textfield/src/spectrum-textfield.css index c17f166620a..468b9138d8a 100644 --- a/packages/textfield/src/spectrum-textfield.css +++ b/packages/textfield/src/spectrum-textfield.css @@ -1076,9 +1076,9 @@ governing permissions and limitations under the License. ) ); } -.input:read-only, :host([readonly]) #textfield .input, -:host([readonly]) #textfield:hover .input { +:host([readonly]) #textfield:hover .input, +:host([readonly]) .input { background-color: #0000; border-color: #0000; color: var( @@ -1090,9 +1090,9 @@ governing permissions and limitations under the License. ); outline: none; } -.input:read-only::placeholder, :host([readonly]) #textfield .input::placeholder, -:host([readonly]) #textfield:hover .input::placeholder { +:host([readonly]) #textfield:hover .input::placeholder, +:host([readonly]) .input::placeholder { background-color: #0000; color: var( --highcontrast-textfield-text-color-readonly, diff --git a/packages/time-field/.npmignore b/packages/time-field/.npmignore new file mode 100644 index 00000000000..c50cbe188c0 --- /dev/null +++ b/packages/time-field/.npmignore @@ -0,0 +1,2 @@ +stories +test \ No newline at end of file diff --git a/packages/time-field/README.md b/packages/time-field/README.md new file mode 100644 index 00000000000..808d31e2ac8 --- /dev/null +++ b/packages/time-field/README.md @@ -0,0 +1,33 @@ +## 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 new file mode 100644 index 00000000000..925298d825a --- /dev/null +++ b/packages/time-field/exports.json @@ -0,0 +1,4 @@ +{ + "./src/*": "./src/*.js", + "./sp-time-field.js": "./sp-time-field.js" +} diff --git a/packages/time-field/package.json b/packages/time-field/package.json new file mode 100644 index 00000000000..9f3b7b6d9da --- /dev/null +++ b/packages/time-field/package.json @@ -0,0 +1,68 @@ +{ + "name": "@spectrum-web-components/time-field", + "version": "0.0.1", + "publishConfig": { + "access": "public" + }, + "description": "Web component implementation of a Spectrum design TimeField", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/adobe/spectrum-web-components.git", + "directory": "packages/time-field" + }, + "author": "", + "homepage": "https://adobe.github.io/spectrum-web-components/components/time-field", + "bugs": { + "url": "https://github.com/adobe/spectrum-web-components/issues" + }, + "main": "./src/index.js", + "module": "./src/index.js", + "type": "module", + "exports": { + ".": { + "development": "./src/index.dev.js", + "default": "./src/index.js" + }, + "./package.json": "./package.json", + "./src/TimeField.js": { + "development": "./src/TimeField.dev.js", + "default": "./src/TimeField.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" + } + }, + "scripts": { + "test": "echo \"Error: run tests from mono-repo root.\" && exit 1" + }, + "files": [ + "**/*.d.ts", + "**/*.js", + "**/*.js.map", + "custom-elements.json", + "!stories/", + "!test/" + ], + "keywords": [ + "spectrum css", + "web components", + "lit-element", + "lit-html" + ], + "dependencies": { + "@spectrum-web-components/input-segments": "^0.0.1" + }, + "types": "./src/index.d.ts", + "customElements": "custom-elements.json", + "sideEffects": [ + "./sp-*.js", + "./**/*.dev.js" + ] +} diff --git a/packages/time-field/sp-time-field.ts b/packages/time-field/sp-time-field.ts new file mode 100644 index 00000000000..5f998e4506e --- /dev/null +++ b/packages/time-field/sp-time-field.ts @@ -0,0 +1,20 @@ +/* +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 { TimeField } from './src/TimeField.js'; + +customElements.define('sp-time-field', TimeField); + +declare global { + interface HTMLElementTagNameMap { + 'sp-time-field': TimeField; + } +} diff --git a/packages/time-field/src/TimeField.ts b/packages/time-field/src/TimeField.ts new file mode 100644 index 00000000000..6847b2dbc3c --- /dev/null +++ b/packages/time-field/src/TimeField.ts @@ -0,0 +1,33 @@ +/* +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/index.ts b/packages/time-field/src/index.ts new file mode 100644 index 00000000000..2c5490778da --- /dev/null +++ b/packages/time-field/src/index.ts @@ -0,0 +1,12 @@ +/* +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. +*/ +export * from './TimeField.js'; diff --git a/packages/time-field/src/time-field.css b/packages/time-field/src/time-field.css new file mode 100644 index 00000000000..26ef92385f0 --- /dev/null +++ b/packages/time-field/src/time-field.css @@ -0,0 +1,11 @@ +/* +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/packages/time-field/stories/time-field.stories.ts b/packages/time-field/stories/time-field.stories.ts new file mode 100644 index 00000000000..a176b56ec3f --- /dev/null +++ b/packages/time-field/stories/time-field.stories.ts @@ -0,0 +1,377 @@ +/* +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 { html, nothing, TemplateResult } from '@spectrum-web-components/base'; +import { TimeGranularity } from '@spectrum-web-components/input-segments/src/types.js'; + +import { spreadProps } from '../../../test/lit-helpers.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, + }, +}; + +export default { + title: 'Time Field', + component: 'sp-time-field', + + argTypes: { + lang: { + options: locales, + control: { + type: 'select', + }, + table: { + defaultValue: { + summary: defaultLocale, + }, + }, + }, + + // 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'], + }, + }, +}; + +interface StoryArgs { + lang?: string; + + selectedDateTime?: Date; + timeGranularity?: TimeGranularity; + quiet?: boolean; + disabled?: boolean; + readonly?: boolean; + valid?: boolean; + invalid?: boolean; + + onChange?: (dateTime: Date) => void; + + [prop: string]: unknown; +} + +const renderTimeField = ( + title: string, + args: StoryArgs = {}, + content: TemplateResult | typeof nothing = nothing +): TemplateResult => { + return html` + + + +

${title}

+

Locale: ${args.lang}

+
+ + ${content} + +
+ `; +}; + +export const Default = (args: StoryArgs = {}): TemplateResult => { + return renderTimeField('Default', args); +}; + +export const selectedDateTime = (args: StoryArgs = {}): TemplateResult[] => { + const formatter = Intl.DateTimeFormat(args.lang ?? defaultLocale, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + return [ + new Date(1995, 1, 28, 9, 31, 7), + new Date(2021, 10, 2, 16, 1, 54), + ].map((dateTime) => { + const formatted = formatter.format(dateTime); + const title = `Selected Date/Time: ${formatted}`; + + args = { + ...args, + selectedDateTime: dateTime, + }; + + return renderTimeField(title, args); + }); +}; + +export const timeGranularity = (args: StoryArgs = {}): TemplateResult => { + args = { + ...args, + timeGranularity: args.timeGranularity, + }; + + return renderTimeField(`Time Granularity: ${args.timeGranularity}`, args); +}; + +timeGranularity.argTypes = { + timeGranularity: { + options: timeGranularities, + control: { + type: 'select', + }, + table: { + defaultValue: { + summary: 'minute', + }, + }, + }, +}; + +timeGranularity.args = { + timeGranularity: 'second', +}; + +export const disabled = (args: StoryArgs = {}): TemplateResult => { + return renderTimeField(`Disabled? ${args.disabled}`, args); +}; + +disabled.argTypes = { + disabled: { + control: 'boolean', + table: { + defaultValue: { + summary: false, + }, + }, + }, +}; + +disabled.args = { + disabled: true, +}; + +export const quiet = (args: StoryArgs = {}): TemplateResult => { + return renderTimeField(`Quiet? ${args.quiet}`, args); +}; + +quiet.argTypes = { + quiet: { + control: 'boolean', + table: { + defaultValue: { + summary: false, + }, + }, + }, +}; + +quiet.args = { + quiet: true, +}; + +export const readonly = (args: StoryArgs = {}): TemplateResult => { + return renderTimeField(`Read only? ${args.readonly}`, args); +}; + +readonly.argTypes = { + readonly: { + control: 'boolean', + table: { + defaultValue: { + summary: false, + }, + }, + }, +}; + +readonly.args = { + readonly: true, +}; + +export const autoFocus = (args: StoryArgs = {}): TemplateResult => { + args = { + ...args, + autofocus: true, + }; + + return renderTimeField('Auto focus', args); +}; + +export const valid = (args: StoryArgs = {}): TemplateResult => { + return renderTimeField(`Is valid? ${args.valid}`, args); +}; + +valid.argTypes = { + valid: { + control: 'boolean', + table: { + defaultValue: { + summary: false, + }, + }, + }, +}; + +valid.args = { + valid: true, +}; + +export const invalid = (args: StoryArgs = {}): TemplateResult => { + return renderTimeField(`Is invalid? ${args.invalid}`, args); +}; + +invalid.argTypes = { + invalid: { + control: 'boolean', + table: { + defaultValue: { + summary: false, + }, + }, + }, +}; + +invalid.args = { + invalid: true, +}; + +export const helpText = (args: StoryArgs = {}): TemplateResult => { + const content = html` + My default help text + `; + + return renderTimeField(`With help text`, args, content); +}; + +export const negativeHelpText = (args: StoryArgs = {}): TemplateResult => { + const content = html` + + Default help text (displayed only when not invalid) + + + This field is required! + + `; + + return renderTimeField(`With negative help text`, args, content); +}; + +negativeHelpText.argTypes = { + invalid: { + control: 'boolean', + table: { + defaultValue: { + summary: false, + }, + }, + }, +}; + +negativeHelpText.args = { + invalid: true, +}; diff --git a/packages/time-field/test/benchmark/basic-test.ts b/packages/time-field/test/benchmark/basic-test.ts new file mode 100644 index 00000000000..5192687fdc7 --- /dev/null +++ b/packages/time-field/test/benchmark/basic-test.ts @@ -0,0 +1,18 @@ +/* +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 '@spectrum-web-components/time-field/sp-time-field.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/time-field/test/time-field.test.ts new file mode 100644 index 00000000000..56127d9e2bb --- /dev/null +++ b/packages/time-field/test/time-field.test.ts @@ -0,0 +1,38 @@ +/* +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 { elementUpdated, expect, fixture, html } from '@open-wc/testing'; + +import '../sp-time-field.js'; +import { TimeField } from '..'; +import { testForLitDevWarnings } from '../../../test/testing-helpers.js'; + +describe('TimeField', () => { + testForLitDevWarnings( + async () => + await fixture( + html` + + ` + ) + ); + it('loads default time-field accessibly', async () => { + const el = await fixture( + html` + + ` + ); + + await elementUpdated(el); + + await expect(el).to.be.accessible(); + }); +}); diff --git a/packages/time-field/tsconfig.json b/packages/time-field/tsconfig.json new file mode 100644 index 00000000000..2326f7e4c30 --- /dev/null +++ b/packages/time-field/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "./" + }, + "include": ["*.ts", "src/*.ts"], + "exclude": ["test/*.ts", "stories/*.ts"], + "references": [{ "path": "../../tools/input-segments" }] +} diff --git a/tools/bundle/elements.ts b/tools/bundle/elements.ts index af94b1b3ff1..a82d368be27 100644 --- a/tools/bundle/elements.ts +++ b/tools/bundle/elements.ts @@ -89,6 +89,7 @@ import '@spectrum-web-components/textfield/sp-textfield.js'; import '@spectrum-web-components/theme/sp-theme.js'; import '@spectrum-web-components/theme/src/themes.js'; import '@spectrum-web-components/thumbnail/sp-thumbnail.js'; +import '@spectrum-web-components/time-field/sp-time-field.js'; import '@spectrum-web-components/toast/sp-toast.js'; import '@spectrum-web-components/tooltip/sp-tooltip.js'; import '@spectrum-web-components/top-nav/sp-top-nav.js'; diff --git a/tools/bundle/package.json b/tools/bundle/package.json index 2b26ae4bc7b..766ac13cff2 100644 --- a/tools/bundle/package.json +++ b/tools/bundle/package.json @@ -96,6 +96,7 @@ "@spectrum-web-components/icons-workflow": "^0.35.0", "@spectrum-web-components/iconset": "^0.35.0", "@spectrum-web-components/illustrated-message": "^0.35.0", + "@spectrum-web-components/input-segments": "^0.0.1", "@spectrum-web-components/link": "^0.35.0", "@spectrum-web-components/menu": "^0.35.0", "@spectrum-web-components/meter": "^0.35.0", @@ -126,6 +127,7 @@ "@spectrum-web-components/textfield": "^0.35.0", "@spectrum-web-components/theme": "^0.35.0", "@spectrum-web-components/thumbnail": "^0.35.0", + "@spectrum-web-components/time-field": "^0.0.1", "@spectrum-web-components/toast": "^0.35.0", "@spectrum-web-components/tooltip": "^0.35.0", "@spectrum-web-components/top-nav": "^0.35.0", diff --git a/tools/bundle/src/index.ts b/tools/bundle/src/index.ts index 3d7a937e503..4c9bdd0f5c7 100644 --- a/tools/bundle/src/index.ts +++ b/tools/bundle/src/index.ts @@ -63,6 +63,7 @@ export * from '@spectrum-web-components/tags'; export * from '@spectrum-web-components/textfield'; export * from '@spectrum-web-components/theme'; export * from '@spectrum-web-components/thumbnail'; +export * from '@spectrum-web-components/time-field'; export * from '@spectrum-web-components/toast'; export * from '@spectrum-web-components/tooltip'; export * from '@spectrum-web-components/top-nav'; diff --git a/tools/bundle/tsconfig.json b/tools/bundle/tsconfig.json index bc2b6774fe0..fa49e45ed11 100644 --- a/tools/bundle/tsconfig.json +++ b/tools/bundle/tsconfig.json @@ -65,6 +65,7 @@ { "path": "../../packages/textfield" }, { "path": "../theme" }, { "path": "../../packages/thumbnail" }, + { "path": "../../packages/time-field" }, { "path": "../../packages/toast" }, { "path": "../../packages/tooltip" }, { "path": "../../packages/top-nav" }, diff --git a/tools/input-segments/.npmignore b/tools/input-segments/.npmignore new file mode 100644 index 00000000000..c50cbe188c0 --- /dev/null +++ b/tools/input-segments/.npmignore @@ -0,0 +1,2 @@ +stories +test \ No newline at end of file diff --git a/tools/input-segments/README.md b/tools/input-segments/README.md new file mode 100644 index 00000000000..fd4177d5364 --- /dev/null +++ b/tools/input-segments/README.md @@ -0,0 +1,30 @@ +## Description + +### Usage + +[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/input-segments?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/input-segments) +[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/input-segments?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/input-segments) + +``` +yarn add @spectrum-web-components/input-segments +``` + +`InputSegments` cannot be used directly as a component, as there is no `` component. The only way to use `InputSegments` is by extending a class: + +```js +import { InputSegments } from '@spectrum-web-components/input-segments'; + +export class MyInput extends InputSegments { + ... +} +``` + +## To-do list + +- Include ARIA attributes for editable segments +- Use `@input`/`@beforeinput` events to handle data input/content cleanup +- Move `handleKeydown()` call to a cache so that it doesn't cycle on the binding in each render pass +- Rename `handleKeydown()` to match the new events used +- Define/revise list of locales and include them in the VRT process to ensure long-term delivery +- Complete documentation +- Add/Review unit tests diff --git a/tools/input-segments/exports.json b/tools/input-segments/exports.json new file mode 100644 index 00000000000..8128c96f462 --- /dev/null +++ b/tools/input-segments/exports.json @@ -0,0 +1,3 @@ +{ + "./src/*": "./src/*.js" +} diff --git a/tools/input-segments/package.json b/tools/input-segments/package.json new file mode 100644 index 00000000000..e7642a9ef53 --- /dev/null +++ b/tools/input-segments/package.json @@ -0,0 +1,71 @@ +{ + "name": "@spectrum-web-components/input-segments", + "version": "0.0.1", + "publishConfig": { + "access": "public" + }, + "description": "“Abstract” component used to extend date and time form fields classes", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/adobe/spectrum-web-components.git", + "directory": "tools/input-segments" + }, + "author": "", + "homepage": "https://adobe.github.io/spectrum-web-components/tools/input-segments", + "bugs": { + "url": "https://github.com/adobe/spectrum-web-components/issues" + }, + "main": "./src/index.js", + "module": "./src/index.js", + "type": "module", + "exports": { + ".": { + "development": "./src/index.dev.js", + "default": "./src/index.js" + }, + "./package.json": "./package.json", + "./src/InputSegments.js": { + "development": "./src/InputSegments.dev.js", + "default": "./src/InputSegments.js" + }, + "./src/index.js": { + "development": "./src/index.dev.js", + "default": "./src/index.js" + }, + "./src/input-segments.css.js": "./src/input-segments.css.js", + "./src/types.js": { + "development": "./src/types.dev.js", + "default": "./src/types.js" + } + }, + "scripts": { + "test": "echo \"Error: run tests from mono-repo root.\" && exit 1" + }, + "files": [ + "**/*.d.ts", + "**/*.js", + "**/*.js.map", + "custom-elements.json", + "!stories/", + "!test/" + ], + "keywords": [ + "spectrum css", + "web components", + "lit-element", + "lit-html" + ], + "dependencies": { + "@internationalized/date": "^3.2.1", + "@internationalized/number": "^3.1.0", + "@spectrum-web-components/base": "^0.35.0", + "@spectrum-web-components/reactive-controllers": "^0.35.0", + "@spectrum-web-components/textfield": "^0.35.0" + }, + "types": "./src/index.d.ts", + "customElements": "custom-elements.json", + "sideEffects": [ + "./**/*.dev.js" + ] +} diff --git a/tools/input-segments/src/InputSegments.ts b/tools/input-segments/src/InputSegments.ts new file mode 100644 index 00000000000..59567a9b63f --- /dev/null +++ b/tools/input-segments/src/InputSegments.ts @@ -0,0 +1,1277 @@ +/* +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 { + CalendarDateTime, + DateFormatter, + endOfMonth, + getLocalTimeZone, + getMinimumDayInMonth, + getMinimumMonthInYear, + now, + toCalendarDateTime, +} from '@internationalized/date'; +import { NumberParser } from '@internationalized/number'; +import { + CSSResultArray, + html, + PropertyValues, + TemplateResult, +} from '@spectrum-web-components/base'; +import { + property, + query, + state, +} from '@spectrum-web-components/base/src/decorators.js'; +import { + ClassInfo, + classMap, + ifDefined, + StyleInfo, + styleMap, + when, +} from '@spectrum-web-components/base/src/directives.js'; +import { + LanguageResolutionController, + languageResolverUpdatedSymbol, +} from '@spectrum-web-components/reactive-controllers/src/LanguageResolution.js'; +import { TextfieldBase } from '@spectrum-web-components/textfield'; + +import { + AM, + dateSegmentTypes, + maxHourAM, + maxHourPM, + minHourAM, + minHourPM, + PM, + timeSegmentTypes, +} from './types.js'; +import type { + EditableSegmentType, + Segment, + SegmentDetails, + SegmentValueAndLimits, + TimeGranularity, +} from './types.js'; + +import styles from './input-segments.css.js'; + +/** + * An utility to check if the given value is a number (not `undefined`) + * + * @param value - Number to check + */ +const isNumber = (value: number | undefined): value is number => { + return typeof value === 'number'; +}; + +/** + * @event change - Announces when a new date/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 InputSegments extends TextfieldBase { + public static override get styles(): CSSResultArray { + return [...super.styles, styles]; + } + + protected languageResolver = new LanguageResolutionController(this); + protected timeZone = getLocalTimeZone(); + protected formatter!: DateFormatter; + protected numberParser!: NumberParser; + + @query('.editable-segment') + firstEditableSegment!: HTMLDivElement; + + /** + * Indicates which segments that are part of time should be used + */ + @property() + timeGranularity: TimeGranularity = 'minute'; + + /** + * Defines whether a date/time should be displayed in the field + */ + @property({ attribute: false }) + selectedDateTime?: Date; + + /** + * Indicates when date segments should be included in the field + */ + @state() + protected includeDate = false; + + /** + * Indicates when time segments should be included in the field + */ + @state() + protected includeTime = false; + + @state() + protected currentDateTime = toCalendarDateTime(now(this.timeZone)); + + @state() + protected newDateTime?: CalendarDateTime; + + @state() + protected segments: Segment[] = []; + + /** + * The `TextfieldBase` class requires this getter to return an element of type `HTMLInputElement` or + * `HTMLTextAreaElement`, but since the segments are DIVs with the `contenteditable` attribute, we need to cast as + * an input only to be able to use autofocus. + * + * Note that `focusElement` is only used for that, so converting as an input will have no side effect as all + * functions and attributes used exist in both types, `HTMLInputElement` and `HTMLDivElement`. + */ + public override get focusElement(): HTMLInputElement { + return this.firstEditableSegment as HTMLInputElement; + } + + public get is12HourClock(): boolean { + return Boolean(this.formatter.resolvedOptions().hour12); + } + + protected get locale(): string { + return this.languageResolver.language; + } + + protected get daySegment(): Segment | undefined { + return this.segment('day'); + } + + protected get monthSegment(): Segment | undefined { + return this.segment('month'); + } + + protected get yearSegment(): Segment | undefined { + return this.segment('year'); + } + + protected get hourSegment(): Segment | undefined { + return this.segment('hour'); + } + + protected get minuteSegment(): Segment | undefined { + return this.segment('minute'); + } + + protected get secondSegment(): Segment | undefined { + return this.segment('second'); + } + + protected get amPmSegment(): Segment | undefined { + return this.segment('dayPeriod'); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + /** + * Segments should be created only when some properties are changed, so we control when this should happen and + * not every time the `willUpdate()` method is executed + */ + let createSegments = false; + + if (changedProperties.has(languageResolverUpdatedSymbol)) { + createSegments = true; + + this.setFormatter(); + this.setNumberParser(); + } + + if (changedProperties.has('selectedDateTime')) { + createSegments = true; + + this.setCurrentDateTime(); + } + + if (changedProperties.has('timeGranularity')) { + createSegments = true; + } + + if (createSegments) { + this.setSegments(); + } + } + + protected override renderField(): TemplateResult { + return html` + ${this.renderStateIcons()} + +
+ +
+ `; + } + + public renderLiteralSegment(segment: Segment): TemplateResult { + /** + * We need this flag below to prevent Prettier from moving the content of the tag to the next line, which causes + * problems on rendering because of spaces added before and after the content (indentation) + */ + // prettier-ignore + return html` + + `; + } + + public renderEditableSegment(segment: Segment): TemplateResult { + const isActive = !this.disabled && !this.readonly; + + const isPlaceholderVisible = segment.value === undefined; + + const segmentClasses: ClassInfo = { + 'is-placeholder': isPlaceholderVisible, + }; + + const segmentStyles: StyleInfo = { + 'min-width': isNumber(segment.maxValue) + ? `${String(segment.maxValue).length}ch` + : undefined, + }; + + /** + * TODO: Include ARIA attributes for editable segments + * TODO: Use `@input`/`@beforeinput` events to handle data input/content cleanup + * TODO: Move `handleKeydown()` call to a cache so that it doesn't cycle on the binding in each render pass + * TODO: Rename `handleKeydown()` to match the new events used + */ + return html` +
{ + this.handleKeydown(segment, event); + }} + > + ${when( + isPlaceholderVisible, + () => html` + + `, + () => segment.formatted + )} +
+ `; + } + + /** + * 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 { + 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 + */ + public handleFocusOut(): void { + super.onBlur(); + } + + /** + * Detects the pressed key and performs the correct action accordingly + * + * @param segment - Segment on which the event was fired + * @param event - Event details + */ + public handleKeydown(segment: Segment, event: KeyboardEvent): void { + switch (event.code) { + case 'ArrowUp': { + this.incrementValue(segment); + break; + } + case 'ArrowRight': { + this.focusNextSegment(event); + break; + } + case 'ArrowDown': { + this.decrementValue(segment); + 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); + } + + if (isNumberKey || isClearKey || !isAllowedKey) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + + /** + * Sets new segment value after user types some number + * + * @param segment - The segment being changed + * @param event - Event details + */ + public handleTypedValue(segment: Segment, event: KeyboardEvent): void { + const details = this.extractDetails(segment); + + if (details === undefined) { + return; + } + + const typedValue = this.numberParser.parse(event.key); + const isAmPmHour = this.is12HourClock && segment.type === 'hour'; + + segment.value = isAmPmHour + ? this.getNewValueForAmPmHourSegment(details, typedValue) + : this.getNewValueForOtherSegments(details, typedValue); + + this.valueChanged(segment); + } + + /** + * Sets the new segment value after the user clears the content + * + * @param segment - The segment being changed + */ + public handleClear(segment: Segment): void { + const details = this.extractDetails(segment); + + if (details?.value === undefined) { + return; + } + + let newValue: string | undefined; + let previousValue = details.value; + + if (this.is12HourClock && segment.type === 'hour') { + const isPM = this.isPM(details.minValue); + + if (isPM) { + previousValue -= PM; + } + + newValue = + previousValue === minHourAM + ? String(minHourAM + 1) + : String(previousValue).slice(0, -1); + + if (isPM && newValue !== '') { + newValue = String(this.numberParser.parse(newValue) + PM); + } + } else { + newValue = + segment.type === 'dayPeriod' + ? undefined + : String(previousValue).slice(0, -1); + } + + segment.value = + (newValue !== undefined && this.numberParser.parse(newValue)) || + undefined; + + this.valueChanged(segment); + } + + /** + * Returns data from the editable segment that corresponds to the given type + * + * @param type - Segment type + */ + protected segment(type: EditableSegmentType): Segment | undefined { + return this.segments.find((segment) => segment.type === type); + } + + /** + * Defines the formatter that will be used in the creation of segments + */ + private setFormatter(): void { + let dateOptions: Intl.DateTimeFormatOptions = {}; + let timeOptions: Intl.DateTimeFormatOptions = {}; + + if (this.includeDate) { + dateOptions = { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }; + } + + 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' }), + }; + } + + 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 a datetime is received by the component via property, it will use it as the current datetime to render the + * input + */ + private setCurrentDateTime(): void { + if (!this.selectedDateTime) { + return; + } + + this.selectedDateTime = new Date(this.selectedDateTime); + + if (!this.isValidTime(this.selectedDateTime)) { + this.selectedDateTime = undefined; + return; + } + + this.currentDateTime = this.dateToCalendarDateTime( + this.selectedDateTime + ); + } + + /** + * 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 setNewDateTime(): void { + this.newDateTime = undefined; + + // If none of the date/time segments are being used, there is nothing to do here + if (!this.includeDate && !this.includeTime) { + return; + } + + let year = this.yearSegment?.value; + let month = this.monthSegment?.value; + let day = this.daySegment?.value; + + // 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); + } + + return; + } + + // 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; + } + + 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'; + + 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 + ); + } + } + + /** + * Checks if the date is valid by parsing the time. Invalid dates return `NaN` for times of invalid dates + * + * @param date - `Date` object to validate + */ + private isValidTime(date: Date): boolean { + return !isNaN(date.getTime()); + } + + /** + * Converts an object of type `Date` to `CalendarDateTime` + * + * @param date - `Date` object to "convert" + */ + private 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(), + date.getSeconds() + ); + } + + /** + * Creates the segments that will be used by the input + */ + private setSegments(): void { + const dateTime = this.currentDateTime.toDate(this.timeZone); + + const segmentTypes = [ + ...(this.includeDate ? dateSegmentTypes : []), + ...(this.includeTime ? timeSegmentTypes : []), + ]; + + this.segments = this.formatter + .formatToParts(dateTime) + .filter((part) => segmentTypes.includes(part.type)) + .map((part) => this.mapToSegment(part)); + } + + /** + * 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) + */ + 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); + + const segment: Segment = { + type, + formatted, + ...(placeholder !== undefined && { placeholder }), + ...(value !== undefined && { value }), + ...(minValue !== undefined && { minValue }), + ...(maxValue !== undefined && { maxValue }), + }; + + if (part.type !== 'literal') { + this.formatValue(segment); + } + + return segment; + } + + /** + * 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 + */ + private formatValue(segment: Segment): void { + if (segment.value === undefined) { + 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; + + /** + * 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; + } + + 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; + } + + 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; + } + } + + /** + * 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; + + segment.formatted = formatted?.padStart(padMaxLength, '0'); + } + + /** + * Returns the placeholder that will be used according to the segment type + * + * @param type - Type of segment + * @param value - The value of the segment + */ + private getPlaceholder( + type: Intl.DateTimeFormatPartTypes, + value: string + ): string | undefined { + switch (type) { + case 'literal': + return undefined; + case 'dayPeriod': + return value; + case 'year': + return '––––'; + default: + return '––'; + } + } + + /** + * 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 + */ + private getAmPmModifier(hour: number): typeof AM | typeof PM { + return this.isPM(hour) ? PM : AM; + } + + /** + * Checks whether the segment being created or updated will have a value or not by checking the following order: + * + * 1. Did the segment already have a previously defined value? If yes, use it + * + * 2. Since the segment doesn't have a previous value to keep, was the component given a specific date/time when it + * was created? If yes, then use that information + * + * 3. There is no value to use at this point, so it will remain as `undefined` + * + * @param previousValue - Previous segment value, if there is one + * @param currentValue - Current segment value + */ + private usePreviousOrCurrentValue( + previousValue: number | undefined, + currentValue: number + ): number | undefined { + return ( + previousValue ?? + (this.selectedDateTime && currentValue) ?? + undefined + ); + } + + /** + * Gets the current value of the segment according to the type + * + * @param type - Type of segment + */ + private getCurrentValue( + type: Intl.DateTimeFormatPartTypes + ): number | undefined { + let previousValue: number | undefined; + let currentValue: number; + + switch (type) { + case 'year': + case 'month': + case 'day': + case 'hour': + case 'minute': + case 'second': + previousValue = this.segment(type)?.value; + 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 + previousValue = + this.hourSegment?.value && + this.getAmPmModifier(this.hourSegment.value); + currentValue = this.getAmPmModifier(this.currentDateTime.hour); + break; + default: + return undefined; + } + + return this.usePreviousOrCurrentValue(previousValue, currentValue); + } + + /** + * Returns the minimum and maximum values for each segment that will be used, in addition to defining if there is a + * current value to be used. If segments are being recreated, we try to recover the value that was previously set + * for each segment, if possible + * + * @param type - Type of segment + */ + private getValueAndLimits( + type: Intl.DateTimeFormatPartTypes + ): SegmentValueAndLimits { + const value = this.getCurrentValue(type); + + switch (type) { + case 'year': + return { + minValue: 1, + maxValue: this.currentDateTime.calendar.getYearsInEra( + this.currentDateTime + ), + value, + }; + case 'month': + return { + minValue: getMinimumMonthInYear(this.currentDateTime), + maxValue: this.currentDateTime.calendar.getMonthsInYear( + this.currentDateTime + ), + value, + }; + case 'day': + return { + minValue: getMinimumDayInMonth(this.currentDateTime), + maxValue: this.currentDateTime.calendar.getDaysInMonth( + this.currentDateTime + ), + value, + }; + case 'hour': + let min = 0; + let max = 23; + + if (this.is12HourClock) { + const isPM = this.isPM( + this.newDateTime?.hour ?? this.currentDateTime.hour + ); + + min = isPM ? minHourPM : minHourAM; + max = isPM ? maxHourPM : maxHourAM; + } + + return { + minValue: min, + maxValue: max, + value, + }; + case 'minute': + case 'second': + return { + minValue: 0, + maxValue: 59, + value, + }; + case 'dayPeriod': + return { + minValue: AM, + maxValue: PM, + value, + }; + default: + return {}; + } + } + + /** + * Increments the segment value respecting the minimum and maximum limits + * + * @param segment - The segment being changed + */ + 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); + } + + /** + * Decrements the segment value respecting the minimum and maximum limits + * + * @param segment - The segment being changed + */ + private decrementValue(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 : max; + } else if (segment.type === 'dayPeriod') { + segment.value = this.toggleDayPeriod(segment.value); + } else { + segment.value -= 1; + + if (segment.value < min) { + segment.value = max; + } + } + + 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; + } + + /** + * When the day period is changed, it automatically adjusts the hour if it has already been informed previously to + * match the new period (AM or PM). In addition, the minimum and maximum values of the hour are also changed + */ + private updateHour(): void { + if (!this.hourSegment || !this.amPmSegment) { + this.resetHourAndDayPeriod(); + return; + } + + if (this.amPmSegment.value === undefined) { + return; + } + + const isAM = this.amPmSegment.value === AM; + const isPM = this.amPmSegment.value === PM; + + this.hourSegment.minValue = isPM ? minHourPM : minHourAM; + this.hourSegment.maxValue = isPM ? maxHourPM : maxHourAM; + + if (this.hourSegment.value === undefined) { + return; + } + + if (isAM && this.isPM(this.hourSegment.value)) { + this.hourSegment.value -= PM; + } else if (isPM && !this.isPM(this.hourSegment.value)) { + this.hourSegment.value += PM; + } + } + + /** + * 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 + */ + private resetHourAndDayPeriod(): void { + if (this.amPmSegment) { + const dayPeriod = this.getValueAndLimits('dayPeriod'); + + this.amPmSegment.value = dayPeriod.value; + this.amPmSegment.minValue = dayPeriod.minValue; + this.amPmSegment.maxValue = dayPeriod.maxValue; + + if (this.amPmSegment.value === undefined) { + this.amPmSegment.formatted = this.amPmSegment.placeholder; + } + } + + if (this.hourSegment) { + const hour = this.getValueAndLimits('hour'); + + this.hourSegment.minValue = hour.minValue; + this.hourSegment.maxValue = hour.maxValue; + + if (isNumber(this.hourSegment.value)) { + this.hourSegment.value += this.getAmPmModifier( + this.currentDateTime.hour + ); + } else { + this.hourSegment.value = hour.value; + } + } + } + + /** + * Updates the value of the day segment to match the last day of the month if it is above the maximum limit and + * furthermore also updates the maximum limit of the day segment to use the new found limit + */ + private updateDay(): void { + if ( + this.monthSegment?.value === undefined || + this.daySegment === undefined + ) { + return; + } + + const useThisDate = isNumber(this.yearSegment?.value) + ? this.currentDateTime.set({ year: this.yearSegment?.value }) + : this.currentDateTime.copy(); + + const lastDayOfMonth = endOfMonth( + useThisDate.set({ month: this.monthSegment.value }) + ); + + this.daySegment.maxValue = lastDayOfMonth.day; + + if ( + isNumber(this.daySegment.value) && + this.daySegment.value > this.daySegment.maxValue + ) { + this.daySegment.value = this.daySegment.maxValue; + this.formatValue(this.daySegment); + } + } + + /** + * 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 + * + * @param typedValue - The value typed by the user + * @param min - The minimum value allowed for that segment + */ + private useTypedValueOrMin(typedValue: number, min: number): number { + return typedValue < min ? min : typedValue; + } + + /** + * Returns the value to be used if the typed value is greater than the maximum allowed + * + * @param typedValue - The value typed by the user + * @param max - The maximum value allowed for that segment + */ + private useTypedValueOrMax(typedValue: number, max: number): number { + return typedValue > max ? max : typedValue; + } + + /** + * When the user types a value into a segment, we need to temporarily store the current segment's value before it + * changes to include the value the user has just entered. Also, we need to identify if it is the hour segment and + * if the clock format is 12 hours, if so, we have to adjust the previous value to perform some calculations + * + * @param details - Details of the segment being changed + * @param typedValue - The value typed by the user + * @param isAmPmHour - Indicates whether it is the hour segment for 12-hour clocks + */ + private mergePreviousValueWithTypedValue( + details: SegmentDetails, + typedValue: number, + isAmPmHour = false + ): number { + let previousValue = details.value ?? 0; + + if (isAmPmHour && this.isPM(previousValue)) { + previousValue -= PM; + } + + let newValue = this.numberParser.parse(`${previousValue}${typedValue}`); + + if (String(newValue).length > String(details.maxValue).length) { + newValue = isAmPmHour + ? typedValue + : this.numberParser.parse(String(newValue).slice(1)); + } + + return newValue; + } + + /** + * 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 + * + * @param details - Segment value and limits + * @param typedValue - The value typed by the user + */ + private getNewValueForOtherSegments( + details: SegmentDetails, + typedValue: number + ): number { + let newValue = this.mergePreviousValueWithTypedValue( + details, + typedValue + ); + + 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; + } + + /** + * Focuses on the next editable segment, if any + * + * @param event - Event details + */ + private focusNextSegment(event: KeyboardEvent): void { + this.focusSegment(event.target as HTMLDivElement, 'next'); + } + + /** + * Focuses on the previous editable segment, if any + * + * @param event - Event details + */ + private focusPreviousSegment(event: KeyboardEvent): void { + this.focusSegment(event.target as HTMLDivElement, 'previous'); + } + + /** + * Focuses the segment according to the direction, if there is one to focus on + * + * @param segment - Segment that is currently focused + * @param elementToFocus - Defines which element will be focused: is it the previous one or the next one? + */ + private focusSegment( + segment: HTMLDivElement, + elementToFocus: 'previous' | 'next' + ): void { + let segmentFound = false; + let currentSegment = segment; + + while (!segmentFound) { + const siblingSegment = ( + elementToFocus === 'previous' + ? currentSegment.previousElementSibling + : currentSegment.nextElementSibling + ) as HTMLDivElement; + + // No more segments to focus on + if (!siblingSegment) { + break; + } + + if (siblingSegment.getAttribute('contenteditable')) { + siblingSegment.focus(); + segmentFound = true; + } + + currentSegment = siblingSegment; + } + } +} diff --git a/tools/input-segments/src/index.ts b/tools/input-segments/src/index.ts new file mode 100644 index 00000000000..e75d429f1a3 --- /dev/null +++ b/tools/input-segments/src/index.ts @@ -0,0 +1,12 @@ +/* +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. +*/ +export * from './InputSegments.js'; diff --git a/tools/input-segments/src/input-segments.css b/tools/input-segments/src/input-segments.css new file mode 100644 index 00000000000..4cf53ceca1d --- /dev/null +++ b/tools/input-segments/src/input-segments.css @@ -0,0 +1,85 @@ +/* +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. +*/ + +.input-content { + display: flex; + align-items: center; + height: 100%; + overflow-x: auto; + scrollbar-width: none; /* Firefox */ +} + +.input-content::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.literal-segment, +.editable-segment { + display: inline-block; + height: 100%; + color: var(--spectrum-textfield-text-color-default); +} + +.editable-segment { + padding: 0 0.15em; + font-variant-numeric: tabular-nums; + text-align: end; + white-space: nowrap; + caret-color: transparent; + 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; +} + +.editable-segment.is-placeholder, +.editable-segment.is-placeholder + .literal-segment { + color: var(--spectrum-gray-500); +} + +.editable-segment.is-placeholder .placeholder { + height: auto; + visibility: visible; +} + +.editable-segment:focus, +.editable-segment:focus .placeholder { + color: var(--spectrum-white); + background-color: var(--spectrum-accent-background-color-default); +} + +/** + * 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 { + background-color: transparent; +} diff --git a/tools/input-segments/src/types.ts b/tools/input-segments/src/types.ts new file mode 100644 index 00000000000..0567b241f2b --- /dev/null +++ b/tools/input-segments/src/types.ts @@ -0,0 +1,86 @@ +/* +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. +*/ + +export const dateSegmentTypes: Intl.DateTimeFormatPartTypes[] = [ + 'day', + 'month', + 'year', + 'literal', +]; + +export const timeSegmentTypes: Intl.DateTimeFormatPartTypes[] = [ + 'hour', + 'minute', + 'second', + 'dayPeriod', + 'literal', +]; + +type EditableSegmentTypes = Pick< + Intl.DateTimeFormatPartTypesRegistry, + 'day' | 'month' | 'year' | 'hour' | 'minute' | 'second' | 'dayPeriod' +>; + +export type EditableSegmentType = keyof EditableSegmentTypes; + +export interface Segment extends Omit { + /** A placeholder string for the segment */ + placeholder?: string; + + /** The formatted text for the segment */ + formatted?: string; + + /** The numeric value for the segment, if applicable */ + value?: number; + + /** The minimum numeric value for the segment, if applicable */ + minValue?: number; + + /** The maximum numeric value for the segment, if applicable */ + maxValue?: number; +} + +/** + * Value and limits of a segment. They are all optional, as literal segments have none of these properties + */ +export type SegmentValueAndLimits = Pick< + Segment, + 'value' | 'minValue' | 'maxValue' +>; + +/** + * Value and limits of a segment, however the limits are mandatory, as it is known that they have already been defined + */ +export interface SegmentDetails + extends Omit { + minValue: number; + maxValue: number; +} + +export type TimeGranularity = 'hour' | 'minute' | 'second'; + +/** AM modifier: `0` hours */ +export const AM = 0; + +/** PM modifier: `12` hours */ +export const PM = 12; + +/** Minimum hour value if AM */ +export const minHourAM = AM; + +/** Maximum hour value if AM */ +export const maxHourAM = 11; + +/** Minimum hour value if PM */ +export const minHourPM = PM; + +/** Maximum hour value if PM */ +export const maxHourPM = 23; diff --git a/tools/input-segments/tsconfig.json b/tools/input-segments/tsconfig.json new file mode 100644 index 00000000000..8da83568428 --- /dev/null +++ b/tools/input-segments/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "./" + }, + "include": ["*.ts", "src/*.ts"], + "exclude": ["test/*.ts", "stories/*.ts"], + "references": [ + { "path": "../base" }, + { "path": "../reactive-controllers" }, + { "path": "../../packages/textfield" } + ] +} diff --git a/tsconfig-all.json b/tsconfig-all.json index a3ad7b0c079..95574583a42 100644 --- a/tsconfig-all.json +++ b/tsconfig-all.json @@ -76,6 +76,7 @@ { "path": "packages/tags" }, { "path": "packages/textfield" }, { "path": "packages/thumbnail" }, + { "path": "packages/time-field" }, { "path": "packages/toast" }, { "path": "packages/tooltip" }, { "path": "packages/top-nav" }, @@ -84,6 +85,7 @@ { "path": "tools/base" }, { "path": "tools/bundle" }, { "path": "tools/grid" }, + { "path": "tools/input-segments" }, { "path": "tools/reactive-controllers" }, { "path": "tools/shared" }, { "path": "tools/styles" }, diff --git a/yarn.lock b/yarn.lock index fceae58a961..c9b12dc100f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2499,10 +2499,10 @@ resolved "https://registry.yarnpkg.com/@import-maps/resolve/-/resolve-1.0.1.tgz#1e9fcadcf23aa0822256a329aabca241879d37c9" integrity sha512-tWZNBIS1CoekcwlMuyG2mr0a1Wo5lb5lEHwwWvZo+5GLgr3e9LLDTtmgtCWEwBpXMkxn9D+2W9j2FY6eZQq0tA== -"@internationalized/date@^3.2.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.4.0.tgz#e843ac40b04afafe99fe0a41bae7abdd221a9a44" - integrity sha512-QUDSGCsvrEVITVf+kv9VSAraAmCgjQmU5CiXtesUBBhBe374NmnEIIaOFBZ72t29dfGMBP0zF+v6toVnbcc6jg== +"@internationalized/date@^3.2.1": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.3.0.tgz#90386d4b4e707f28507d1a1b3cc0e162ad5ee038" + integrity sha512-qfRd7jCIgUjabI8RxeAsxhLDRS1u8eUPX96GB5uBp1Tpm6YY6dVveE7YwsTEV6L4QOp5LKFirFHHGsL/XQwJIA== dependencies: "@swc/helpers" "^0.5.0" @@ -5368,10 +5368,10 @@ resolved "https://registry.yarnpkg.com/@spectrum-css/buttongroup/-/buttongroup-6.0.61.tgz#7c62d511905a0b544b5cc02ab506fddd071a7b57" integrity sha512-N5d0REziOHWL4pBHFaWzxdnJgBjCSAy4IVkJCIaoQXXqBCSisjfzgepUSiPbS8yekn78InUwKNUyvS866R0MqQ== -"@spectrum-css/calendar@^3.2.6": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@spectrum-css/calendar/-/calendar-3.2.7.tgz#10fd44176b6afbdf5baf29ce16728baa98b0d844" - integrity sha512-e2BGyuXzP+VOv0q855EIgrR+ne7e/EP8AMMuSAWazgq2fPZ4CoJIeLYP3tnniKnj2dlb3Gr1LH+6MPlUXS74RA== +"@spectrum-css/calendar@^4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@spectrum-css/calendar/-/calendar-4.0.3.tgz#3519942dd92d2c427f782bd1d174c42ec761185e" + integrity sha512-nwBNY8HxuC0ukFV9Wptdta3T06MOXgjSBKI46TCTgPdM9K4zf2OybgUV8bpV/dACeM7GUJmN1YeNOPpaLbYK+A== "@spectrum-css/card@^6.0.9": version "6.0.9"