From 7620096a5dfbf008e95c003218a4ca124f4ac96d Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 16 Feb 2024 11:02:33 +0200 Subject: [PATCH 01/63] feat: Date picker PoC --- .../common/definitions/defineAllComponents.ts | 2 + .../date-picker/date-picker.spec.ts | 35 +++ src/components/date-picker/date-picker.ts | 239 ++++++++++++++++++ .../date-time-input/date-time-input.ts | 7 +- src/index.ts | 1 + stories/datepicker.stories.ts | 237 +++++++++++++++++ 6 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 src/components/date-picker/date-picker.spec.ts create mode 100644 src/components/date-picker/date-picker.ts create mode 100644 stories/datepicker.stories.ts diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 90f97c8ea..3b5770b26 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -17,6 +17,7 @@ import IgcCheckboxComponent from '../../checkbox/checkbox.js'; import IgcSwitchComponent from '../../checkbox/switch.js'; import IgcChipComponent from '../../chip/chip.js'; import IgcComboComponent from '../../combo/combo.js'; +import IgcDatepickerComponent from '../../date-picker/date-picker.js'; import IgcDateTimeInputComponent from '../../date-time-input/date-time-input.js'; import IgcDialogComponent from '../../dialog/dialog.js'; import IgcDropdownGroupComponent from '../../dropdown/dropdown-group.js'; @@ -78,6 +79,7 @@ const allComponents: IgniteComponent[] = [ IgcCheckboxComponent, IgcChipComponent, IgcComboComponent, + IgcDatepickerComponent, IgcDropdownComponent, IgcDropdownGroupComponent, IgcDropdownHeaderComponent, diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts new file mode 100644 index 000000000..4516dbbf2 --- /dev/null +++ b/src/components/date-picker/date-picker.spec.ts @@ -0,0 +1,35 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; + +import IgcDatepickerComponent from './date-picker.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; + +describe('Date picker', () => { + before(() => defineComponents(IgcDatepickerComponent)); + + let picker: IgcDatepickerComponent; + + describe('Default', () => { + beforeEach(async () => { + picker = await fixture( + html`` + ); + }); + + it('is defined', async () => { + expect(picker).is.not.undefined; + }); + + it('is accessible (closed state)', async () => { + await expect(picker).shadowDom.to.be.accessible(); + await expect(picker).lightDom.to.be.accessible(); + }); + + it('is accessible (open state)', async () => { + picker.open = true; + await elementUpdated(picker); + + await expect(picker).shadowDom.to.be.accessible(); + await expect(picker).lightDom.to.be.accessible(); + }); + }); +}); diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts new file mode 100644 index 000000000..19b3f258c --- /dev/null +++ b/src/components/date-picker/date-picker.ts @@ -0,0 +1,239 @@ +import { LitElement, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import IgcCalendarComponent from '../calendar/calendar.js'; +import { + addKeybindings, + escapeKey, +} from '../common/controllers/key-bindings.js'; +import { addRootClickHandler } from '../common/controllers/root-click.js'; +import { watch } from '../common/decorators/watch.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; +import { createCounter } from '../common/util.js'; +import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; +import IgcPopoverComponent from '../popover/popover.js'; + +export interface IgcDatepickerEventMap { + igcOpening: CustomEvent; + igcOpened: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; + igcChange: CustomEvent; + igcInput: CustomEvent; +} + +/** + * @element igc-datepicker + * + * @slot prefix - Renders content before the input. + * @slot suffix - Renders content after the input. + * @slot helper-text - Renders content below the input. + * @slot title - Renders content in the calendar title. + * + * @fires igcOpening - Emitted just before the calendar dropdown is shown. + * @fires igcOpened - Emitted after the calendar dropdown is shown. + * @fires igcClosing - Emitted just before the calendar dropdown is hidden. + * @fires igcClosed - Emitted after the calendar dropdown is hidden. + * @fires igcChange - Emitted when the user modifies and commits the elements's value. + * @fires igcInput - Emitted when when the user types in the element. + */ +export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( + EventEmitterMixin< + IgcDatepickerEventMap, + Constructor + >(IgcBaseComboBoxLikeComponent) +) { + public static readonly tagName = 'igc-datepicker'; + + protected static shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + private static readonly increment = createCounter(); + protected inputId = `datepicker-${IgcDatepickerComponent.increment()}`; + + public static register() { + registerComponent( + this, + IgcCalendarComponent, + IgcDateTimeInputComponent, + IgcPopoverComponent + ); + } + + private _rootClickController = addRootClickHandler(this, { + hideCallback: () => this._hide(true), + }); + + @query(IgcDateTimeInputComponent.tagName, true) + private _input!: IgcDateTimeInputComponent; + + /** + * Whether the calendar dropdown should be kept open on clicking outside of it. + * @attr keep-open-on-outside-click + */ + @property({ + type: Boolean, + reflect: true, + attribute: 'keep-open-on-outside-click', + }) + public override keepOpenOnOutsideClick = false; + + /** + * Sets the state of the datepicker dropdown. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public override open = false; + + /** + * The label of the datepicker. + * @attr label + */ + @property() + public label!: string; + + /** + * Makes the control a readonly field. + * @attr readonly + */ + @property({ type: Boolean, reflect: true, attribute: 'readonly' }) + public readOnly = false; + + @property({ + converter: { + fromAttribute: (value: string) => (value ? new Date(value) : undefined), + toAttribute: (value: Date) => value.toISOString(), + }, + }) + public value!: Date; + + /** + * Controls the visibility of the dates that do not belong to the current month. + * @attr hide-outside-days + */ + @property({ type: Boolean, reflect: true, attribute: 'hide-outside-days' }) + public hideOutsideDays = false; + + /** + * The number of months displayed in the calendar. + * @attr visible-months + */ + @property({ type: Number, attribute: 'visible-months' }) + public visibleMonths = 1; + + /** + * Whether to show the number of the week in the calendar. + * @attr show-week-numbers + */ + @property({ type: Boolean, reflect: true, attribute: 'show-week-numbers' }) + public showWeekNumbers = false; + + @watch('open') + protected openChange() { + this._rootClickController.update(); + } + + constructor() { + super(); + + addKeybindings(this, { + skip: () => this.disabled, + bindingDefaults: { preventDefault: true }, + }).set(escapeKey, this.onEscapeKey); + } + + protected async onEscapeKey() { + if (await this._hide(true)) { + this._input.focus(); + } + } + + private async _shouldCloseCalendarDropdown() { + if (!this.keepOpenOnSelect && (await this._hide(true))) { + this._input.focus(); + this._input.select(); + } + } + + protected handleInputChangeEvent(event: CustomEvent) { + event.stopPropagation(); + this.value = (event.target as IgcDateTimeInputComponent).value!; + this.emitEvent('igcChange', { detail: this.value }); + } + + protected handleCalendarChangeEvent(event: CustomEvent) { + event.stopPropagation(); + + this.value = (event.target as IgcCalendarComponent).value!; + this.emitEvent('igcChange', { detail: this.value }); + + this._shouldCloseCalendarDropdown(); + } + + protected handleInputEvent(event: CustomEvent) { + event.stopPropagation(); + + this.value = (event.target as IgcDateTimeInputComponent).value!; + this.emitEvent('igcInput', { detail: this.value }); + } + + protected override render() { + const id = this.id || this.inputId; + + return html` + + + 📅 + + + + + + + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-datepicker': IgcDatepickerComponent; + } +} diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index dff9afc34..e9d53883b 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -349,7 +349,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< skip: () => this.readOnly, bindingDefaults: { preventDefault: true }, }) - .set([ctrlKey, ';'], () => (this.value = new Date())) + .set([ctrlKey, ';'], this.setToday) .set(arrowUp, this.keyboardSpin.bind(this, 'up')) .set(arrowDown, this.keyboardSpin.bind(this, 'down')) .set([ctrlKey, arrowLeft], this.navigateParts.bind(this, 0)) @@ -400,6 +400,11 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< this.value = null; } + protected setToday() { + this.value = new Date(); + this.handleInput(); + } + protected updateMask() { if (this.focused) { this.maskedValue = this.getMaskedValue(); diff --git a/src/index.ts b/src/index.ts index b63fdffa6..46d058c0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { default as IgcCircularProgressComponent } from './components/progress/c export { default as IgcCircularGradientComponent } from './components/progress/circular-gradient.js'; export { default as IgcChipComponent } from './components/chip/chip.js'; export { default as IgcComboComponent } from './components/combo/combo.js'; +export { default as IgcDatepickerComponent } from './components/date-picker/date-picker.js'; export { default as IgcDateTimeInputComponent } from './components/date-time-input/date-time-input.js'; export { default as IgcDialogComponent } from './components/dialog/dialog.js'; export { default as IgcDropdownComponent } from './components/dropdown/dropdown.js'; diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts new file mode 100644 index 000000000..9471dcf03 --- /dev/null +++ b/stories/datepicker.stories.ts @@ -0,0 +1,237 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; + +import { + disableStoryControls, + formControls, + formSubmitHandler, +} from './story.js'; +import { IgcDatepickerComponent, defineComponents } from '../src/index.js'; + +defineComponents(IgcDatepickerComponent); + +// region default +const metadata: Meta = { + title: 'Datepicker', + component: 'igc-datepicker', + parameters: { + docs: { description: { component: '' } }, + actions: { + handles: [ + 'igcOpening', + 'igcOpened', + 'igcClosing', + 'igcClosed', + 'igcChange', + 'igcInput', + ], + }, + }, + argTypes: { + keepOpenOnOutsideClick: { + type: 'boolean', + description: + 'Whether the calendar dropdown should be kept open on clicking outside of it.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + open: { + type: 'boolean', + description: 'Sets the state of the datepicker dropdown.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + label: { + type: 'string', + description: 'The label of the datepicker.', + control: 'text', + }, + readOnly: { + type: 'boolean', + description: 'Makes the control a readonly field.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + value: { type: 'Date', control: 'date' }, + hideOutsideDays: { + type: 'boolean', + description: + 'Controls the visibility of the dates that do not belong to the current month.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + visibleMonths: { + type: 'number', + description: 'The number of months displayed in the calendar.', + control: 'number', + table: { defaultValue: { summary: 1 } }, + }, + showWeekNumbers: { + type: 'boolean', + description: 'Whether to show the number of the week in the calendar.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + required: { + type: 'boolean', + description: 'Makes the control a required field in a form context.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + name: { + type: 'string', + description: 'The name attribute of the control.', + control: 'text', + }, + disabled: { + type: 'boolean', + description: 'The disabled state of the component', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + invalid: { + type: 'boolean', + description: 'Control the validity of the control.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + keepOpenOnSelect: { + type: 'boolean', + description: + 'Whether the component dropdown should be kept open on selection.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + }, + args: { + keepOpenOnOutsideClick: false, + open: false, + readOnly: false, + hideOutsideDays: false, + visibleMonths: 1, + showWeekNumbers: false, + required: false, + disabled: false, + invalid: false, + keepOpenOnSelect: false, + }, +}; + +export default metadata; + +interface IgcDatepickerArgs { + /** Whether the calendar dropdown should be kept open on clicking outside of it. */ + keepOpenOnOutsideClick: boolean; + /** Sets the state of the datepicker dropdown. */ + open: boolean; + /** The label of the datepicker. */ + label: string; + /** Makes the control a readonly field. */ + readOnly: boolean; + value: Date; + /** Controls the visibility of the dates that do not belong to the current month. */ + hideOutsideDays: boolean; + /** The number of months displayed in the calendar. */ + visibleMonths: number; + /** Whether to show the number of the week in the calendar. */ + showWeekNumbers: boolean; + /** Makes the control a required field in a form context. */ + required: boolean; + /** The name attribute of the control. */ + name: string; + /** The disabled state of the component */ + disabled: boolean; + /** Control the validity of the control. */ + invalid: boolean; + /** Whether the component dropdown should be kept open on selection. */ + keepOpenOnSelect: boolean; +} +type Story = StoryObj; + +// endregion + +export const Default: Story = { + args: { + label: 'Pick a date', + value: new Date(), + }, + render: (args) => html` + + + `, +}; + +export const Slots: Story = { + args: { + label: 'Pick a date', + }, + render: (args) => html` + + $ + 🦀 +

For example, select your birthday

+

🎉 Custom title 🎉

+
+ `, +}; + +export const Form: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + + +
+ +
+ +
+ +
+ +
+ ${formControls()} +
+ `, +}; From 03f9caf31a604ff7450a9de4e685f76a205053cb Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 26 Feb 2024 15:47:43 +0200 Subject: [PATCH 02/63] wip: Added simple focus trap for datepicker --- src/components/common/util.ts | 67 +++++++++++++++++ src/components/date-picker/date-picker.ts | 31 ++++---- src/components/focus-trap/focus-trap.ts | 90 +++++++++++++++++++++++ 3 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 src/components/focus-trap/focus-trap.ts diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 2c9018273..2351170c5 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -142,6 +142,36 @@ export function* iterNodes( } } +export function* iterNodesShadow( + root: Node | ShadowRoot, + whatToShow?: keyof typeof NodeFilter, + filter?: (node: T) => boolean +): Generator { + const iter = document.createTreeWalker( + root, + NodeFilter[whatToShow ?? 'SHOW_ALL'] + ); + let node = iter.nextNode() as T; + + // XXX: What about slotted content hmm? + + while (node) { + if (isElement(node) && node.shadowRoot) { + yield* iterNodesShadow(node.shadowRoot, whatToShow, filter); + } else { + if (filter) { + if (filter(node)) { + yield node; + } + } else { + yield node; + } + } + + node = iter.nextNode() as T; + } +} + export function getElementByIdFromRoot(root: HTMLElement, id: string) { return (root.getRootNode() as Document | ShadowRoot).getElementById(id); } @@ -163,3 +193,40 @@ export function groupBy(array: T[], key: keyof T | ((item: T) => any)) { return result; } + +const _baseSelectors = [ + '[tabindex]', + 'a[href]', + 'button', + 'input', + 'select', + 'textarea', +]; + +function isHidden(node: HTMLElement) { + return ( + node.hasAttribute('hidden') || + node.hasAttribute('inert') || + (node.hasAttribute('aria-hidden') && + node.getAttribute('aria-hidden') !== 'false') + ); +} + +function isDisabled(node: HTMLElement) { + return node.hasAttribute('disabled') || node.hasAttribute('inert'); +} + +/** + * Whether the passed in `node` is a focusable element. + */ +export function isFocusable(node: HTMLElement) { + if ( + node.getAttribute('tabindex') === '-1' || + isHidden(node) || + isDisabled(node) + ) { + return false; + } + + return _baseSelectors.some((qs) => node.matches(qs)); +} diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 19b3f258c..01c1d9b59 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -16,6 +16,7 @@ import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; import { createCounter } from '../common/util.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; +import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcPopoverComponent from '../popover/popover.js'; export interface IgcDatepickerEventMap { @@ -63,6 +64,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this, IgcCalendarComponent, IgcDateTimeInputComponent, + IgcFocusTrapComponent, IgcPopoverComponent ); } @@ -186,6 +188,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( protected override render() { const id = this.id || this.inputId; + const calendarDisabled = !this.open || this.disabled; return html` - - - + + + + + `; } diff --git a/src/components/focus-trap/focus-trap.ts b/src/components/focus-trap/focus-trap.ts new file mode 100644 index 000000000..71cb1358c --- /dev/null +++ b/src/components/focus-trap/focus-trap.ts @@ -0,0 +1,90 @@ +import { LitElement, css, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import { registerComponent } from '../common/definitions/register.js'; +import { isFocusable, iterNodesShadow } from '../common/util.js'; + +/** + * + * @element igc-focus-trap + * + * @slot - The content of the focus trap component + */ +export default class IgcFocusTrapComponent extends LitElement { + public static readonly tagName = 'igc-focus-trap'; + public static override styles = css` + :host { + display: contents; + } + `; + + /* blazorSuppress */ + public static register() { + registerComponent(this); + } + + @state() + protected _focused = false; + + /** + * Whether to manage focus state for the slotted children. + * @attr disabled + */ + @property({ type: Boolean, reflect: true }) + public disabled = false; + + /** + * Whether focus in currently inside the trap component. + */ + public get focused() { + return this._focused; + } + + /** An array of focusable elements including elements in Shadow roots */ + public get focusableElements() { + return Array.from( + iterNodesShadow(this, 'SHOW_ELEMENT', (node) => { + return isFocusable(node); + }) + ); + } + + constructor() { + super(); + + this.addEventListener('focusin', () => (this._focused = true)); + this.addEventListener('focusout', () => (this._focused = false)); + } + + protected focusFirstElement() { + this.focusableElements.at(0)?.focus(); + } + + protected focusLastElement() { + this.focusableElements.at(-1)?.focus(); + } + + protected override render() { + const tabStop = !this.focused || this.disabled ? -1 : 0; + + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-focus-trap': IgcFocusTrapComponent; + } +} From 7822222940056547c4e5016f961e9468dfd1003c Mon Sep 17 00:00:00 2001 From: ddaribo Date: Mon, 19 Feb 2024 17:15:40 +0200 Subject: [PATCH 03/63] feat(date-picker): add localization, display/input format props --- .../date-picker/date-picker.spec.ts | 73 ++++++++++++ src/components/date-picker/date-picker.ts | 59 ++++++++++ stories/datepicker.stories.ts | 104 ++++++++++++------ 3 files changed, 203 insertions(+), 33 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 4516dbbf2..9ed7c864f 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -2,17 +2,22 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import IgcDatepickerComponent from './date-picker.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; +import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; describe('Date picker', () => { before(() => defineComponents(IgcDatepickerComponent)); let picker: IgcDatepickerComponent; + let dateTimeInput: IgcDateTimeInputComponent; describe('Default', () => { beforeEach(async () => { picker = await fixture( html`` ); + dateTimeInput = picker.shadowRoot!.querySelector( + IgcDateTimeInputComponent.tagName + ) as IgcDateTimeInputComponent; }); it('is defined', async () => { @@ -31,5 +36,73 @@ describe('Date picker', () => { await expect(picker).shadowDom.to.be.accessible(); await expect(picker).lightDom.to.be.accessible(); }); + + describe('Localization', () => { + it('should set inputFormat correctly', async () => { + const testFormat = 'dd--MM--yyyy'; + picker.inputFormat = testFormat; + await elementUpdated(picker); + + expect(dateTimeInput.inputFormat).to.equal(testFormat); + }); + + it('should set displayFormat correctly', async () => { + let testFormat = 'dd-MM-yyyy'; + picker.displayFormat = testFormat; + await elementUpdated(picker); + + expect(dateTimeInput.displayFormat).to.equal(testFormat); + + // set via attribute + testFormat = 'dd--MM--yyyy'; + picker.setAttribute('display-format', testFormat); + await elementUpdated(picker); + + expect(dateTimeInput.displayFormat).to.equal(testFormat); + expect(picker.displayFormat).not.to.equal(picker.inputFormat); + }); + + it('should properly set displayFormat to the set of predefined formats', async () => { + const predefinedFormats = ['short', 'medium', 'long', 'full']; + + for (let i = 0; i < predefinedFormats.length; i++) { + const format = predefinedFormats[i]; + picker.displayFormat = format; + await elementUpdated(picker); + + expect(dateTimeInput.displayFormat).to.equal(format + 'Date'); + } + }); + + it('should default inputFormat to whatever Intl.DateTimeFormat returns for the current locale', async () => { + const defaultFormat = 'MM/dd/yyyy'; + expect(picker.locale).to.equal('en'); + expect(picker.inputFormat).to.equal(defaultFormat); + + picker.locale = 'fr'; + await elementUpdated(picker); + + expect(picker.inputFormat).to.equal('dd/MM/yyyy'); + }); + + it('should use the value of inputFormat for displayFormat, if it is not defined', async () => { + expect(picker.locale).to.equal('en'); + expect(picker.getAttribute('display-format')).to.be.null; + expect(picker.displayFormat).to.equal(picker.inputFormat); + + // updates inputFormat according to changed locale + picker.locale = 'fr'; + await elementUpdated(picker); + expect(picker.inputFormat).to.equal('dd/MM/yyyy'); + expect(picker.displayFormat).to.equal(picker.inputFormat); + + // sets inputFormat as attribute + picker.setAttribute('input-format', 'dd-MM-yyyy'); + await elementUpdated(picker); + + expect(picker.inputFormat).to.equal('dd-MM-yyyy'); + expect(picker.displayFormat).to.equal(picker.inputFormat); + }); + }); }); }); diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 01c1d9b59..eb0b39ee5 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -59,6 +59,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private static readonly increment = createCounter(); protected inputId = `datepicker-${IgcDatepickerComponent.increment()}`; + private predefinedDisplayFormatsMap = new Map([ + ['short', 'shortDate'], + ['medium', 'mediumDate'], + ['long', 'longDate'], + ['full', 'fullDate'], + ]); + public static register() { registerComponent( this, @@ -76,6 +83,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @query(IgcDateTimeInputComponent.tagName, true) private _input!: IgcDateTimeInputComponent; + private _displayFormat!: string; + /** * Whether the calendar dropdown should be kept open on clicking outside of it. * @attr keep-open-on-outside-click @@ -137,6 +146,54 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @property({ type: Boolean, reflect: true, attribute: 'show-week-numbers' }) public showWeekNumbers = false; + /** + * Format to display the value in when not editing. + * Defaults to the input format if not set. + * @attr display-format + */ + @property({ attribute: 'display-format' }) + public get displayFormat(): string { + return ( + this._displayFormat ?? this._input?.displayFormat ?? this.inputFormat + ); + } + + public set displayFormat(value: string) { + if (!value) { + return; + } + this._displayFormat = value; + if (this.predefinedDisplayFormatsMap.has(value)) { + value = this.predefinedDisplayFormatsMap.get(value)!; + } + if (this._input) { + this._input.displayFormat = value; + } + } + + /** + * The date format to apply on the input. + * Defaults to the current locale Intl.DateTimeFormat + * @attr input-format + */ + @property({ attribute: 'input-format' }) + public get inputFormat(): string { + return this._input?.inputFormat; + } + + public set inputFormat(value: string) { + if (value && this._input) { + this._input.inputFormat = value; + } + } + + /** + * The locale settings used to display the value. + * @attr + */ + @property() + public locale = 'en'; + @watch('open') protected openChange() { this._rootClickController.update(); @@ -200,6 +257,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( label=${ifDefined(this.label)} aria-expanded=${this.open ? 'true' : 'false'} .value=${this.value} + .locale=${this.locale} @igcChange=${this.handleInputChangeEvent} @igcInput=${this.handleInputEvent} > @@ -226,6 +284,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( ?hide-outside-days=${this.hideOutsideDays} .visibleMonths=${this.visibleMonths} .value=${this.value} + .locale=${this.locale} .activeDate=${this.value ?? nothing} @igcChange=${this.handleCalendarChangeEvent} > diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index 9471dcf03..a9c41a047 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -72,6 +72,24 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: false } }, }, + displayFormat: { + type: 'string', + description: + 'Format to display the value in when not editing.\nDefaults to the input format if not set.', + control: 'text', + }, + inputFormat: { + type: 'string', + description: + 'The date format to apply on the input.\nDefaults to the current locale Intl.DateTimeFormat', + control: 'text', + }, + locale: { + type: 'string', + description: 'The locale settings used to display the value.', + control: 'text', + table: { defaultValue: { summary: 'en' } }, + }, required: { type: 'boolean', description: 'Makes the control a required field in a form context.', @@ -110,6 +128,7 @@ const metadata: Meta = { hideOutsideDays: false, visibleMonths: 1, showWeekNumbers: false, + locale: 'en', required: false, disabled: false, invalid: false, @@ -135,6 +154,18 @@ interface IgcDatepickerArgs { visibleMonths: number; /** Whether to show the number of the week in the calendar. */ showWeekNumbers: boolean; + /** + * Format to display the value in when not editing. + * Defaults to the input format if not set. + */ + displayFormat: string; + /** + * The date format to apply on the input. + * Defaults to the current locale Intl.DateTimeFormat + */ + inputFormat: string; + /** The locale settings used to display the value. */ + locale: string; /** Makes the control a required field in a form context. */ required: boolean; /** The name attribute of the control. */ @@ -156,21 +187,26 @@ export const Default: Story = { value: new Date(), }, render: (args) => html` - - +
+ + +
`, }; @@ -179,24 +215,26 @@ export const Slots: Story = { label: 'Pick a date', }, render: (args) => html` - - $ - 🦀 -

For example, select your birthday

-

🎉 Custom title 🎉

-
+
+ + $ + 🦀 +

For example, select your birthday

+

🎉 Custom title 🎉

+
+
`, }; From bc86f5278a0176e32c8bad20907ec3fda482a8a9 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Wed, 21 Feb 2024 14:39:06 +0200 Subject: [PATCH 04/63] feat(date-picker): implement main properties, methods, tests --- .../date-picker/date-picker.spec.ts | 382 +++++++++++++++++- src/components/date-picker/date-picker.ts | 238 ++++++++++- stories/datepicker.stories.ts | 128 +++++- 3 files changed, 734 insertions(+), 14 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 9ed7c864f..a3d754522 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -1,6 +1,10 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { spy } from 'sinon'; import IgcDatepickerComponent from './date-picker.js'; +import IgcCalendarComponent from '../calendar/calendar.js'; +import { DateRangeType } from '../calendar/common/calendar.model.js'; +import IgcDaysViewComponent from '../calendar/days-view/days-view.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; @@ -9,8 +13,9 @@ describe('Date picker', () => { let picker: IgcDatepickerComponent; let dateTimeInput: IgcDateTimeInputComponent; + let calendar: IgcCalendarComponent; - describe('Default', () => { + describe('Rendering and initialization', () => { beforeEach(async () => { picker = await fixture( html`` @@ -18,6 +23,10 @@ describe('Date picker', () => { dateTimeInput = picker.shadowRoot!.querySelector( IgcDateTimeInputComponent.tagName ) as IgcDateTimeInputComponent; + + calendar = picker.shadowRoot!.querySelector( + IgcCalendarComponent.tagName + ) as IgcCalendarComponent; }); it('is defined', async () => { @@ -37,6 +46,315 @@ describe('Date picker', () => { await expect(picker).lightDom.to.be.accessible(); }); + describe('Basic', () => { + it('should set prompt char correctly', async () => { + picker.prompt = '*'; + await elementUpdated(picker); + + expect(dateTimeInput.prompt).to.equal('*'); + }); + + it('should not close calendar after selection when keepOpenOnSelect is true', async () => { + expect(picker.open).to.equal(false); + picker.keepOpenOnSelect = true; + await elementUpdated(picker); + + picker.show(); + await elementUpdated(picker); + + const currentDate = new Date(new Date().setHours(0, 0, 0)); + const eventSpy = spy(picker, 'emitEvent'); + + selectCurrentDate(calendar); + await elementUpdated(picker); + + expect(eventSpy).calledOnce; + + expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( + currentDate.toLocaleDateString('en-US') + ); + + expect((calendar.value as Date).toLocaleDateString('en-US')).to.equal( + currentDate.toLocaleDateString('en-US') + ); + + expect(picker.open).to.equal(true); + }); + + it('should not close calendar on clicking outside of it when keepOpenOnOutsideClick is true', async () => { + expect(picker.open).to.equal(false); + picker.keepOpenOnOutsideClick = true; + await elementUpdated(picker); + + picker.show(); + await elementUpdated(picker); + + document.body.click(); + await elementUpdated(picker); + + expect(picker.open).to.equal(true); + }); + + it('should set properties of the calendar correctly', async () => { + const props = { + weekStart: 'friday', + hideOutsideDays: true, + hideHeader: true, + showWeekNumbers: true, + visibleMonths: 3, + headerOrientation: 'vertical', + orientation: 'vertical', + disabledDates: [ + { + type: DateRangeType.Before, + dateRange: [new Date()], + }, + ], + specialDates: [ + { + type: DateRangeType.Weekends, + dateRange: [], + }, + ], + }; + + //test defaults + expect(picker.weekStart).to.equal('sunday'); + expect(picker.hideOutsideDays).to.equal(false); + expect(picker.hideHeader).to.equal(false); + expect(picker.showWeekNumbers).to.equal(false); + expect(picker.visibleMonths).to.equal(1); + expect(picker.headerOrientation).to.equal('horizontal'); + expect(picker.orientation).to.equal('horizontal'); + expect(calendar.disabledDates).to.be.undefined; + expect(calendar.specialDates).to.be.undefined; + + Object.assign(picker, props); + await elementUpdated(picker); + + for (const [prop, value] of Object.entries(props)) { + expect((calendar as any)[prop]).to.equal(value); + } + }); + + it('should set the mode property correctly', async () => { + // TODO + }); + + it('should set properties of the input correctly', async () => { + const props = { + required: true, + label: 'Sample Label', + disabled: true, + placeholder: 'Sample placeholder', + outlined: true, + }; + + Object.assign(picker, props); + await elementUpdated(picker); + + for (const [prop, value] of Object.entries(props)) { + expect((dateTimeInput as any)[prop]).to.equal(value); + } + }); + + describe('Active date', () => { + const currentDate = new Date(); + const currentDateString = currentDate.toLocaleDateString('en-US'); + + const tomorrowDate = new Date( + currentDate.setDate(currentDate.getDate() + 1) + ); + const tomorrowDateString = tomorrowDate.toLocaleDateString('en-US'); + + const after10DaysDate = new Date( + currentDate.setDate(currentDate.getDate() + 10) + ); + const after10DaysString = after10DaysDate.toLocaleDateString('en-US'); + + const after20DaysDate = new Date( + currentDate.setDate(currentDate.getDate() + 20) + ); + + it('should initialize activeDate with current date, when not set', async () => { + expect(picker.activeDate.toLocaleDateString('en-US')).to.equal( + currentDateString + ); + expect(picker.value).to.be.undefined; + expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( + currentDateString + ); + expect(calendar.value).to.be.undefined; + }); + + it('should initialize activeDate = value when it is not set, but value is', async () => { + const valueDate = after10DaysDate; + picker = await fixture( + html`` + ); + await elementUpdated(picker); + await elementUpdated(picker); + + expect(picker.value?.toLocaleDateString('en-US')).to.equal( + valueDate.toLocaleDateString('en-US') + ); + + calendar = picker.shadowRoot!.querySelector( + IgcCalendarComponent.tagName + ) as IgcCalendarComponent; + + expect(picker.activeDate.toLocaleDateString('en-US')).to.equal( + after10DaysString + ); + expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( + after10DaysString + ); + expect(calendar.value!.toLocaleDateString('en-US')).to.equal( + after10DaysString + ); + }); + + it('should set activeDate correctly', async () => { + picker.activeDate = tomorrowDate; + + await elementUpdated(picker); + expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( + tomorrowDateString + ); + // value is not defined + expect(picker.value).to.be.undefined; + + // setting the value does not affect the activeDate, when it is explicitly set + picker.value = after20DaysDate; + await elementUpdated(picker); + + expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( + tomorrowDateString + ); + }); + }); + }); + + describe('Methods', () => { + let input: HTMLInputElement; + + beforeEach(() => { + input = dateTimeInput.shadowRoot!.querySelector( + 'input' + ) as HTMLInputElement; + }); + + it('should open/close the picker on invoking show/hide/toggle and not emit events', async () => { + const eventSpy = spy(picker, 'emitEvent'); + + expect(picker.open).to.be.false; + picker.show(); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.open).to.be.true; + + picker.hide(); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.open).to.be.false; + + picker.toggle(); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.open).to.be.true; + + picker.toggle(); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.open).to.be.false; + }); + + it('should clear the input on invoking clear()', async () => { + picker.value = new Date(); + await elementUpdated(picker); + + expect(dateTimeInput.value).to.equal(picker.value); + picker.clear(); + await elementUpdated(picker); + + expect(picker.value).to.be.undefined; + expect(dateTimeInput.value).to.be.null; + }); + + it('should delegate stepUp and stepDown to the igc-date-time-input', async () => { + const stepUpSpy = spy(dateTimeInput, 'stepUp'); + const stepDownSpy = spy(dateTimeInput, 'stepDown'); + + picker.stepUp(); + await elementUpdated(picker); + + expect(stepUpSpy).called; + + picker.stepDown(); + await elementUpdated(picker); + + expect(stepDownSpy).called; + }); + + it('should select the text in the input with the select method', async () => { + const selectSpy = spy(dateTimeInput, 'select'); + picker.value = new Date(); + await elementUpdated(picker); + + dateTimeInput.focus(); + picker.select(); + await elementUpdated(picker); + + expect(selectSpy).to.be.called; + expect(input.selectionStart).to.eq(0); + expect(input.selectionEnd).to.eq(input.value.length); + }); + + it('should set the text selection range in the input with setSelectionRange()', async () => { + const selectionRangeSpy = spy(dateTimeInput, 'setSelectionRange'); + picker.value = new Date(); + await elementUpdated(picker); + + dateTimeInput.focus(); + picker.setSelectionRange(0, 2); + await elementUpdated(picker); + + expect(selectionRangeSpy).to.be.called; + expect(input.selectionStart).to.eq(0); + expect(input.selectionEnd).to.eq(2); + }); + + it('should replace the selected text in the input and re-apply the mask with setRangeText()', async () => { + // TODO - fix of src/components/date-picker/date-picker.spec.ts + /* + const setRangeTextSpy = spy(dateTimeInput, 'setRangeText'); + picker.value = new Date(2024, 2, 21); + const expectedValue = new Date(2023, 2, 21); + await elementUpdated(picker); + + dateTimeInput.focus(); + picker.setRangeText('2023', 6, 10); + await elementUpdated(picker); + + expect(setRangeTextSpy).to.be.called; + + expect(new Date(input.value).toISOString()).to.equal( + expectedValue.toISOString() + ); + expect(picker.value).to.eq(expectedValue); + expect(dateTimeInput.value).to.eq(expectedValue); + */ + }); + }); + + describe('Slotted content', () => { + // TODO + }); + describe('Localization', () => { it('should set inputFormat correctly', async () => { const testFormat = 'dd--MM--yyyy'; @@ -104,5 +422,67 @@ describe('Date picker', () => { expect(picker.displayFormat).to.equal(picker.inputFormat); }); }); + + describe('Validation', () => { + it('should set the min and max properties and update validity according to set value', async () => { + expect(picker.min).to.be.undefined; + expect(dateTimeInput.min).to.be.undefined; + expect(picker.max).to.be.undefined; + expect(dateTimeInput.max).to.be.undefined; + expect(picker.invalid).to.be.false; + + picker.value = new Date(2024, 2, 18); + await elementUpdated(picker); + + picker.min = new Date(2024, 2, 20); + await elementUpdated(picker); + + // only changing the min/max property does not set invalid state - updating the value does ? + expect(picker.invalid).to.be.false; + picker.value = new Date(2024, 2, 19); + await elementUpdated(picker); + + expect(picker.invalid).to.be.true; + + picker.value = new Date(2024, 2, 24); + await elementUpdated(picker); + + expect(picker.invalid).to.be.false; + + picker.max = new Date(2024, 2, 22); + await elementUpdated(picker); + + expect(picker.invalid).to.be.false; + + picker.value = new Date(2024, 2, 23); + await elementUpdated(picker); + + expect(picker.invalid).to.be.true; + + picker.value = new Date(2024, 2, 21); + await elementUpdated(picker); + + expect(picker.invalid).to.be.false; + // TODO check same on typing - untouched/dirty + }); + }); + + describe('Form integration', () => { + it('should set a custom validation message with setCustomValidity()', async () => { + // TODO + // As long as message is not empty, the component is considered invalid + }); + }); }); }); + +const selectCurrentDate = (calendar: IgcCalendarComponent) => { + const daysView = calendar.shadowRoot?.querySelector( + 'igc-days-view' + ) as IgcDaysViewComponent; + + const currentDaySpan = daysView.shadowRoot?.querySelector( + 'span[part~="current"]' + ) as HTMLElement; + (currentDaySpan?.children[0] as HTMLElement).click(); +}; diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index eb0b39ee5..f6c1bc944 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -1,8 +1,9 @@ -import { LitElement, html, nothing } from 'lit'; +import { ComplexAttributeConverter, LitElement, html } from 'lit'; import { property, query } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import IgcCalendarComponent from '../calendar/calendar.js'; +import { DateRangeDescriptor } from '../calendar/common/calendar.model.js'; import { addKeybindings, escapeKey, @@ -10,12 +11,19 @@ import { import { addRootClickHandler } from '../common/controllers/root-click.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; +import { + IgcCalendarResourceStringEN, + IgcCalendarResourceStrings, +} from '../common/i18n/calendar.resources.js'; +import messages from '../common/localization/validation-en.js'; import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; -import { createCounter } from '../common/util.js'; +import { createCounter, format } from '../common/util.js'; +import { Validator } from '../common/validators.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; +import { DatePart, DateTimeUtil } from '../date-time-input/date-util.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcPopoverComponent from '../popover/popover.js'; @@ -28,6 +36,11 @@ export interface IgcDatepickerEventMap { igcInput: CustomEvent; } +const converter: ComplexAttributeConverter = { + fromAttribute: (value: string) => (value ? new Date(value) : undefined), + toAttribute: (value: Date) => value.toISOString(), +}; + /** * @element igc-datepicker * @@ -59,6 +72,41 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private static readonly increment = createCounter(); protected inputId = `datepicker-${IgcDatepickerComponent.increment()}`; + // TODO - can the date-time-input's validator's be reused and the picker's validity state be updated ? + public override validators: Validator[] = [ + { + key: 'valueMissing', + message: messages.required, + isValid: () => (this.required ? !!this.value : true), + }, + { + key: 'rangeUnderflow', + message: () => format(messages.min, `${this.min}`), + isValid: () => + this.min + ? !DateTimeUtil.lessThanMinValue( + this.value || new Date(), + this.min, + false, + true + ) + : true, + }, + { + key: 'rangeOverflow', + message: () => format(messages.max, `${this.max}`), + isValid: () => + this.max + ? !DateTimeUtil.greaterThanMaxValue( + this.value || new Date(), + this.max, + false, + true + ) + : true, + }, + ]; + private predefinedDisplayFormatsMap = new Map([ ['short', 'shortDate'], ['medium', 'mediumDate'], @@ -83,6 +131,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @query(IgcDateTimeInputComponent.tagName, true) private _input!: IgcDateTimeInputComponent; + @query(IgcCalendarComponent.tagName, true) + private _calendar!: IgcCalendarComponent; + private _displayFormat!: string; /** @@ -110,6 +161,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @property() public label!: string; + /** + * Determines whether the calendar is opened as a dropdown or as a dialog + * @attr mode + */ + @property() + public mode: 'dropdown' | 'dialog' = 'dropdown'; + /** * Makes the control a readonly field. * @attr readonly @@ -117,13 +175,60 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @property({ type: Boolean, reflect: true, attribute: 'readonly' }) public readOnly = false; + /** + * The value of the picker + * @attr + */ @property({ - converter: { - fromAttribute: (value: string) => (value ? new Date(value) : undefined), - toAttribute: (value: Date) => value.toISOString(), - }, + converter: converter, }) - public value!: Date; + public value?: Date; + + @property({ + attribute: 'active-date', + converter: converter, + }) + public get activeDate(): Date { + return this._calendar?.activeDate ?? new Date(); + } + + public set activeDate(value: Date) { + if (this._calendar) { + this._calendar.activeDate = value ?? undefined; + } + } + + /** + * The minimum value required for the date picker to remain valid. + * @attr + */ + @property({ converter: converter }) + public min?: Date; + + /** + * The maximum value required for the date picker to remain valid. + * @attr + */ + @property({ converter: converter }) + public max?: Date; + + /** The orientation of the calendar header. + * @attr header-orientation + */ + @property({ attribute: 'header-orientation', reflect: true }) + public headerOrientation: 'vertical' | 'horizontal' = 'horizontal'; + + /** The orientation of the multiple months displayed in the calendar's days view. + * @attr + */ + @property() + public orientation: 'vertical' | 'horizontal' = 'horizontal'; + + /** Determines whether the calendar hides its header. + * @attr hide-header + */ + @property({ attribute: 'hide-header' }) + public hideHeader = false; /** * Controls the visibility of the dates that do not belong to the current month. @@ -132,6 +237,28 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @property({ type: Boolean, reflect: true, attribute: 'hide-outside-days' }) public hideOutsideDays = false; + /** Gets/sets disabled dates. */ + @property({ attribute: false }) + public disabledDates!: DateRangeDescriptor[]; + + /** Gets/sets special dates. */ + @property({ attribute: false }) + public specialDates!: DateRangeDescriptor[]; + + /** + * Whether the control will have outlined appearance. + * @attr + */ + @property({ reflect: true, type: Boolean }) + public outlined = false; + + /** + * The placeholder attribute of the control. + * @attr + */ + @property() + public placeholder!: string; + /** * The number of months displayed in the calendar. * @attr visible-months @@ -194,11 +321,33 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @property() public locale = 'en'; + /** The prompt symbol to use for unfilled parts of the mask. + * @attr + */ + @property() + public prompt = '_'; + + /** The resource strings of the calendar. */ + @property({ attribute: false }) + public resourceStrings: IgcCalendarResourceStrings = + IgcCalendarResourceStringEN; + @watch('open') protected openChange() { this._rootClickController.update(); } + /** Sets the start day of the week for the calendar. */ + @property({ attribute: 'week-start' }) + public weekStart: + | 'sunday' + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' = 'sunday'; + constructor() { super(); @@ -208,6 +357,48 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( }).set(escapeKey, this.onEscapeKey); } + /** Clears the input part of the component of any user input */ + public clear() { + this.value = undefined; + this._input?.clear(); + } + + /** Increments the passed in date part */ + public stepUp(datePart?: DatePart, delta?: number): void { + this._input.stepUp(datePart, delta); + } + + /** Decrements the passed in date part */ + public stepDown(datePart?: DatePart, delta?: number): void { + this._input.stepDown(datePart, delta); + } + + /** Selects the text in the input of the component */ + public select(): void { + this._input.select(); + } + + /** Sets the text selection range in the input of the component */ + public setSelectionRange( + start: number, + end: number, + direction?: 'none' | 'backward' | 'forward' + ): void { + this._input.setSelectionRange(start, end, direction); + } + + /* Replaces the selected text in the input and re-applies the mask */ + public setRangeText( + replacement: string, + start: number, + end: number, + mode?: 'select' | 'start' | 'end' | 'preserve' + ): void { + // currently does not work, would depend on the fix of this issue: + // https://github.com/IgniteUI/igniteui-webcomponents/issues/1075 + this._input.setRangeText(replacement, start, end, mode); + } + protected async onEscapeKey() { if (await this._hide(true)) { this._input.focus(); @@ -243,6 +434,21 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this.emitEvent('igcInput', { detail: this.value }); } + @watch('value') + protected valueChange() { + this.value + ? this.setFormValue(this.value.toISOString()) + : this.setFormValue(null); + this.updateValidity(); + this.setInvalidState(); + } + + @watch('min', { waitUntilFirstUpdate: true }) + @watch('max', { waitUntilFirstUpdate: true }) + protected constraintChange() { + this.updateValidity(); + } + protected override render() { const id = this.id || this.inputId; const calendarDisabled = !this.open || this.disabled; @@ -256,8 +462,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( ?required=${this.required} label=${ifDefined(this.label)} aria-expanded=${this.open ? 'true' : 'false'} - .value=${this.value} + .value=${this.value ?? null} .locale=${this.locale} + .prompt=${this.prompt} + .outlined=${this.outlined} + .placeholder=${this.placeholder} + .min=${this.min} + .max=${this.max} @igcChange=${this.handleInputChangeEvent} @igcInput=${this.handleInputEvent} > @@ -278,14 +489,19 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index a9c41a047..6b2b5233f 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -46,13 +46,59 @@ const metadata: Meta = { description: 'The label of the datepicker.', control: 'text', }, + mode: { + type: '"dropdown" | "dialog"', + description: + 'Determines whether the calendar is opened as a dropdown or as a dialog', + options: ['dropdown', 'dialog'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'dropdown' } }, + }, readOnly: { type: 'boolean', description: 'Makes the control a readonly field.', control: 'boolean', table: { defaultValue: { summary: false } }, }, - value: { type: 'Date', control: 'date' }, + value: { + type: 'Date', + description: 'The value of the picker', + control: 'date', + }, + activeDate: { type: 'Date', control: 'date' }, + min: { + type: 'Date', + description: + 'The minimum value required for the date picker to remain valid.', + control: 'date', + }, + max: { + type: 'Date', + description: + 'The maximum value required for the date picker to remain valid.', + control: 'date', + }, + headerOrientation: { + type: '"vertical" | "horizontal"', + description: 'The orientation of the calendar header.', + options: ['vertical', 'horizontal'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'horizontal' } }, + }, + orientation: { + type: '"vertical" | "horizontal"', + description: + "The orientation of the multiple months displayed in the calendar's days view.", + options: ['vertical', 'horizontal'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'horizontal' } }, + }, + hideHeader: { + type: 'boolean', + description: 'Determines whether the calendar hides its header.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, hideOutsideDays: { type: 'boolean', description: @@ -60,6 +106,17 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: false } }, }, + outlined: { + type: 'boolean', + description: 'Whether the control will have outlined appearance.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, + placeholder: { + type: 'string', + description: 'The placeholder attribute of the control.', + control: 'text', + }, visibleMonths: { type: 'number', description: 'The number of months displayed in the calendar.', @@ -90,6 +147,27 @@ const metadata: Meta = { control: 'text', table: { defaultValue: { summary: 'en' } }, }, + prompt: { + type: 'string', + description: 'The prompt symbol to use for unfilled parts of the mask.', + control: 'text', + table: { defaultValue: { summary: '_' } }, + }, + weekStart: { + type: '"sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"', + description: 'Sets the start day of the week for the calendar.', + options: [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ], + control: { type: 'select' }, + table: { defaultValue: { summary: 'sunday' } }, + }, required: { type: 'boolean', description: 'Makes the control a required field in a form context.', @@ -124,11 +202,18 @@ const metadata: Meta = { args: { keepOpenOnOutsideClick: false, open: false, + mode: 'dropdown', readOnly: false, + headerOrientation: 'horizontal', + orientation: 'horizontal', + hideHeader: false, hideOutsideDays: false, + outlined: false, visibleMonths: 1, showWeekNumbers: false, locale: 'en', + prompt: '_', + weekStart: 'sunday', required: false, disabled: false, invalid: false, @@ -145,11 +230,29 @@ interface IgcDatepickerArgs { open: boolean; /** The label of the datepicker. */ label: string; + /** Determines whether the calendar is opened as a dropdown or as a dialog */ + mode: 'dropdown' | 'dialog'; /** Makes the control a readonly field. */ readOnly: boolean; + /** The value of the picker */ value: Date; + activeDate: Date; + /** The minimum value required for the date picker to remain valid. */ + min: Date; + /** The maximum value required for the date picker to remain valid. */ + max: Date; + /** The orientation of the calendar header. */ + headerOrientation: 'vertical' | 'horizontal'; + /** The orientation of the multiple months displayed in the calendar's days view. */ + orientation: 'vertical' | 'horizontal'; + /** Determines whether the calendar hides its header. */ + hideHeader: boolean; /** Controls the visibility of the dates that do not belong to the current month. */ hideOutsideDays: boolean; + /** Whether the control will have outlined appearance. */ + outlined: boolean; + /** The placeholder attribute of the control. */ + placeholder: string; /** The number of months displayed in the calendar. */ visibleMonths: number; /** Whether to show the number of the week in the calendar. */ @@ -166,6 +269,17 @@ interface IgcDatepickerArgs { inputFormat: string; /** The locale settings used to display the value. */ locale: string; + /** The prompt symbol to use for unfilled parts of the mask. */ + prompt: string; + /** Sets the start day of the week for the calendar. */ + weekStart: + | 'sunday' + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday'; /** Makes the control a required field in a form context. */ required: boolean; /** The name attribute of the control. */ @@ -191,10 +305,20 @@ export const Default: Story = { Date: Fri, 23 Feb 2024 13:04:05 +0200 Subject: [PATCH 05/63] feat(date-picker): add nonEditable prop --- .../date-picker/date-picker.spec.ts | 631 +++++++++++------- src/components/date-picker/date-picker.ts | 21 +- stories/datepicker.stories.ts | 14 +- 3 files changed, 425 insertions(+), 241 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index a3d754522..81f7b5544 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -6,6 +6,7 @@ import IgcCalendarComponent from '../calendar/calendar.js'; import { DateRangeType } from '../calendar/common/calendar.model.js'; import IgcDaysViewComponent from '../calendar/days-view/days-view.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; +import { FormAssociatedTestBed } from '../common/utils.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; describe('Date picker', () => { @@ -15,20 +16,20 @@ describe('Date picker', () => { let dateTimeInput: IgcDateTimeInputComponent; let calendar: IgcCalendarComponent; - describe('Rendering and initialization', () => { - beforeEach(async () => { - picker = await fixture( - html`` - ); - dateTimeInput = picker.shadowRoot!.querySelector( - IgcDateTimeInputComponent.tagName - ) as IgcDateTimeInputComponent; - - calendar = picker.shadowRoot!.querySelector( - IgcCalendarComponent.tagName - ) as IgcCalendarComponent; - }); + beforeEach(async () => { + picker = await fixture( + html`` + ); + dateTimeInput = picker.shadowRoot!.querySelector( + IgcDateTimeInputComponent.tagName + ) as IgcDateTimeInputComponent; + + calendar = picker.shadowRoot!.querySelector( + IgcCalendarComponent.tagName + ) as IgcCalendarComponent; + }); + describe('Rendering and initialization', () => { it('is defined', async () => { expect(picker).is.not.undefined; }); @@ -45,292 +46,357 @@ describe('Date picker', () => { await expect(picker).shadowDom.to.be.accessible(); await expect(picker).lightDom.to.be.accessible(); }); + }); - describe('Basic', () => { - it('should set prompt char correctly', async () => { - picker.prompt = '*'; - await elementUpdated(picker); + describe('Basic', () => { + const currentDate = new Date(new Date().setHours(0, 0, 0)); + const tomorrowDate = new Date( + new Date().setDate(currentDate.getDate() + 1) + ); - expect(dateTimeInput.prompt).to.equal('*'); - }); + it('should set prompt char correctly', async () => { + picker.prompt = '*'; + await elementUpdated(picker); - it('should not close calendar after selection when keepOpenOnSelect is true', async () => { - expect(picker.open).to.equal(false); - picker.keepOpenOnSelect = true; - await elementUpdated(picker); + expect(dateTimeInput.prompt).to.equal('*'); + }); - picker.show(); - await elementUpdated(picker); + it('should not close calendar after selection when keepOpenOnSelect is true', async () => { + expect(picker.open).to.equal(false); + picker.keepOpenOnSelect = true; + await elementUpdated(picker); - const currentDate = new Date(new Date().setHours(0, 0, 0)); - const eventSpy = spy(picker, 'emitEvent'); + picker.show(); + await elementUpdated(picker); - selectCurrentDate(calendar); - await elementUpdated(picker); + const eventSpy = spy(picker, 'emitEvent'); - expect(eventSpy).calledOnce; + selectCurrentDate(calendar); + await elementUpdated(picker); - expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( - currentDate.toLocaleDateString('en-US') - ); + expect(eventSpy).calledOnce; - expect((calendar.value as Date).toLocaleDateString('en-US')).to.equal( - currentDate.toLocaleDateString('en-US') - ); + expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( + currentDate.toLocaleDateString('en-US') + ); - expect(picker.open).to.equal(true); - }); + expect((calendar.value as Date).toLocaleDateString('en-US')).to.equal( + currentDate.toLocaleDateString('en-US') + ); - it('should not close calendar on clicking outside of it when keepOpenOnOutsideClick is true', async () => { - expect(picker.open).to.equal(false); - picker.keepOpenOnOutsideClick = true; - await elementUpdated(picker); + expect(picker.open).to.equal(true); + }); - picker.show(); - await elementUpdated(picker); + it('should not close calendar on clicking outside of it when keepOpenOnOutsideClick is true', async () => { + expect(picker.open).to.equal(false); + picker.keepOpenOnOutsideClick = true; + await elementUpdated(picker); - document.body.click(); - await elementUpdated(picker); + picker.show(); + await elementUpdated(picker); - expect(picker.open).to.equal(true); - }); + document.body.click(); + await elementUpdated(picker); - it('should set properties of the calendar correctly', async () => { - const props = { - weekStart: 'friday', - hideOutsideDays: true, - hideHeader: true, - showWeekNumbers: true, - visibleMonths: 3, - headerOrientation: 'vertical', - orientation: 'vertical', - disabledDates: [ - { - type: DateRangeType.Before, - dateRange: [new Date()], - }, - ], - specialDates: [ - { - type: DateRangeType.Weekends, - dateRange: [], - }, - ], - }; - - //test defaults - expect(picker.weekStart).to.equal('sunday'); - expect(picker.hideOutsideDays).to.equal(false); - expect(picker.hideHeader).to.equal(false); - expect(picker.showWeekNumbers).to.equal(false); - expect(picker.visibleMonths).to.equal(1); - expect(picker.headerOrientation).to.equal('horizontal'); - expect(picker.orientation).to.equal('horizontal'); - expect(calendar.disabledDates).to.be.undefined; - expect(calendar.specialDates).to.be.undefined; - - Object.assign(picker, props); - await elementUpdated(picker); + expect(picker.open).to.equal(true); + }); - for (const [prop, value] of Object.entries(props)) { - expect((calendar as any)[prop]).to.equal(value); - } - }); + it('should modify value only through calendar selection and not input when nonEditable is true', async () => { + picker.value = tomorrowDate; + await elementUpdated(picker); - it('should set the mode property correctly', async () => { - // TODO - }); + picker.nonEditable = true; + await elementUpdated(picker); - it('should set properties of the input correctly', async () => { - const props = { - required: true, - label: 'Sample Label', - disabled: true, - placeholder: 'Sample placeholder', - outlined: true, - }; + picker.show(); + await elementUpdated(picker); - Object.assign(picker, props); - await elementUpdated(picker); + const eventSpy = spy(picker, 'emitEvent'); - for (const [prop, value] of Object.entries(props)) { - expect((dateTimeInput as any)[prop]).to.equal(value); - } - }); + selectCurrentDate(calendar); + await elementUpdated(picker); - describe('Active date', () => { - const currentDate = new Date(); - const currentDateString = currentDate.toLocaleDateString('en-US'); + expect(eventSpy).calledWith('igcChange'); + expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( + currentDate.toLocaleDateString('en-US') + ); - const tomorrowDate = new Date( - currentDate.setDate(currentDate.getDate() + 1) - ); - const tomorrowDateString = tomorrowDate.toLocaleDateString('en-US'); + eventSpy.resetHistory(); + dateTimeInput.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })); + await elementUpdated(picker); - const after10DaysDate = new Date( - currentDate.setDate(currentDate.getDate() + 10) - ); - const after10DaysString = after10DaysDate.toLocaleDateString('en-US'); + expect(eventSpy).not.called; + expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( + currentDate.toLocaleDateString('en-US') + ); + }); - const after20DaysDate = new Date( - currentDate.setDate(currentDate.getDate() + 20) - ); + it('should not modify value through selection or typing when readOnly is true', async () => { + picker.value = tomorrowDate; + await elementUpdated(picker); - it('should initialize activeDate with current date, when not set', async () => { - expect(picker.activeDate.toLocaleDateString('en-US')).to.equal( - currentDateString - ); - expect(picker.value).to.be.undefined; - expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( - currentDateString - ); - expect(calendar.value).to.be.undefined; - }); - - it('should initialize activeDate = value when it is not set, but value is', async () => { - const valueDate = after10DaysDate; - picker = await fixture( - html`` - ); - await elementUpdated(picker); - await elementUpdated(picker); + picker.readOnly = true; + await elementUpdated(picker); - expect(picker.value?.toLocaleDateString('en-US')).to.equal( - valueDate.toLocaleDateString('en-US') - ); + picker.show(); + await elementUpdated(picker); - calendar = picker.shadowRoot!.querySelector( - IgcCalendarComponent.tagName - ) as IgcCalendarComponent; + const eventSpy = spy(picker, 'emitEvent'); - expect(picker.activeDate.toLocaleDateString('en-US')).to.equal( - after10DaysString - ); - expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( - after10DaysString - ); - expect(calendar.value!.toLocaleDateString('en-US')).to.equal( - after10DaysString - ); - }); + selectCurrentDate(calendar); + await elementUpdated(picker); - it('should set activeDate correctly', async () => { - picker.activeDate = tomorrowDate; + expect(eventSpy).not.calledWith('igcChange'); + expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( + tomorrowDate.toLocaleDateString('en-US') + ); - await elementUpdated(picker); - expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( - tomorrowDateString - ); - // value is not defined - expect(picker.value).to.be.undefined; - - // setting the value does not affect the activeDate, when it is explicitly set - picker.value = after20DaysDate; - await elementUpdated(picker); + eventSpy.resetHistory(); + dateTimeInput.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })); + await elementUpdated(picker); - expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( - tomorrowDateString - ); - }); - }); + expect(eventSpy).not.called; + expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( + tomorrowDate.toLocaleDateString('en-US') + ); }); - describe('Methods', () => { - let input: HTMLInputElement; + it('should set properties of the calendar correctly', async () => { + const props = { + weekStart: 'friday', + hideOutsideDays: true, + hideHeader: true, + showWeekNumbers: true, + visibleMonths: 3, + headerOrientation: 'vertical', + orientation: 'vertical', + disabledDates: [ + { + type: DateRangeType.Before, + dateRange: [new Date()], + }, + ], + specialDates: [ + { + type: DateRangeType.Weekends, + dateRange: [], + }, + ], + }; + + //test defaults + expect(picker.weekStart).to.equal('sunday'); + expect(picker.hideOutsideDays).to.equal(false); + expect(picker.hideHeader).to.equal(false); + expect(picker.showWeekNumbers).to.equal(false); + expect(picker.visibleMonths).to.equal(1); + expect(picker.headerOrientation).to.equal('horizontal'); + expect(picker.orientation).to.equal('horizontal'); + expect(calendar.disabledDates).to.be.undefined; + expect(calendar.specialDates).to.be.undefined; + + Object.assign(picker, props); + await elementUpdated(picker); - beforeEach(() => { - input = dateTimeInput.shadowRoot!.querySelector( - 'input' - ) as HTMLInputElement; - }); + for (const [prop, value] of Object.entries(props)) { + expect((calendar as any)[prop]).to.equal(value); + } + }); - it('should open/close the picker on invoking show/hide/toggle and not emit events', async () => { - const eventSpy = spy(picker, 'emitEvent'); + it('should set the mode property correctly', async () => { + // TODO + }); - expect(picker.open).to.be.false; - picker.show(); - await elementUpdated(picker); + it('should set properties of the input correctly', async () => { + const props = { + required: true, + label: 'Sample Label', + disabled: true, + placeholder: 'Sample placeholder', + outlined: true, + }; - expect(eventSpy).not.called; - expect(picker.open).to.be.true; + Object.assign(picker, props); + await elementUpdated(picker); - picker.hide(); - await elementUpdated(picker); + for (const [prop, value] of Object.entries(props)) { + expect((dateTimeInput as any)[prop]).to.equal(value); + } + }); - expect(eventSpy).not.called; - expect(picker.open).to.be.false; + describe('Active date', () => { + const currentDate = new Date(); + const currentDateString = currentDate.toLocaleDateString('en-US'); - picker.toggle(); - await elementUpdated(picker); + const tomorrowDate = new Date( + new Date().setDate(currentDate.getDate() + 1) + ); + const tomorrowDateString = tomorrowDate.toLocaleDateString('en-US'); - expect(eventSpy).not.called; - expect(picker.open).to.be.true; + const after10DaysDate = new Date( + new Date().setDate(currentDate.getDate() + 10) + ); + const after10DaysString = after10DaysDate.toLocaleDateString('en-US'); - picker.toggle(); - await elementUpdated(picker); + const after20DaysDate = new Date( + new Date().setDate(currentDate.getDate() + 20) + ); - expect(eventSpy).not.called; - expect(picker.open).to.be.false; + it('should initialize activeDate with current date, when not set', async () => { + expect(picker.activeDate.toLocaleDateString('en-US')).to.equal( + currentDateString + ); + expect(picker.value).to.be.undefined; + expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( + currentDateString + ); + expect(calendar.value).to.be.undefined; }); - it('should clear the input on invoking clear()', async () => { - picker.value = new Date(); + it('should initialize activeDate = value when it is not set, but value is', async () => { + const valueDate = after10DaysDate; + picker = await fixture( + html`` + ); await elementUpdated(picker); - - expect(dateTimeInput.value).to.equal(picker.value); - picker.clear(); await elementUpdated(picker); - expect(picker.value).to.be.undefined; - expect(dateTimeInput.value).to.be.null; + expect(picker.value?.toLocaleDateString('en-US')).to.equal( + valueDate.toLocaleDateString('en-US') + ); + + calendar = picker.shadowRoot!.querySelector( + IgcCalendarComponent.tagName + ) as IgcCalendarComponent; + + expect(picker.activeDate.toLocaleDateString('en-US')).to.equal( + after10DaysString + ); + expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( + after10DaysString + ); + expect(calendar.value!.toLocaleDateString('en-US')).to.equal( + after10DaysString + ); }); - it('should delegate stepUp and stepDown to the igc-date-time-input', async () => { - const stepUpSpy = spy(dateTimeInput, 'stepUp'); - const stepDownSpy = spy(dateTimeInput, 'stepDown'); + it('should set activeDate correctly', async () => { + picker.activeDate = tomorrowDate; - picker.stepUp(); await elementUpdated(picker); + expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( + tomorrowDateString + ); + // value is not defined + expect(picker.value).to.be.undefined; - expect(stepUpSpy).called; - - picker.stepDown(); + // setting the value does not affect the activeDate, when it is explicitly set + picker.value = after20DaysDate; await elementUpdated(picker); - expect(stepDownSpy).called; + expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( + tomorrowDateString + ); }); + }); + }); - it('should select the text in the input with the select method', async () => { - const selectSpy = spy(dateTimeInput, 'select'); - picker.value = new Date(); - await elementUpdated(picker); + describe('Methods', () => { + let input: HTMLInputElement; - dateTimeInput.focus(); - picker.select(); - await elementUpdated(picker); + beforeEach(() => { + input = dateTimeInput.shadowRoot!.querySelector( + 'input' + ) as HTMLInputElement; + }); - expect(selectSpy).to.be.called; - expect(input.selectionStart).to.eq(0); - expect(input.selectionEnd).to.eq(input.value.length); - }); + it('should open/close the picker on invoking show/hide/toggle and not emit events', async () => { + const eventSpy = spy(picker, 'emitEvent'); - it('should set the text selection range in the input with setSelectionRange()', async () => { - const selectionRangeSpy = spy(dateTimeInput, 'setSelectionRange'); - picker.value = new Date(); - await elementUpdated(picker); + expect(picker.open).to.be.false; + picker.show(); + await elementUpdated(picker); - dateTimeInput.focus(); - picker.setSelectionRange(0, 2); - await elementUpdated(picker); + expect(eventSpy).not.called; + expect(picker.open).to.be.true; - expect(selectionRangeSpy).to.be.called; - expect(input.selectionStart).to.eq(0); - expect(input.selectionEnd).to.eq(2); - }); + picker.hide(); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.open).to.be.false; + + picker.toggle(); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.open).to.be.true; + + picker.toggle(); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.open).to.be.false; + }); + + it('should clear the input on invoking clear()', async () => { + picker.value = new Date(); + await elementUpdated(picker); + + expect(dateTimeInput.value).to.equal(picker.value); + picker.clear(); + await elementUpdated(picker); + + expect(picker.value).to.be.undefined; + expect(dateTimeInput.value).to.be.null; + }); + + it('should delegate stepUp and stepDown to the igc-date-time-input', async () => { + const stepUpSpy = spy(dateTimeInput, 'stepUp'); + const stepDownSpy = spy(dateTimeInput, 'stepDown'); + + picker.stepUp(); + await elementUpdated(picker); + + expect(stepUpSpy).called; + + picker.stepDown(); + await elementUpdated(picker); + + expect(stepDownSpy).called; + }); - it('should replace the selected text in the input and re-apply the mask with setRangeText()', async () => { - // TODO - fix of src/components/date-picker/date-picker.spec.ts - /* + it('should select the text in the input with the select method', async () => { + const selectSpy = spy(dateTimeInput, 'select'); + picker.value = new Date(); + await elementUpdated(picker); + + dateTimeInput.focus(); + picker.select(); + await elementUpdated(picker); + + expect(selectSpy).to.be.called; + expect(input.selectionStart).to.eq(0); + expect(input.selectionEnd).to.eq(input.value.length); + }); + + it('should set the text selection range in the input with setSelectionRange()', async () => { + const selectionRangeSpy = spy(dateTimeInput, 'setSelectionRange'); + picker.value = new Date(); + await elementUpdated(picker); + + dateTimeInput.focus(); + picker.setSelectionRange(0, 2); + await elementUpdated(picker); + + expect(selectionRangeSpy).to.be.called; + expect(input.selectionStart).to.eq(0); + expect(input.selectionEnd).to.eq(2); + }); + + it('should replace the selected text in the input and re-apply the mask with setRangeText()', async () => { + // TODO - fix of src/components/date-picker/date-picker.spec.ts + /* const setRangeTextSpy = spy(dateTimeInput, 'setRangeText'); picker.value = new Date(2024, 2, 21); const expectedValue = new Date(2023, 2, 21); @@ -348,7 +414,14 @@ describe('Date picker', () => { expect(picker.value).to.eq(expectedValue); expect(dateTimeInput.value).to.eq(expectedValue); */ - }); + }); + }); + + describe('Interactions', () => { + it('should close the picker when in open state on pressing Escape', async () => { + // TODO + // when focus in on any part of the date picker + // when focus is outside of the date picker parts }); describe('Slotted content', () => { @@ -468,6 +541,90 @@ describe('Date picker', () => { }); describe('Form integration', () => { + const spec = new FormAssociatedTestBed( + html`` + ); + + beforeEach(async () => { + await spec.setup(IgcDatepickerComponent.tagName); + }); + + it('is form associated', async () => { + expect(spec.element.form).to.equal(spec.form); + }); + + it('is not associated on submit if no value', async () => { + expect(spec.submit()?.get(spec.element.name)).to.be.null; + }); + + it('is associated on submit', async () => { + spec.element.value = new Date(Date.now()); + await elementUpdated(spec.element); + + expect(spec.submit()?.get(spec.element.name)).to.equal( + spec.element.value.toISOString() + ); + }); + + it('is correctly reset on form reset', async () => { + spec.element.value = new Date(Date.now()); + await elementUpdated(spec.element); + + spec.reset(); + expect(spec.element.value).to.be.undefined; + }); + + it('reflects disabled ancestor state', async () => { + spec.setAncestorDisabledState(true); + expect(spec.element.disabled).to.be.true; + + spec.setAncestorDisabledState(false); + expect(spec.element.disabled).to.be.false; + }); + + it('fulfils required constraint', async () => { + spec.element.required = true; + await elementUpdated(spec.element); + spec.submitFails(); + + spec.element.value = new Date(Date.now()); + await elementUpdated(spec.element); + spec.submitValidates(); + }); + + it('fulfils min value constraint', async () => { + spec.element.min = new Date(2025, 0, 1); + await elementUpdated(spec.element); + spec.submitFails(); + + spec.element.value = new Date(2022, 0, 1); + await elementUpdated(spec.element); + spec.submitFails(); + + spec.element.value = new Date(2025, 0, 2); + await elementUpdated(spec.element); + spec.submitValidates(); + }); + + it('fulfils max value constraint', async () => { + spec.element.max = new Date(2020, 0, 1); + spec.element.value = new Date(Date.now()); + await elementUpdated(spec.element); + spec.submitFails(); + + spec.element.value = new Date(2020, 0, 1); + await elementUpdated(spec.element); + spec.submitValidates(); + }); + + it('fulfils custom constraint', async () => { + spec.element.setCustomValidity('invalid'); + spec.submitFails(); + + spec.element.setCustomValidity(''); + spec.submitValidates(); + }); + it('should set a custom validation message with setCustomValidity()', async () => { // TODO // As long as message is not empty, the component is considered invalid diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index f6c1bc944..91377cf1d 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -162,12 +162,19 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( public label!: string; /** - * Determines whether the calendar is opened as a dropdown or as a dialog + * Determines whether the calendar is opened in a dropdown or a modal dialog * @attr mode */ @property() public mode: 'dropdown' | 'dialog' = 'dropdown'; + /** + * Whether to allow typing in the input. + * @attr non-editable + */ + @property({ type: Boolean, reflect: true, attribute: 'non-editable' }) + public nonEditable = false; + /** * Makes the control a readonly field. * @attr readonly @@ -421,6 +428,11 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( protected handleCalendarChangeEvent(event: CustomEvent) { event.stopPropagation(); + if (this.readOnly) { + event.preventDefault(); + return; + } + this.value = (event.target as IgcCalendarComponent).value!; this.emitEvent('igcChange', { detail: this.value }); @@ -430,6 +442,11 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( protected handleInputEvent(event: CustomEvent) { event.stopPropagation(); + if (this.nonEditable) { + event.preventDefault(); + return; + } + this.value = (event.target as IgcDateTimeInputComponent).value!; this.emitEvent('igcInput', { detail: this.value }); } @@ -458,7 +475,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( id=${id} aria-haspopup="true" ?disabled=${this.disabled} - ?readonly=${this.readOnly} + ?readonly=${this.nonEditable || this.readOnly} ?required=${this.required} label=${ifDefined(this.label)} aria-expanded=${this.open ? 'true' : 'false'} diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index 6b2b5233f..23c3835ab 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -49,11 +49,17 @@ const metadata: Meta = { mode: { type: '"dropdown" | "dialog"', description: - 'Determines whether the calendar is opened as a dropdown or as a dialog', + 'Determines whether the calendar is opened in a dropdown or a modal dialog', options: ['dropdown', 'dialog'], control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'dropdown' } }, }, + nonEditable: { + type: 'boolean', + description: 'Whether to allow typing in the input.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, readOnly: { type: 'boolean', description: 'Makes the control a readonly field.', @@ -203,6 +209,7 @@ const metadata: Meta = { keepOpenOnOutsideClick: false, open: false, mode: 'dropdown', + nonEditable: false, readOnly: false, headerOrientation: 'horizontal', orientation: 'horizontal', @@ -230,8 +237,10 @@ interface IgcDatepickerArgs { open: boolean; /** The label of the datepicker. */ label: string; - /** Determines whether the calendar is opened as a dropdown or as a dialog */ + /** Determines whether the calendar is opened in a dropdown or a modal dialog */ mode: 'dropdown' | 'dialog'; + /** Whether to allow typing in the input. */ + nonEditable: boolean; /** Makes the control a readonly field. */ readOnly: boolean; /** The value of the picker */ @@ -313,6 +322,7 @@ export const Default: Story = { .weekStart=${args.weekStart} .hideHeader=${args.hideHeader} .headerOrientation=${args.headerOrientation} + .nonEditable=${args.nonEditable} .orientation=${args.orientation} .min=${args.min ? new Date(args.min as Date) : undefined} .max=${args.max ? new Date(args.max as Date) : undefined} From 2abacd553a8f351ce8e11943be6d1917ab6be62a Mon Sep 17 00:00:00 2001 From: ddaribo Date: Mon, 26 Feb 2024 16:22:03 +0200 Subject: [PATCH 06/63] feat(date-picker): checkpoint commit --- .../common/localization/validation-en.ts | 1 + .../date-picker/date-picker.spec.ts | 466 +++++++++--------- src/components/date-picker/date-picker.ts | 21 +- stories/datepicker.stories.ts | 27 +- 4 files changed, 288 insertions(+), 227 deletions(-) diff --git a/src/components/common/localization/validation-en.ts b/src/components/common/localization/validation-en.ts index 683199349..d14e47352 100644 --- a/src/components/common/localization/validation-en.ts +++ b/src/components/common/localization/validation-en.ts @@ -8,4 +8,5 @@ export default { max: 'A value no more than {0} should be entered', email: 'A valid email address should be entered', url: 'A valid url address should be entered', + disabledDate: 'The entered value {0} is within the disabled dates range', } as const; diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 81f7b5544..17100ebf0 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -3,7 +3,10 @@ import { spy } from 'sinon'; import IgcDatepickerComponent from './date-picker.js'; import IgcCalendarComponent from '../calendar/calendar.js'; -import { DateRangeType } from '../calendar/common/calendar.model.js'; +import { + DateRangeDescriptor, + DateRangeType, +} from '../calendar/common/calendar.model.js'; import IgcDaysViewComponent from '../calendar/days-view/days-view.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { FormAssociatedTestBed } from '../common/utils.spec.js'; @@ -39,13 +42,22 @@ describe('Date picker', () => { await expect(picker).lightDom.to.be.accessible(); }); - it('is accessible (open state)', async () => { + it('is accessible (open state) - default dropdown mode', async () => { picker.open = true; await elementUpdated(picker); await expect(picker).shadowDom.to.be.accessible(); await expect(picker).lightDom.to.be.accessible(); }); + + it('is accessible (open state) - dialog mode', async () => { + picker.open = true; + picker.mode = 'dialog'; + await elementUpdated(picker); + + await expect(picker).shadowDom.to.be.accessible(); + await expect(picker).lightDom.to.be.accessible(); + }); }); describe('Basic', () => { @@ -54,6 +66,22 @@ describe('Date picker', () => { new Date().setDate(currentDate.getDate() + 1) ); + it('should show/hide the picker based on the value of the open attribute', async () => { + expect(picker.open).to.equal(false); + picker.open = true; + const eventSpy = spy(picker, 'emitEvent'); + await elementUpdated(picker); + + expect(picker.open).to.equal(true); + expect(eventSpy).not.called; + + picker.open = false; + await elementUpdated(picker); + + expect(picker.open).to.equal(false); + expect(eventSpy).not.called; + }); + it('should set prompt char correctly', async () => { picker.prompt = '*'; await elementUpdated(picker); @@ -76,13 +104,8 @@ describe('Date picker', () => { expect(eventSpy).calledOnce; - expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( - currentDate.toLocaleDateString('en-US') - ); - - expect((calendar.value as Date).toLocaleDateString('en-US')).to.equal( - currentDate.toLocaleDateString('en-US') - ); + checkDatesEqual(picker.value as Date, currentDate); + checkDatesEqual(calendar.value as Date, currentDate); expect(picker.open).to.equal(true); }); @@ -117,18 +140,14 @@ describe('Date picker', () => { await elementUpdated(picker); expect(eventSpy).calledWith('igcChange'); - expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( - currentDate.toLocaleDateString('en-US') - ); + checkDatesEqual(picker.value as Date, currentDate); eventSpy.resetHistory(); dateTimeInput.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })); await elementUpdated(picker); expect(eventSpy).not.called; - expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( - currentDate.toLocaleDateString('en-US') - ); + checkDatesEqual(picker.value as Date, currentDate); }); it('should not modify value through selection or typing when readOnly is true', async () => { @@ -147,18 +166,14 @@ describe('Date picker', () => { await elementUpdated(picker); expect(eventSpy).not.calledWith('igcChange'); - expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( - tomorrowDate.toLocaleDateString('en-US') - ); + checkDatesEqual(picker.value as Date, tomorrowDate); eventSpy.resetHistory(); dateTimeInput.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })); await elementUpdated(picker); expect(eventSpy).not.called; - expect((picker.value as Date).toLocaleDateString('en-US')).to.equal( - tomorrowDate.toLocaleDateString('en-US') - ); + checkDatesEqual(picker.value as Date, tomorrowDate); }); it('should set properties of the calendar correctly', async () => { @@ -207,6 +222,14 @@ describe('Date picker', () => { // TODO }); + it('should be successfully initialized in open state in dropdown mode', async () => { + // TODO + }); + + it('should be successfully initialized in open state in dialog mode', async () => { + // TODO + }); + it('should set properties of the input correctly', async () => { const props = { required: true, @@ -226,30 +249,20 @@ describe('Date picker', () => { describe('Active date', () => { const currentDate = new Date(); - const currentDateString = currentDate.toLocaleDateString('en-US'); - const tomorrowDate = new Date( new Date().setDate(currentDate.getDate() + 1) ); - const tomorrowDateString = tomorrowDate.toLocaleDateString('en-US'); - const after10DaysDate = new Date( new Date().setDate(currentDate.getDate() + 10) ); - const after10DaysString = after10DaysDate.toLocaleDateString('en-US'); - const after20DaysDate = new Date( new Date().setDate(currentDate.getDate() + 20) ); it('should initialize activeDate with current date, when not set', async () => { - expect(picker.activeDate.toLocaleDateString('en-US')).to.equal( - currentDateString - ); + checkDatesEqual(picker.activeDate, currentDate); expect(picker.value).to.be.undefined; - expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( - currentDateString - ); + checkDatesEqual(calendar.activeDate, currentDate); expect(calendar.value).to.be.undefined; }); @@ -261,32 +274,23 @@ describe('Date picker', () => { await elementUpdated(picker); await elementUpdated(picker); - expect(picker.value?.toLocaleDateString('en-US')).to.equal( - valueDate.toLocaleDateString('en-US') - ); + checkDatesEqual(picker.value as Date, valueDate); calendar = picker.shadowRoot!.querySelector( IgcCalendarComponent.tagName ) as IgcCalendarComponent; - expect(picker.activeDate.toLocaleDateString('en-US')).to.equal( - after10DaysString - ); - expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( - after10DaysString - ); - expect(calendar.value!.toLocaleDateString('en-US')).to.equal( - after10DaysString - ); + checkDatesEqual(picker.activeDate, after10DaysDate); + checkDatesEqual(calendar.activeDate, after10DaysDate); + checkDatesEqual(calendar.value as Date, after10DaysDate); }); it('should set activeDate correctly', async () => { picker.activeDate = tomorrowDate; await elementUpdated(picker); - expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( - tomorrowDateString - ); + checkDatesEqual(calendar.activeDate, tomorrowDate); + // value is not defined expect(picker.value).to.be.undefined; @@ -294,9 +298,7 @@ describe('Date picker', () => { picker.value = after20DaysDate; await elementUpdated(picker); - expect(calendar.activeDate.toLocaleDateString('en-US')).to.equal( - tomorrowDateString - ); + checkDatesEqual(calendar.activeDate, tomorrowDate); }); }); }); @@ -395,240 +397,251 @@ describe('Date picker', () => { }); it('should replace the selected text in the input and re-apply the mask with setRangeText()', async () => { - // TODO - fix of src/components/date-picker/date-picker.spec.ts - /* - const setRangeTextSpy = spy(dateTimeInput, 'setRangeText'); - picker.value = new Date(2024, 2, 21); - const expectedValue = new Date(2023, 2, 21); - await elementUpdated(picker); + const setRangeTextSpy = spy(dateTimeInput, 'setRangeText'); + picker.value = new Date(2024, 2, 21); + const expectedValue = new Date(2023, 2, 21); + await elementUpdated(picker); - dateTimeInput.focus(); - picker.setRangeText('2023', 6, 10); - await elementUpdated(picker); + dateTimeInput.focus(); + picker.setRangeText('2023', 6, 10); + await elementUpdated(picker); - expect(setRangeTextSpy).to.be.called; + expect(setRangeTextSpy).to.be.called; - expect(new Date(input.value).toISOString()).to.equal( - expectedValue.toISOString() - ); - expect(picker.value).to.eq(expectedValue); - expect(dateTimeInput.value).to.eq(expectedValue); - */ + checkDatesEqual(new Date(input.value), expectedValue); + expect(picker.value).to.eq(expectedValue); + expect(dateTimeInput.value).to.eq(expectedValue); }); }); describe('Interactions', () => { it('should close the picker when in open state on pressing Escape', async () => { - // TODO - // when focus in on any part of the date picker - // when focus is outside of the date picker parts - }); + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); + const eventSpy = spy(picker, 'emitEvent'); + picker.focus(); + picker.dispatchEvent(escapeEvent); + await elementUpdated(picker); - describe('Slotted content', () => { - // TODO - }); + expect(eventSpy).not.called; - describe('Localization', () => { - it('should set inputFormat correctly', async () => { - const testFormat = 'dd--MM--yyyy'; - picker.inputFormat = testFormat; - await elementUpdated(picker); + picker.show(); + await elementUpdated(picker); - expect(dateTimeInput.inputFormat).to.equal(testFormat); - }); + picker.dispatchEvent(escapeEvent); + await elementUpdated(picker); - it('should set displayFormat correctly', async () => { - let testFormat = 'dd-MM-yyyy'; - picker.displayFormat = testFormat; - await elementUpdated(picker); + expect(eventSpy).calledTwice; + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).calledWith('igcClosed'); + eventSpy.resetHistory(); + }); - expect(dateTimeInput.displayFormat).to.equal(testFormat); + it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp', async () => { + const altArrowDownEvent = new KeyboardEvent('keydown', { + key: 'ArrowDown', + altKey: true, + }); + const eventSpy = spy(picker, 'emitEvent'); + expect(picker.open).to.be.false; + picker.focus(); + picker.dispatchEvent(altArrowDownEvent); + await elementUpdated(picker); - // set via attribute - testFormat = 'dd--MM--yyyy'; - picker.setAttribute('display-format', testFormat); - await elementUpdated(picker); + expect(picker.open).to.be.true; + expect(eventSpy).calledWith('igcOpening'); + expect(eventSpy).calledWith('igcOpened'); - expect(dateTimeInput.displayFormat).to.equal(testFormat); - expect(picker.displayFormat).not.to.equal(picker.inputFormat); + eventSpy.resetHistory(); + const altArrowUpEvent = new KeyboardEvent('keydown', { + key: 'ArrowUp', + altKey: true, }); - it('should properly set displayFormat to the set of predefined formats', async () => { - const predefinedFormats = ['short', 'medium', 'long', 'full']; - - for (let i = 0; i < predefinedFormats.length; i++) { - const format = predefinedFormats[i]; - picker.displayFormat = format; - await elementUpdated(picker); + picker.dispatchEvent(altArrowUpEvent); + await elementUpdated(picker); - expect(dateTimeInput.displayFormat).to.equal(format + 'Date'); - } - }); + expect(picker.open).to.be.false; + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).calledWith('igcClosed'); + }); + }); - it('should default inputFormat to whatever Intl.DateTimeFormat returns for the current locale', async () => { - const defaultFormat = 'MM/dd/yyyy'; - expect(picker.locale).to.equal('en'); - expect(picker.inputFormat).to.equal(defaultFormat); + describe('Slotted content', () => { + // TODO + }); - picker.locale = 'fr'; - await elementUpdated(picker); + describe('Localization', () => { + it('should set inputFormat correctly', async () => { + const testFormat = 'dd--MM--yyyy'; + picker.inputFormat = testFormat; + await elementUpdated(picker); - expect(picker.inputFormat).to.equal('dd/MM/yyyy'); - }); + expect(dateTimeInput.inputFormat).to.equal(testFormat); + }); - it('should use the value of inputFormat for displayFormat, if it is not defined', async () => { - expect(picker.locale).to.equal('en'); - expect(picker.getAttribute('display-format')).to.be.null; - expect(picker.displayFormat).to.equal(picker.inputFormat); + it('should set displayFormat correctly', async () => { + let testFormat = 'dd-MM-yyyy'; + picker.displayFormat = testFormat; + await elementUpdated(picker); - // updates inputFormat according to changed locale - picker.locale = 'fr'; - await elementUpdated(picker); - expect(picker.inputFormat).to.equal('dd/MM/yyyy'); - expect(picker.displayFormat).to.equal(picker.inputFormat); + expect(dateTimeInput.displayFormat).to.equal(testFormat); - // sets inputFormat as attribute - picker.setAttribute('input-format', 'dd-MM-yyyy'); - await elementUpdated(picker); + // set via attribute + testFormat = 'dd--MM--yyyy'; + picker.setAttribute('display-format', testFormat); + await elementUpdated(picker); - expect(picker.inputFormat).to.equal('dd-MM-yyyy'); - expect(picker.displayFormat).to.equal(picker.inputFormat); - }); + expect(dateTimeInput.displayFormat).to.equal(testFormat); + expect(picker.displayFormat).not.to.equal(picker.inputFormat); }); - describe('Validation', () => { - it('should set the min and max properties and update validity according to set value', async () => { - expect(picker.min).to.be.undefined; - expect(dateTimeInput.min).to.be.undefined; - expect(picker.max).to.be.undefined; - expect(dateTimeInput.max).to.be.undefined; - expect(picker.invalid).to.be.false; + it('should properly set displayFormat to the set of predefined formats', async () => { + const predefinedFormats = ['short', 'medium', 'long', 'full']; - picker.value = new Date(2024, 2, 18); + for (let i = 0; i < predefinedFormats.length; i++) { + const format = predefinedFormats[i]; + picker.displayFormat = format; await elementUpdated(picker); - picker.min = new Date(2024, 2, 20); - await elementUpdated(picker); + expect(dateTimeInput.displayFormat).to.equal(format + 'Date'); + } + }); - // only changing the min/max property does not set invalid state - updating the value does ? - expect(picker.invalid).to.be.false; - picker.value = new Date(2024, 2, 19); - await elementUpdated(picker); + it('should default inputFormat to whatever Intl.DateTimeFormat returns for the current locale', async () => { + const defaultFormat = 'MM/dd/yyyy'; + expect(picker.locale).to.equal('en'); + expect(picker.inputFormat).to.equal(defaultFormat); - expect(picker.invalid).to.be.true; + picker.locale = 'fr'; + await elementUpdated(picker); - picker.value = new Date(2024, 2, 24); - await elementUpdated(picker); + expect(picker.inputFormat).to.equal('dd/MM/yyyy'); + }); - expect(picker.invalid).to.be.false; + it('should use the value of inputFormat for displayFormat, if it is not defined', async () => { + expect(picker.locale).to.equal('en'); + expect(picker.getAttribute('display-format')).to.be.null; + expect(picker.displayFormat).to.equal(picker.inputFormat); - picker.max = new Date(2024, 2, 22); - await elementUpdated(picker); + // updates inputFormat according to changed locale + picker.locale = 'fr'; + await elementUpdated(picker); + expect(picker.inputFormat).to.equal('dd/MM/yyyy'); + expect(picker.displayFormat).to.equal(picker.inputFormat); - expect(picker.invalid).to.be.false; + // sets inputFormat as attribute + picker.setAttribute('input-format', 'dd-MM-yyyy'); + await elementUpdated(picker); - picker.value = new Date(2024, 2, 23); - await elementUpdated(picker); + expect(picker.inputFormat).to.equal('dd-MM-yyyy'); + expect(picker.displayFormat).to.equal(picker.inputFormat); + }); + }); - expect(picker.invalid).to.be.true; + describe('Form integration', () => { + const spec = new FormAssociatedTestBed( + html`` + ); - picker.value = new Date(2024, 2, 21); - await elementUpdated(picker); + beforeEach(async () => { + await spec.setup(IgcDatepickerComponent.tagName); + }); - expect(picker.invalid).to.be.false; - // TODO check same on typing - untouched/dirty - }); + it('should be form associated', async () => { + expect(spec.element.form).to.equal(spec.form); }); - describe('Form integration', () => { - const spec = new FormAssociatedTestBed( - html`` - ); + it('should not participate in form submission if the value is empty/invalid', async () => { + expect(spec.submit()?.get(spec.element.name)).to.be.null; + }); - beforeEach(async () => { - await spec.setup(IgcDatepickerComponent.tagName); - }); + it('should participate in form submission if there is a value and the value adheres to the validation constraints', async () => { + spec.element.value = new Date(Date.now()); + await elementUpdated(spec.element); - it('is form associated', async () => { - expect(spec.element.form).to.equal(spec.form); - }); + expect(spec.submit()?.get(spec.element.name)).to.equal( + spec.element.value.toISOString() + ); + }); - it('is not associated on submit if no value', async () => { - expect(spec.submit()?.get(spec.element.name)).to.be.null; - }); + it('should reset to its default value state on form reset', async () => { + spec.element.value = new Date(Date.now()); + await elementUpdated(spec.element); + + spec.reset(); + expect(spec.element.value).to.be.undefined; + }); - it('is associated on submit', async () => { - spec.element.value = new Date(Date.now()); - await elementUpdated(spec.element); + it('should reflect disabled ancestor state (fieldset/form)', async () => { + spec.setAncestorDisabledState(true); + expect(spec.element.disabled).to.be.true; - expect(spec.submit()?.get(spec.element.name)).to.equal( - spec.element.value.toISOString() - ); - }); + spec.setAncestorDisabledState(false); + expect(spec.element.disabled).to.be.false; + }); - it('is correctly reset on form reset', async () => { - spec.element.value = new Date(Date.now()); - await elementUpdated(spec.element); + it('should enforce required constraint', async () => { + spec.element.required = true; + await elementUpdated(spec.element); + spec.submitFails(); - spec.reset(); - expect(spec.element.value).to.be.undefined; - }); + spec.element.value = new Date(Date.now()); + await elementUpdated(spec.element); + spec.submitValidates(); + }); - it('reflects disabled ancestor state', async () => { - spec.setAncestorDisabledState(true); - expect(spec.element.disabled).to.be.true; + it('should enforce min value constraint', async () => { + spec.element.min = new Date(2025, 0, 1); + await elementUpdated(spec.element); + spec.submitFails(); - spec.setAncestorDisabledState(false); - expect(spec.element.disabled).to.be.false; - }); + spec.element.value = new Date(2022, 0, 1); + await elementUpdated(spec.element); + spec.submitFails(); - it('fulfils required constraint', async () => { - spec.element.required = true; - await elementUpdated(spec.element); - spec.submitFails(); + spec.element.value = new Date(2025, 0, 2); + await elementUpdated(spec.element); + spec.submitValidates(); + }); - spec.element.value = new Date(Date.now()); - await elementUpdated(spec.element); - spec.submitValidates(); - }); + it('should enforce max value constraint', async () => { + spec.element.max = new Date(2020, 0, 1); + spec.element.value = new Date(Date.now()); + await elementUpdated(spec.element); + spec.submitFails(); - it('fulfils min value constraint', async () => { - spec.element.min = new Date(2025, 0, 1); - await elementUpdated(spec.element); - spec.submitFails(); + spec.element.value = new Date(2020, 0, 1); + await elementUpdated(spec.element); + spec.submitValidates(); + }); - spec.element.value = new Date(2022, 0, 1); - await elementUpdated(spec.element); - spec.submitFails(); + it('should invalidate the component if a disabled date is typed in the input', async () => { + const minDate = new Date(2024, 1, 1); + const maxDate = new Date(2024, 1, 28); - spec.element.value = new Date(2025, 0, 2); - await elementUpdated(spec.element); - spec.submitValidates(); - }); + const disabledDates: DateRangeDescriptor[] = [ + { + type: DateRangeType.Between, + dateRange: [minDate, maxDate], + }, + ]; - it('fulfils max value constraint', async () => { - spec.element.max = new Date(2020, 0, 1); - spec.element.value = new Date(Date.now()); - await elementUpdated(spec.element); - spec.submitFails(); + spec.element.disabledDates = disabledDates; + await elementUpdated(spec.element); - spec.element.value = new Date(2020, 0, 1); - await elementUpdated(spec.element); - spec.submitValidates(); - }); + spec.element.value = new Date(2024, 1, 26); + await elementUpdated(spec.element); - it('fulfils custom constraint', async () => { - spec.element.setCustomValidity('invalid'); - spec.submitFails(); + expect(spec.element.invalid).to.be.true; + spec.submitFails(); + }); - spec.element.setCustomValidity(''); - spec.submitValidates(); - }); + it('should enforce custom constraint', async () => { + spec.element.setCustomValidity('invalid'); + spec.submitFails(); - it('should set a custom validation message with setCustomValidity()', async () => { - // TODO - // As long as message is not empty, the component is considered invalid - }); + spec.element.setCustomValidity(''); + spec.submitValidates(); }); }); }); @@ -643,3 +656,8 @@ const selectCurrentDate = (calendar: IgcCalendarComponent) => { ) as HTMLElement; (currentDaySpan?.children[0] as HTMLElement).click(); }; + +const checkDatesEqual = (a: Date, b: Date) => + expect(new Date(a.setHours(0, 0, 0, 0)).toISOString()).to.equal( + new Date(b.setHours(0, 0, 0, 0)).toISOString() + ); diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 91377cf1d..090a3eda9 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -1,9 +1,13 @@ import { ComplexAttributeConverter, LitElement, html } from 'lit'; import { property, query } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { live } from 'lit/directives/live.js'; import IgcCalendarComponent from '../calendar/calendar.js'; -import { DateRangeDescriptor } from '../calendar/common/calendar.model.js'; +import { + DateRangeDescriptor, + isDateInRanges, +} from '../calendar/common/calendar.model.js'; import { addKeybindings, escapeKey, @@ -105,6 +109,14 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( ) : true, }, + { + key: 'badInput', + message: () => format(messages.disabledDate, `${this.value}`), + isValid: () => + this.value && this.disabledDates + ? !isDateInRanges(this.value, this.disabledDates) + : true, + }, ]; private predefinedDisplayFormatsMap = new Map([ @@ -462,10 +474,12 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @watch('min', { waitUntilFirstUpdate: true }) @watch('max', { waitUntilFirstUpdate: true }) + @watch('disabledDates', { waitUntilFirstUpdate: true }) protected constraintChange() { this.updateValidity(); } + // TODO clear-icon, calendar-icon, calendar-icon-open? slots protected override render() { const id = this.id || this.inputId; const calendarDisabled = !this.open || this.disabled; @@ -486,6 +500,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( .placeholder=${this.placeholder} .min=${this.min} .max=${this.max} + .invalid=${live(this.invalid)} @igcChange=${this.handleInputChangeEvent} @igcInput=${this.handleInputEvent} > @@ -521,7 +536,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( .weekStart=${this.weekStart} @igcChange=${this.handleCalendarChangeEvent} > - + ${this.mode === 'dropdown' + ? html`` + : undefined} diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index 23c3835ab..05b5e79c6 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -6,7 +6,12 @@ import { formControls, formSubmitHandler, } from './story.js'; -import { IgcDatepickerComponent, defineComponents } from '../src/index.js'; +import { + DateRangeDescriptor, + DateRangeType, + IgcDatepickerComponent, + defineComponents, +} from '../src/index.js'; defineComponents(IgcDatepickerComponent); @@ -324,6 +329,7 @@ export const Default: Story = { .headerOrientation=${args.headerOrientation} .nonEditable=${args.nonEditable} .orientation=${args.orientation} + .mode=${args.mode} .min=${args.min ? new Date(args.min as Date) : undefined} .max=${args.max ? new Date(args.max as Date) : undefined} .activeDate=${args.activeDate @@ -372,6 +378,15 @@ export const Slots: Story = { `, }; +const minDate = new Date(2024, 1, 1); +const maxDate = new Date(2024, 1, 28); +const disabledDates: DateRangeDescriptor[] = [ + { + type: DateRangeType.Between, + dateRange: [minDate, maxDate], + }, +]; + export const Form: Story = { argTypes: disableStoryControls(metadata), render: () => html` @@ -381,6 +396,8 @@ export const Form: Story = { + +
+ +
${formControls()} `, From b10cfb02341d8d7785147bac784e4cb88247aa0f Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Feb 2024 10:16:14 +0200 Subject: [PATCH 07/63] refactor: Cleaned some properties Started abstracting date validators --- src/components/common/validators.ts | 8 +- src/components/date-picker/date-picker.ts | 84 ++++++------------- .../date-time-input/date-time-input.ts | 8 +- src/components/mask-input/mask-input.ts | 5 +- stories/datepicker.stories.ts | 20 ++--- 5 files changed, 43 insertions(+), 82 deletions(-) diff --git a/src/components/common/validators.ts b/src/components/common/validators.ts index 6d1d6c08d..b79070488 100644 --- a/src/components/common/validators.ts +++ b/src/components/common/validators.ts @@ -10,9 +10,12 @@ export interface Validator { isValid: ValidatorHandler; } +const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + export const requiredValidator: Validator<{ required: boolean; - value?: string; + value?: unknown; }> = { key: 'valueMissing', message: validatorMessages.required, @@ -103,9 +106,6 @@ export const stepValidator: Validator<{ : true, }; -const emailRegex = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; - export const emailValidator: Validator<{ value: string }> = { key: 'typeMismatch', message: validatorMessages.email, diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 090a3eda9..cbd1ce34b 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -25,7 +25,7 @@ import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; import { createCounter, format } from '../common/util.js'; -import { Validator } from '../common/validators.js'; +import { Validator, requiredValidator } from '../common/validators.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import { DatePart, DateTimeUtil } from '../date-time-input/date-util.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; @@ -45,6 +45,8 @@ const converter: ComplexAttributeConverter = { toAttribute: (value: Date) => value.toISOString(), }; +const formats = new Set(['short', 'medium', 'long', 'full']); + /** * @element igc-datepicker * @@ -78,11 +80,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( // TODO - can the date-time-input's validator's be reused and the picker's validity state be updated ? public override validators: Validator[] = [ - { - key: 'valueMissing', - message: messages.required, - isValid: () => (this.required ? !!this.value : true), - }, + requiredValidator, { key: 'rangeUnderflow', message: () => format(messages.min, `${this.min}`), @@ -119,13 +117,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( }, ]; - private predefinedDisplayFormatsMap = new Map([ - ['short', 'shortDate'], - ['medium', 'mediumDate'], - ['long', 'longDate'], - ['full', 'fullDate'], - ]); - public static register() { registerComponent( this, @@ -136,6 +127,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( ); } + private _displayFormat?: string; + private _inputFormat?: string; + private _rootClickController = addRootClickHandler(this, { hideCallback: () => this._hide(true), }); @@ -143,22 +137,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @query(IgcDateTimeInputComponent.tagName, true) private _input!: IgcDateTimeInputComponent; - @query(IgcCalendarComponent.tagName, true) + @query(IgcCalendarComponent.tagName) private _calendar!: IgcCalendarComponent; - private _displayFormat!: string; - - /** - * Whether the calendar dropdown should be kept open on clicking outside of it. - * @attr keep-open-on-outside-click - */ - @property({ - type: Boolean, - reflect: true, - attribute: 'keep-open-on-outside-click', - }) - public override keepOpenOnOutsideClick = false; - /** * Sets the state of the datepicker dropdown. * @attr @@ -198,15 +179,10 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( * The value of the picker * @attr */ - @property({ - converter: converter, - }) + @property({ converter: converter }) public value?: Date; - @property({ - attribute: 'active-date', - converter: converter, - }) + @property({ attribute: 'active-date', converter: converter }) public get activeDate(): Date { return this._calendar?.activeDate ?? new Date(); } @@ -222,14 +198,14 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( * @attr */ @property({ converter: converter }) - public min?: Date; + public min!: Date; /** * The maximum value required for the date picker to remain valid. * @attr */ @property({ converter: converter }) - public max?: Date; + public max!: Date; /** The orientation of the calendar header. * @attr header-orientation @@ -246,7 +222,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( /** Determines whether the calendar hides its header. * @attr hide-header */ - @property({ attribute: 'hide-header' }) + @property({ type: Boolean, reflect: true, attribute: 'hide-header' }) public hideHeader = false; /** @@ -298,23 +274,12 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( * @attr display-format */ @property({ attribute: 'display-format' }) - public get displayFormat(): string { - return ( - this._displayFormat ?? this._input?.displayFormat ?? this.inputFormat - ); - } - public set displayFormat(value: string) { - if (!value) { - return; - } this._displayFormat = value; - if (this.predefinedDisplayFormatsMap.has(value)) { - value = this.predefinedDisplayFormatsMap.get(value)!; - } - if (this._input) { - this._input.displayFormat = value; - } + } + + public get displayFormat(): string { + return this._displayFormat ?? this.inputFormat; } /** @@ -323,14 +288,12 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( * @attr input-format */ @property({ attribute: 'input-format' }) - public get inputFormat(): string { - return this._input?.inputFormat; + public set inputFormat(value: string) { + this._inputFormat = value; } - public set inputFormat(value: string) { - if (value && this._input) { - this._input.inputFormat = value; - } + public get inputFormat(): string { + return this._inputFormat ?? this._input?.inputFormat; } /** @@ -483,6 +446,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( protected override render() { const id = this.id || this.inputId; const calendarDisabled = !this.open || this.disabled; + const displayFormat = formats.has(this._displayFormat!) + ? `${this._displayFormat}Date` + : this._displayFormat; return html` [] = [ - { - key: 'valueMissing', - message: messages.required, - isValid: () => (this.required ? !!this.value : true), - }, + requiredValidator, { key: 'rangeUnderflow', message: () => format(messages.min, `${this.min}`), diff --git a/src/components/mask-input/mask-input.ts b/src/components/mask-input/mask-input.ts index 277893841..22cc0d9e8 100644 --- a/src/components/mask-input/mask-input.ts +++ b/src/components/mask-input/mask-input.ts @@ -42,10 +42,7 @@ export default class IgcMaskInputComponent extends IgcMaskInputBaseComponent { } protected override validators: Validator[] = [ - { - ...requiredValidator, - isValid: () => (this.required ? !!this._value : true), - }, + requiredValidator, { key: 'badInput', message: messages.mask, diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index 05b5e79c6..ffd696475 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -33,13 +33,6 @@ const metadata: Meta = { }, }, argTypes: { - keepOpenOnOutsideClick: { - type: 'boolean', - description: - 'Whether the calendar dropdown should be kept open on clicking outside of it.', - control: 'boolean', - table: { defaultValue: { summary: false } }, - }, open: { type: 'boolean', description: 'Sets the state of the datepicker dropdown.', @@ -209,9 +202,15 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: false } }, }, + keepOpenOnOutsideClick: { + type: 'boolean', + description: + 'Whether the component dropdown should be kept open on clicking outside of it.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, }, args: { - keepOpenOnOutsideClick: false, open: false, mode: 'dropdown', nonEditable: false, @@ -230,14 +229,13 @@ const metadata: Meta = { disabled: false, invalid: false, keepOpenOnSelect: false, + keepOpenOnOutsideClick: false, }, }; export default metadata; interface IgcDatepickerArgs { - /** Whether the calendar dropdown should be kept open on clicking outside of it. */ - keepOpenOnOutsideClick: boolean; /** Sets the state of the datepicker dropdown. */ open: boolean; /** The label of the datepicker. */ @@ -304,6 +302,8 @@ interface IgcDatepickerArgs { invalid: boolean; /** Whether the component dropdown should be kept open on selection. */ keepOpenOnSelect: boolean; + /** Whether the component dropdown should be kept open on clicking outside of it. */ + keepOpenOnOutsideClick: boolean; } type Story = StoryObj; From 7b8d9bade35a1aa585ceb6732f86ff18fa4f3f0b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Feb 2024 10:57:35 +0200 Subject: [PATCH 08/63] refactor: Datetime validators --- src/components/common/validators.ts | 25 +++++++++++ src/components/date-picker/date-picker.ts | 41 +++++-------------- .../date-time-input/date-time-input.ts | 17 ++++---- 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/src/components/common/validators.ts b/src/components/common/validators.ts index b79070488..aa0d8429d 100644 --- a/src/components/common/validators.ts +++ b/src/components/common/validators.ts @@ -1,5 +1,6 @@ import validatorMessages from './localization/validation-en.js'; import { asNumber, format, isDefined } from './util.js'; +import { DateTimeUtil } from '../date-time-input/date-util.js'; type ValidatorHandler = (host: T) => boolean; type ValidatorMessageFormat = (host: T) => string; @@ -117,3 +118,27 @@ export const urlValidator: Validator<{ value: string }> = { message: validatorMessages.url, isValid: ({ value }) => URL.canParse(value), }; + +export const minDateValidator: Validator<{ + value?: Date | null; + min?: Date | null; +}> = { + key: 'rangeUnderflow', + message: ({ min }) => format(validatorMessages.min, `${min}`), + isValid: ({ value, min }) => + min + ? !DateTimeUtil.lessThanMinValue(value ?? new Date(), min, false, true) + : true, +}; + +export const maxDateValidator: Validator<{ + value?: Date | null; + max?: Date | null; +}> = { + key: 'rangeOverflow', + message: ({ max }) => format(validatorMessages.max, `${max}`), + isValid: ({ value, max }) => + max + ? !DateTimeUtil.greaterThanMaxValue(value ?? new Date(), max, false, true) + : true, +}; diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index cbd1ce34b..89d2fa425 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -25,9 +25,14 @@ import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; import { createCounter, format } from '../common/util.js'; -import { Validator, requiredValidator } from '../common/validators.js'; +import { + Validator, + maxDateValidator, + minDateValidator, + requiredValidator, +} from '../common/validators.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; -import { DatePart, DateTimeUtil } from '../date-time-input/date-util.js'; +import { DatePart } from '../date-time-input/date-util.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcPopoverComponent from '../popover/popover.js'; @@ -81,38 +86,14 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( // TODO - can the date-time-input's validator's be reused and the picker's validity state be updated ? public override validators: Validator[] = [ requiredValidator, - { - key: 'rangeUnderflow', - message: () => format(messages.min, `${this.min}`), - isValid: () => - this.min - ? !DateTimeUtil.lessThanMinValue( - this.value || new Date(), - this.min, - false, - true - ) - : true, - }, - { - key: 'rangeOverflow', - message: () => format(messages.max, `${this.max}`), - isValid: () => - this.max - ? !DateTimeUtil.greaterThanMaxValue( - this.value || new Date(), - this.max, - false, - true - ) - : true, - }, + minDateValidator, + maxDateValidator, { key: 'badInput', message: () => format(messages.disabledDate, `${this.value}`), isValid: () => - this.value && this.disabledDates - ? !isDateInRanges(this.value, this.disabledDates) + this.value + ? !isDateInRanges(this.value, this.disabledDates ?? []) : true, }, ]; diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index dcba40051..290045a0a 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -21,11 +21,15 @@ import { import { blazorTwoWayBind } from '../common/decorators/blazorTwoWayBind.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; -import messages from '../common/localization/validation-en.js'; import { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { format, partNameMap } from '../common/util.js'; -import { Validator, requiredValidator } from '../common/validators.js'; +import { partNameMap } from '../common/util.js'; +import { + Validator, + maxDateValidator, + minDateValidator, + requiredValidator, +} from '../common/validators.js'; import { IgcInputEventMap } from '../input/input-base.js'; import { IgcMaskInputBaseComponent, @@ -78,9 +82,9 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< protected override validators: Validator[] = [ requiredValidator, + { - key: 'rangeUnderflow', - message: () => format(messages.min, `${this.min}`), + ...minDateValidator, isValid: () => this.min ? !DateTimeUtil.lessThanMinValue( @@ -92,8 +96,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< : true, }, { - key: 'rangeOverflow', - message: () => format(messages.max, `${this.max}`), + ...maxDateValidator, isValid: () => this.max ? !DateTimeUtil.greaterThanMaxValue( From d6153b19d1b700c13a9573e7e3c19fef80ad0013 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Tue, 27 Feb 2024 11:26:55 +0200 Subject: [PATCH 09/63] chore(date-picker): regroup tests --- .../date-picker/date-picker.spec.ts | 260 ++++++++++++------ 1 file changed, 173 insertions(+), 87 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 17100ebf0..da36b08bc 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -58,9 +58,113 @@ describe('Date picker', () => { await expect(picker).shadowDom.to.be.accessible(); await expect(picker).lightDom.to.be.accessible(); }); + + it('should render slotted elements - prefix, suffix, clear-icon, calendar-icon(-open), helper-text, title', async () => { + picker = await fixture( + html` + $ + ~ +

For example, select your birthday

+

Custom title

+ v + ^ + X +
` + ); + await elementUpdated(picker); + + dateTimeInput = picker.shadowRoot!.querySelector( + IgcDateTimeInputComponent.tagName + ) as IgcDateTimeInputComponent; + + const slotTests = [ + { + slot: 'prefix', + tagName: 'span', + content: '$', + parent: dateTimeInput, + nestedIn: 'prefix', + }, + { + slot: 'suffix', + tagName: 'span', + content: '~', + parent: dateTimeInput, + nestedIn: 'suffix', + }, + { + slot: 'title', + tagName: 'p', + content: 'Custom title', + parent: picker, + }, + { + slot: 'helper-text', + tagName: 'p', + content: 'For example, select your birthday', + parent: picker, + }, + { + slot: 'calendar-icon', + tagName: 'span', + content: '^', + parent: picker, + }, + { + slot: 'calendar-icon-open', + tagName: 'span', + content: 'v', + prerequisite: () => picker.show(), + parent: picker, + }, + { + slot: 'clear-icon', + tagName: 'span', + content: 'X', + prerequisite: () => (picker.value = new Date()), + parent: picker, + }, + ]; + + for (let i = 0; i < slotTests.length; i++) { + slotTests[i].prerequisite?.(); + await elementUpdated(picker); + + const slot = slotTests[i].parent.shadowRoot!.querySelector( + `slot[name="${slotTests[i].slot}"]` + ) as HTMLSlotElement; + let elements = slot.assignedElements(); + + if (slotTests[i].nestedIn) { + const targetElement = elements.find((el) => + el.matches(`slot[name="${slotTests[i].nestedIn}"]`) + ) as HTMLSlotElement; + elements = targetElement.assignedElements(); + } + + expect((elements[0] as HTMLElement).innerText).to.equal( + slotTests[i].content + ); + expect(elements[0].tagName.toLowerCase()).to.equal( + slotTests[i].tagName + ); + } + }); + + it('should set the mode property correctly', async () => { + // TODO + }); + + it('should be successfully initialized in open state in dropdown mode', async () => { + // TODO + }); + + it('should be successfully initialized in open state in dialog mode', async () => { + // TODO + }); }); - describe('Basic', () => { + describe('Attributes and properties', () => { const currentDate = new Date(new Date().setHours(0, 0, 0)); const tomorrowDate = new Date( new Date().setDate(currentDate.getDate() + 1) @@ -218,18 +322,6 @@ describe('Date picker', () => { } }); - it('should set the mode property correctly', async () => { - // TODO - }); - - it('should be successfully initialized in open state in dropdown mode', async () => { - // TODO - }); - - it('should be successfully initialized in open state in dialog mode', async () => { - // TODO - }); - it('should set properties of the input correctly', async () => { const props = { required: true, @@ -248,7 +340,6 @@ describe('Date picker', () => { }); describe('Active date', () => { - const currentDate = new Date(); const tomorrowDate = new Date( new Date().setDate(currentDate.getDate() + 1) ); @@ -272,7 +363,6 @@ describe('Date picker', () => { html`` ); await elementUpdated(picker); - await elementUpdated(picker); checkDatesEqual(picker.value as Date, valueDate); @@ -301,6 +391,74 @@ describe('Date picker', () => { checkDatesEqual(calendar.activeDate, tomorrowDate); }); }); + + describe('Localization', () => { + it('should set inputFormat correctly', async () => { + const testFormat = 'dd--MM--yyyy'; + picker.inputFormat = testFormat; + await elementUpdated(picker); + + expect(dateTimeInput.inputFormat).to.equal(testFormat); + }); + + it('should set displayFormat correctly', async () => { + let testFormat = 'dd-MM-yyyy'; + picker.displayFormat = testFormat; + await elementUpdated(picker); + + expect(dateTimeInput.displayFormat).to.equal(testFormat); + + // set via attribute + testFormat = 'dd--MM--yyyy'; + picker.setAttribute('display-format', testFormat); + await elementUpdated(picker); + + expect(dateTimeInput.displayFormat).to.equal(testFormat); + expect(picker.displayFormat).not.to.equal(picker.inputFormat); + }); + + it('should properly set displayFormat to the set of predefined formats', async () => { + const predefinedFormats = ['short', 'medium', 'long', 'full']; + + for (let i = 0; i < predefinedFormats.length; i++) { + const format = predefinedFormats[i]; + picker.displayFormat = format; + await elementUpdated(picker); + + expect(dateTimeInput.displayFormat).to.equal(format + 'Date'); + } + }); + + it('should default inputFormat to whatever Intl.DateTimeFormat returns for the current locale', async () => { + const defaultFormat = 'MM/dd/yyyy'; + expect(picker.locale).to.equal('en'); + expect(picker.inputFormat).to.equal(defaultFormat); + + picker.locale = 'fr'; + await elementUpdated(picker); + + expect(picker.inputFormat).to.equal('dd/MM/yyyy'); + }); + + it('should use the value of inputFormat for displayFormat, if it is not defined', async () => { + expect(picker.locale).to.equal('en'); + expect(picker.getAttribute('display-format')).to.be.null; + expect(picker.displayFormat).to.equal(picker.inputFormat); + + // updates inputFormat according to changed locale + picker.locale = 'fr'; + await elementUpdated(picker); + expect(picker.inputFormat).to.equal('dd/MM/yyyy'); + expect(picker.displayFormat).to.equal(picker.inputFormat); + + // sets inputFormat as attribute + picker.setAttribute('input-format', 'dd-MM-yyyy'); + await elementUpdated(picker); + + expect(picker.inputFormat).to.equal('dd-MM-yyyy'); + expect(picker.displayFormat).to.equal(picker.inputFormat); + }); + }); }); describe('Methods', () => { @@ -466,78 +624,6 @@ describe('Date picker', () => { }); }); - describe('Slotted content', () => { - // TODO - }); - - describe('Localization', () => { - it('should set inputFormat correctly', async () => { - const testFormat = 'dd--MM--yyyy'; - picker.inputFormat = testFormat; - await elementUpdated(picker); - - expect(dateTimeInput.inputFormat).to.equal(testFormat); - }); - - it('should set displayFormat correctly', async () => { - let testFormat = 'dd-MM-yyyy'; - picker.displayFormat = testFormat; - await elementUpdated(picker); - - expect(dateTimeInput.displayFormat).to.equal(testFormat); - - // set via attribute - testFormat = 'dd--MM--yyyy'; - picker.setAttribute('display-format', testFormat); - await elementUpdated(picker); - - expect(dateTimeInput.displayFormat).to.equal(testFormat); - expect(picker.displayFormat).not.to.equal(picker.inputFormat); - }); - - it('should properly set displayFormat to the set of predefined formats', async () => { - const predefinedFormats = ['short', 'medium', 'long', 'full']; - - for (let i = 0; i < predefinedFormats.length; i++) { - const format = predefinedFormats[i]; - picker.displayFormat = format; - await elementUpdated(picker); - - expect(dateTimeInput.displayFormat).to.equal(format + 'Date'); - } - }); - - it('should default inputFormat to whatever Intl.DateTimeFormat returns for the current locale', async () => { - const defaultFormat = 'MM/dd/yyyy'; - expect(picker.locale).to.equal('en'); - expect(picker.inputFormat).to.equal(defaultFormat); - - picker.locale = 'fr'; - await elementUpdated(picker); - - expect(picker.inputFormat).to.equal('dd/MM/yyyy'); - }); - - it('should use the value of inputFormat for displayFormat, if it is not defined', async () => { - expect(picker.locale).to.equal('en'); - expect(picker.getAttribute('display-format')).to.be.null; - expect(picker.displayFormat).to.equal(picker.inputFormat); - - // updates inputFormat according to changed locale - picker.locale = 'fr'; - await elementUpdated(picker); - expect(picker.inputFormat).to.equal('dd/MM/yyyy'); - expect(picker.displayFormat).to.equal(picker.inputFormat); - - // sets inputFormat as attribute - picker.setAttribute('input-format', 'dd-MM-yyyy'); - await elementUpdated(picker); - - expect(picker.inputFormat).to.equal('dd-MM-yyyy'); - expect(picker.displayFormat).to.equal(picker.inputFormat); - }); - }); - describe('Form integration', () => { const spec = new FormAssociatedTestBed( html`` From f5447dce4d55533903cf08cf33eda512fbb2022c Mon Sep 17 00:00:00 2001 From: ddaribo Date: Tue, 27 Feb 2024 11:30:01 +0200 Subject: [PATCH 10/63] feat(date-picker): add more slots --- src/components/date-picker/date-picker.ts | 53 +++++++++++++++++++++-- stories/datepicker.stories.ts | 3 ++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 89d2fa425..9ac3ffb0c 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -1,5 +1,5 @@ import { ComplexAttributeConverter, LitElement, html } from 'lit'; -import { property, query } from 'lit/decorators.js'; +import { property, query, queryAssignedElements } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; @@ -59,6 +59,9 @@ const formats = new Set(['short', 'medium', 'long', 'full']); * @slot suffix - Renders content after the input. * @slot helper-text - Renders content below the input. * @slot title - Renders content in the calendar title. + * @slot clear-icon - Renders a clear icon template. + * @slot calendar-icon - Renders the icon/content for the calendar picker. + * @slot calendar-icon-open - Renders the icon/content for the picker in open state. * * @fires igcOpening - Emitted just before the calendar dropdown is shown. * @fires igcOpened - Emitted after the calendar dropdown is shown. @@ -121,6 +124,15 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @query(IgcCalendarComponent.tagName) private _calendar!: IgcCalendarComponent; + @queryAssignedElements({ slot: 'calendar-icon' }) + protected calendarIcon!: Array; + + @queryAssignedElements({ slot: 'calendar-icon-open' }) + protected calendarIconOpen!: Array; + + @queryAssignedElements({ slot: 'clear-icon' }) + protected clearIcon!: Array; + /** * Sets the state of the datepicker dropdown. * @attr @@ -423,7 +435,42 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this.updateValidity(); } - // TODO clear-icon, calendar-icon, calendar-icon-open? slots + private renderClearIcon() { + return ( + this.value && + html` + + + + ` + ); + } + + private renderCalendarIcon() { + const defaultIcon = '📅'; + return html` + ${this.open + ? html`${defaultIcon}` + : html`${defaultIcon}`} + `; + } + protected override render() { const id = this.id || this.inputId; const calendarDisabled = !this.open || this.disabled; @@ -454,7 +501,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @igcInput=${this.handleInputEvent} > - 📅 + ${this.renderClearIcon()} ${this.renderCalendarIcon()}
diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index ffd696475..a77b09614 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -373,6 +373,9 @@ export const Slots: Story = { 🦀

For example, select your birthday

🎉 Custom title 🎉

+ 👩‍💻 + 👨‍💻 + 🗑️
`, From 0b64554833d1bf894031a50f5701038aa8c7dc44 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Tue, 27 Feb 2024 13:59:28 +0200 Subject: [PATCH 11/63] fix: address failing Aria test for dropdown mode --- src/components/date-picker/date-picker.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 9ac3ffb0c..6bdcb1031 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -133,6 +133,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @queryAssignedElements({ slot: 'clear-icon' }) protected clearIcon!: Array; + @queryAssignedElements({ slot: 'title' }) + protected titleSlot!: Array; + /** * Sets the state of the datepicker dropdown. * @attr @@ -519,7 +522,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( aria-labelledby=${id} role="dialog" .inert=${!this.open || this.disabled} - .hideHeader=${this.hideHeader} + .hideHeader=${(this.mode === 'dialog' && this.hideHeader) || + (this.mode === 'dropdown' && this.titleSlot!.length === 0)} .headerOrientation=${this.headerOrientation} .orientation=${this.orientation} ?show-week-numbers=${this.showWeekNumbers} @@ -532,9 +536,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( .weekStart=${this.weekStart} @igcChange=${this.handleCalendarChangeEvent} > - ${this.mode === 'dropdown' - ? html`` - : undefined} + ${this.mode === 'dropdown' && + html``} From 739aa01eabc1ed040e803cf1d335fdbba053507a Mon Sep 17 00:00:00 2001 From: ddaribo Date: Wed, 28 Feb 2024 15:25:26 +0200 Subject: [PATCH 12/63] feat(date-picker): refactor + dialog mode initial implementation --- .../date-picker/date-picker.spec.ts | 130 +++++++++++++---- src/components/date-picker/date-picker.ts | 134 +++++++++++------- 2 files changed, 180 insertions(+), 84 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index da36b08bc..917e0b587 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -8,8 +8,18 @@ import { DateRangeType, } from '../calendar/common/calendar.model.js'; import IgcDaysViewComponent from '../calendar/days-view/days-view.js'; +import { + altKey, + arrowDown, + arrowUp, + escapeKey, +} from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; -import { FormAssociatedTestBed } from '../common/utils.spec.js'; +import { + FormAssociatedTestBed, + simulateClick, + simulateKeyboard, +} from '../common/utils.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; describe('Date picker', () => { @@ -151,16 +161,40 @@ describe('Date picker', () => { } }); - it('should set the mode property correctly', async () => { - // TODO - }); - it('should be successfully initialized in open state in dropdown mode', async () => { - // TODO + picker = await fixture( + html`` + ); + calendar = picker.shadowRoot!.querySelector( + IgcCalendarComponent.tagName + ) as IgcCalendarComponent; + + expect(picker.mode).to.equal('dropdown'); + picker.show(); + await elementUpdated(picker); + + const popover = picker.shadowRoot?.querySelector('igc-popover'); + expect(popover).not.to.be.undefined; + expect(calendar).not.to.be.undefined; + expect(calendar.parentElement).to.have.tagName('igc-focus-trap'); }); it('should be successfully initialized in open state in dialog mode', async () => { - // TODO + picker = await fixture( + html`` + ); + calendar = picker.shadowRoot!.querySelector( + IgcCalendarComponent.tagName + ) as IgcCalendarComponent; + + expect(picker.mode).to.equal('dialog'); + picker.show(); + await elementUpdated(picker); + + const dialog = picker.shadowRoot?.querySelector('igc-dialog'); + expect(dialog).not.to.be.undefined; + expect(calendar).not.to.be.undefined; + expect(calendar.parentElement).to.equal(dialog); }); }); @@ -222,7 +256,7 @@ describe('Date picker', () => { picker.show(); await elementUpdated(picker); - document.body.click(); + simulateClick(document.body); await elementUpdated(picker); expect(picker.open).to.equal(true); @@ -247,7 +281,7 @@ describe('Date picker', () => { checkDatesEqual(picker.value as Date, currentDate); eventSpy.resetHistory(); - dateTimeInput.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })); + simulateKeyboard(dateTimeInput, '1'); await elementUpdated(picker); expect(eventSpy).not.called; @@ -265,15 +299,18 @@ describe('Date picker', () => { await elementUpdated(picker); const eventSpy = spy(picker, 'emitEvent'); + const calendarEventSpy = spy(calendar, 'emitEvent'); selectCurrentDate(calendar); await elementUpdated(picker); expect(eventSpy).not.calledWith('igcChange'); + expect(calendarEventSpy).calledWith('igcChange'); checkDatesEqual(picker.value as Date, tomorrowDate); + checkDatesEqual(calendar.value as Date, tomorrowDate); eventSpy.resetHistory(); - dateTimeInput.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })); + simulateKeyboard(dateTimeInput, '1'); await elementUpdated(picker); expect(eventSpy).not.called; @@ -304,6 +341,7 @@ describe('Date picker', () => { }; //test defaults + expect(picker.value).to.be.null; expect(picker.weekStart).to.equal('sunday'); expect(picker.hideOutsideDays).to.equal(false); expect(picker.hideHeader).to.equal(false); @@ -352,7 +390,7 @@ describe('Date picker', () => { it('should initialize activeDate with current date, when not set', async () => { checkDatesEqual(picker.activeDate, currentDate); - expect(picker.value).to.be.undefined; + expect(picker.value).to.be.null; checkDatesEqual(calendar.activeDate, currentDate); expect(calendar.value).to.be.undefined; }); @@ -381,8 +419,8 @@ describe('Date picker', () => { await elementUpdated(picker); checkDatesEqual(calendar.activeDate, tomorrowDate); - // value is not defined - expect(picker.value).to.be.undefined; + // value is null + expect(picker.value).to.be.null; // setting the value does not affect the activeDate, when it is explicitly set picker.value = after20DaysDate; @@ -507,7 +545,7 @@ describe('Date picker', () => { picker.clear(); await elementUpdated(picker); - expect(picker.value).to.be.undefined; + expect(picker.value).to.be.null; expect(dateTimeInput.value).to.be.null; }); @@ -574,10 +612,9 @@ describe('Date picker', () => { describe('Interactions', () => { it('should close the picker when in open state on pressing Escape', async () => { - const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); const eventSpy = spy(picker, 'emitEvent'); picker.focus(); - picker.dispatchEvent(escapeEvent); + simulateKeyboard(picker, escapeKey); await elementUpdated(picker); expect(eventSpy).not.called; @@ -585,37 +622,70 @@ describe('Date picker', () => { picker.show(); await elementUpdated(picker); - picker.dispatchEvent(escapeEvent); + simulateKeyboard(picker, escapeKey); await elementUpdated(picker); expect(eventSpy).calledTwice; expect(eventSpy).calledWith('igcClosing'); expect(eventSpy).calledWith('igcClosed'); eventSpy.resetHistory(); + + // dialog mode + picker.mode = 'dialog'; + await elementUpdated(picker); + picker.show(); + await elementUpdated(picker); + + simulateKeyboard(picker, escapeKey); + await elementUpdated(picker); + + expect(eventSpy).calledTwice; + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).calledWith('igcClosed'); }); - it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp', async () => { - const altArrowDownEvent = new KeyboardEvent('keydown', { - key: 'ArrowDown', - altKey: true, - }); + it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp - dropdown mode', async () => { const eventSpy = spy(picker, 'emitEvent'); expect(picker.open).to.be.false; picker.focus(); - picker.dispatchEvent(altArrowDownEvent); + simulateKeyboard(picker, [altKey, arrowDown]); await elementUpdated(picker); expect(picker.open).to.be.true; + expect(eventSpy).calledWith('igcOpening'); expect(eventSpy).calledWith('igcOpened'); eventSpy.resetHistory(); - const altArrowUpEvent = new KeyboardEvent('keydown', { - key: 'ArrowUp', - altKey: true, - }); - picker.dispatchEvent(altArrowUpEvent); + simulateKeyboard(picker, [altKey, arrowUp]); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).calledWith('igcClosed'); + eventSpy.resetHistory(); + }); + + it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp - dialog mode', async () => { + const eventSpy = spy(picker, 'emitEvent'); + expect(picker.open).to.be.false; + picker.focus(); + picker.mode = 'dialog'; + await elementUpdated(picker); + + simulateKeyboard(picker, [altKey, arrowDown]); + await elementUpdated(picker); + + const dialog = picker.shadowRoot!.querySelector('igc-dialog'); + expect(picker.open).to.be.true; + expect(dialog).not.to.be.undefined; + expect(dialog?.open).to.be.true; + expect(eventSpy).calledWith('igcOpening'); + expect(eventSpy).calledWith('igcOpened'); + eventSpy.resetHistory(); + + simulateKeyboard(picker, [altKey, arrowUp]); await elementUpdated(picker); expect(picker.open).to.be.false; @@ -655,7 +725,7 @@ describe('Date picker', () => { await elementUpdated(spec.element); spec.reset(); - expect(spec.element.value).to.be.undefined; + expect(spec.element.value).to.be.null; }); it('should reflect disabled ancestor state (fieldset/form)', async () => { @@ -740,7 +810,7 @@ const selectCurrentDate = (calendar: IgcCalendarComponent) => { const currentDaySpan = daysView.shadowRoot?.querySelector( 'span[part~="current"]' ) as HTMLElement; - (currentDaySpan?.children[0] as HTMLElement).click(); + simulateClick(currentDaySpan?.children[0]); }; const checkDatesEqual = (a: Date, b: Date) => diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 6bdcb1031..9a76bf96b 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -33,6 +33,7 @@ import { } from '../common/validators.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import { DatePart } from '../date-time-input/date-util.js'; +import IgcDialogComponent from '../dialog/dialog.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcPopoverComponent from '../popover/popover.js'; @@ -86,7 +87,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private static readonly increment = createCounter(); protected inputId = `datepicker-${IgcDatepickerComponent.increment()}`; - // TODO - can the date-time-input's validator's be reused and the picker's validity state be updated ? public override validators: Validator[] = [ requiredValidator, minDateValidator, @@ -107,10 +107,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( IgcCalendarComponent, IgcDateTimeInputComponent, IgcFocusTrapComponent, - IgcPopoverComponent + IgcPopoverComponent, + IgcDialogComponent ); } + private _value?: Date | null; + private _activeDate?: Date | null; private _displayFormat?: string; private _inputFormat?: string; @@ -176,17 +179,26 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( * @attr */ @property({ converter: converter }) - public value?: Date; + public set value(value: Date | null) { + this._value = value; + this.value + ? this.setFormValue(this.value.toISOString()) + : this.setFormValue(null); + this.updateValidity(); + this.setInvalidState(); + } - @property({ attribute: 'active-date', converter: converter }) - public get activeDate(): Date { - return this._calendar?.activeDate ?? new Date(); + public get value(): Date | null { + return this._value ?? null; } + @property({ attribute: 'active-date', converter: converter }) public set activeDate(value: Date) { - if (this._calendar) { - this._calendar.activeDate = value ?? undefined; - } + this._activeDate = value; + } + + public get activeDate(): Date { + return this._activeDate ?? this._calendar?.activeDate; } /** @@ -337,7 +349,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( /** Clears the input part of the component of any user input */ public clear() { - this.value = undefined; + this.value = null; this._input?.clear(); } @@ -401,6 +413,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( if (this.readOnly) { event.preventDefault(); + this._calendar.value = this.value ?? undefined; return; } @@ -422,15 +435,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this.emitEvent('igcInput', { detail: this.value }); } - @watch('value') - protected valueChange() { - this.value - ? this.setFormValue(this.value.toISOString()) - : this.setFormValue(null); - this.updateValidity(); - this.setInvalidState(); - } - @watch('min', { waitUntilFirstUpdate: true }) @watch('max', { waitUntilFirstUpdate: true }) @watch('disabledDates', { waitUntilFirstUpdate: true }) @@ -474,9 +478,63 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( `; } + private renderCalendar(id: string) { + const calendarDisabled = !this.open || this.disabled; + const role = this.mode === 'dropdown' ? 'dialog' : ''; + const hideHeader = + (this.mode === 'dialog' && this.hideHeader) || + (this.mode === 'dropdown' && this.titleSlot!.length === 0); + + const calendar = html` + ${this.mode === 'dropdown' && + html``} + `; + + if (this.mode === 'dropdown') { + return html` + + ${calendar} + + `; + } else { + return html` this._hide(true)} + > + ${calendar} + `; + } + } + protected override render() { const id = this.id || this.inputId; - const calendarDisabled = !this.open || this.disabled; const displayFormat = formats.has(this._displayFormat!) ? `${this._displayFormat}Date` : this._displayFormat; @@ -492,7 +550,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( aria-expanded=${this.open ? 'true' : 'false'} input-format=${ifDefined(this._inputFormat)} display-format=${ifDefined(displayFormat)} - .value=${this.value ?? null} + .value=${this.value} .locale=${this.locale} .prompt=${this.prompt} .outlined=${this.outlined} @@ -508,39 +566,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin(
- - - - - ${this.mode === 'dropdown' && - html``} - - - + ${this.renderCalendar(id)} `; } } From 4409d5495209628b2fef2402803761c07b9ca198 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Wed, 28 Feb 2024 15:49:22 +0200 Subject: [PATCH 13/63] feat(date-picker): refactor + dialog mode initial implementation --- .../date-picker/date-picker.spec.ts | 130 +++++++++++++---- src/components/date-picker/date-picker.ts | 132 +++++++++++------- 2 files changed, 179 insertions(+), 83 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index da36b08bc..917e0b587 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -8,8 +8,18 @@ import { DateRangeType, } from '../calendar/common/calendar.model.js'; import IgcDaysViewComponent from '../calendar/days-view/days-view.js'; +import { + altKey, + arrowDown, + arrowUp, + escapeKey, +} from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; -import { FormAssociatedTestBed } from '../common/utils.spec.js'; +import { + FormAssociatedTestBed, + simulateClick, + simulateKeyboard, +} from '../common/utils.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; describe('Date picker', () => { @@ -151,16 +161,40 @@ describe('Date picker', () => { } }); - it('should set the mode property correctly', async () => { - // TODO - }); - it('should be successfully initialized in open state in dropdown mode', async () => { - // TODO + picker = await fixture( + html`` + ); + calendar = picker.shadowRoot!.querySelector( + IgcCalendarComponent.tagName + ) as IgcCalendarComponent; + + expect(picker.mode).to.equal('dropdown'); + picker.show(); + await elementUpdated(picker); + + const popover = picker.shadowRoot?.querySelector('igc-popover'); + expect(popover).not.to.be.undefined; + expect(calendar).not.to.be.undefined; + expect(calendar.parentElement).to.have.tagName('igc-focus-trap'); }); it('should be successfully initialized in open state in dialog mode', async () => { - // TODO + picker = await fixture( + html`` + ); + calendar = picker.shadowRoot!.querySelector( + IgcCalendarComponent.tagName + ) as IgcCalendarComponent; + + expect(picker.mode).to.equal('dialog'); + picker.show(); + await elementUpdated(picker); + + const dialog = picker.shadowRoot?.querySelector('igc-dialog'); + expect(dialog).not.to.be.undefined; + expect(calendar).not.to.be.undefined; + expect(calendar.parentElement).to.equal(dialog); }); }); @@ -222,7 +256,7 @@ describe('Date picker', () => { picker.show(); await elementUpdated(picker); - document.body.click(); + simulateClick(document.body); await elementUpdated(picker); expect(picker.open).to.equal(true); @@ -247,7 +281,7 @@ describe('Date picker', () => { checkDatesEqual(picker.value as Date, currentDate); eventSpy.resetHistory(); - dateTimeInput.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })); + simulateKeyboard(dateTimeInput, '1'); await elementUpdated(picker); expect(eventSpy).not.called; @@ -265,15 +299,18 @@ describe('Date picker', () => { await elementUpdated(picker); const eventSpy = spy(picker, 'emitEvent'); + const calendarEventSpy = spy(calendar, 'emitEvent'); selectCurrentDate(calendar); await elementUpdated(picker); expect(eventSpy).not.calledWith('igcChange'); + expect(calendarEventSpy).calledWith('igcChange'); checkDatesEqual(picker.value as Date, tomorrowDate); + checkDatesEqual(calendar.value as Date, tomorrowDate); eventSpy.resetHistory(); - dateTimeInput.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })); + simulateKeyboard(dateTimeInput, '1'); await elementUpdated(picker); expect(eventSpy).not.called; @@ -304,6 +341,7 @@ describe('Date picker', () => { }; //test defaults + expect(picker.value).to.be.null; expect(picker.weekStart).to.equal('sunday'); expect(picker.hideOutsideDays).to.equal(false); expect(picker.hideHeader).to.equal(false); @@ -352,7 +390,7 @@ describe('Date picker', () => { it('should initialize activeDate with current date, when not set', async () => { checkDatesEqual(picker.activeDate, currentDate); - expect(picker.value).to.be.undefined; + expect(picker.value).to.be.null; checkDatesEqual(calendar.activeDate, currentDate); expect(calendar.value).to.be.undefined; }); @@ -381,8 +419,8 @@ describe('Date picker', () => { await elementUpdated(picker); checkDatesEqual(calendar.activeDate, tomorrowDate); - // value is not defined - expect(picker.value).to.be.undefined; + // value is null + expect(picker.value).to.be.null; // setting the value does not affect the activeDate, when it is explicitly set picker.value = after20DaysDate; @@ -507,7 +545,7 @@ describe('Date picker', () => { picker.clear(); await elementUpdated(picker); - expect(picker.value).to.be.undefined; + expect(picker.value).to.be.null; expect(dateTimeInput.value).to.be.null; }); @@ -574,10 +612,9 @@ describe('Date picker', () => { describe('Interactions', () => { it('should close the picker when in open state on pressing Escape', async () => { - const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); const eventSpy = spy(picker, 'emitEvent'); picker.focus(); - picker.dispatchEvent(escapeEvent); + simulateKeyboard(picker, escapeKey); await elementUpdated(picker); expect(eventSpy).not.called; @@ -585,37 +622,70 @@ describe('Date picker', () => { picker.show(); await elementUpdated(picker); - picker.dispatchEvent(escapeEvent); + simulateKeyboard(picker, escapeKey); await elementUpdated(picker); expect(eventSpy).calledTwice; expect(eventSpy).calledWith('igcClosing'); expect(eventSpy).calledWith('igcClosed'); eventSpy.resetHistory(); + + // dialog mode + picker.mode = 'dialog'; + await elementUpdated(picker); + picker.show(); + await elementUpdated(picker); + + simulateKeyboard(picker, escapeKey); + await elementUpdated(picker); + + expect(eventSpy).calledTwice; + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).calledWith('igcClosed'); }); - it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp', async () => { - const altArrowDownEvent = new KeyboardEvent('keydown', { - key: 'ArrowDown', - altKey: true, - }); + it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp - dropdown mode', async () => { const eventSpy = spy(picker, 'emitEvent'); expect(picker.open).to.be.false; picker.focus(); - picker.dispatchEvent(altArrowDownEvent); + simulateKeyboard(picker, [altKey, arrowDown]); await elementUpdated(picker); expect(picker.open).to.be.true; + expect(eventSpy).calledWith('igcOpening'); expect(eventSpy).calledWith('igcOpened'); eventSpy.resetHistory(); - const altArrowUpEvent = new KeyboardEvent('keydown', { - key: 'ArrowUp', - altKey: true, - }); - picker.dispatchEvent(altArrowUpEvent); + simulateKeyboard(picker, [altKey, arrowUp]); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).calledWith('igcClosed'); + eventSpy.resetHistory(); + }); + + it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp - dialog mode', async () => { + const eventSpy = spy(picker, 'emitEvent'); + expect(picker.open).to.be.false; + picker.focus(); + picker.mode = 'dialog'; + await elementUpdated(picker); + + simulateKeyboard(picker, [altKey, arrowDown]); + await elementUpdated(picker); + + const dialog = picker.shadowRoot!.querySelector('igc-dialog'); + expect(picker.open).to.be.true; + expect(dialog).not.to.be.undefined; + expect(dialog?.open).to.be.true; + expect(eventSpy).calledWith('igcOpening'); + expect(eventSpy).calledWith('igcOpened'); + eventSpy.resetHistory(); + + simulateKeyboard(picker, [altKey, arrowUp]); await elementUpdated(picker); expect(picker.open).to.be.false; @@ -655,7 +725,7 @@ describe('Date picker', () => { await elementUpdated(spec.element); spec.reset(); - expect(spec.element.value).to.be.undefined; + expect(spec.element.value).to.be.null; }); it('should reflect disabled ancestor state (fieldset/form)', async () => { @@ -740,7 +810,7 @@ const selectCurrentDate = (calendar: IgcCalendarComponent) => { const currentDaySpan = daysView.shadowRoot?.querySelector( 'span[part~="current"]' ) as HTMLElement; - (currentDaySpan?.children[0] as HTMLElement).click(); + simulateClick(currentDaySpan?.children[0]); }; const checkDatesEqual = (a: Date, b: Date) => diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 6bdcb1031..4ae9d12d8 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -33,6 +33,7 @@ import { } from '../common/validators.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import { DatePart } from '../date-time-input/date-util.js'; +import IgcDialogComponent from '../dialog/dialog.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcPopoverComponent from '../popover/popover.js'; @@ -86,7 +87,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private static readonly increment = createCounter(); protected inputId = `datepicker-${IgcDatepickerComponent.increment()}`; - // TODO - can the date-time-input's validator's be reused and the picker's validity state be updated ? public override validators: Validator[] = [ requiredValidator, minDateValidator, @@ -107,10 +107,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( IgcCalendarComponent, IgcDateTimeInputComponent, IgcFocusTrapComponent, - IgcPopoverComponent + IgcPopoverComponent, + IgcDialogComponent ); } + private _value?: Date | null; + private _activeDate?: Date | null; private _displayFormat?: string; private _inputFormat?: string; @@ -176,17 +179,26 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( * @attr */ @property({ converter: converter }) - public value?: Date; + public set value(value: Date | null) { + this._value = value; + this.value + ? this.setFormValue(this.value.toISOString()) + : this.setFormValue(null); + this.updateValidity(); + this.setInvalidState(); + } - @property({ attribute: 'active-date', converter: converter }) - public get activeDate(): Date { - return this._calendar?.activeDate ?? new Date(); + public get value(): Date | null { + return this._value ?? null; } + @property({ attribute: 'active-date', converter: converter }) public set activeDate(value: Date) { - if (this._calendar) { - this._calendar.activeDate = value ?? undefined; - } + this._activeDate = value; + } + + public get activeDate(): Date { + return this._activeDate ?? this._calendar?.activeDate; } /** @@ -337,7 +349,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( /** Clears the input part of the component of any user input */ public clear() { - this.value = undefined; + this.value = null; this._input?.clear(); } @@ -401,6 +413,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( if (this.readOnly) { event.preventDefault(); + this._calendar.value = this.value ?? undefined; return; } @@ -422,15 +435,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this.emitEvent('igcInput', { detail: this.value }); } - @watch('value') - protected valueChange() { - this.value - ? this.setFormValue(this.value.toISOString()) - : this.setFormValue(null); - this.updateValidity(); - this.setInvalidState(); - } - @watch('min', { waitUntilFirstUpdate: true }) @watch('max', { waitUntilFirstUpdate: true }) @watch('disabledDates', { waitUntilFirstUpdate: true }) @@ -474,9 +478,63 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( `; } + private renderCalendar(id: string) { + const calendarDisabled = !this.open || this.disabled; + const role = this.mode === 'dropdown' ? 'dialog' : undefined; + const hideHeader = + (this.mode === 'dialog' && this.hideHeader) || + (this.mode === 'dropdown' && this.titleSlot!.length === 0); + + const calendar = html` + ${this.mode === 'dropdown' && + html``} + `; + + if (this.mode === 'dropdown') { + return html` + + ${calendar} + + `; + } else { + return html` this._hide(true)} + > + ${calendar} + `; + } + } + protected override render() { const id = this.id || this.inputId; - const calendarDisabled = !this.open || this.disabled; const displayFormat = formats.has(this._displayFormat!) ? `${this._displayFormat}Date` : this._displayFormat; @@ -508,39 +566,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( - - - - - ${this.mode === 'dropdown' && - html``} - - - + ${this.renderCalendar(id)} `; } } From e763bcc93de8aa0d4fc3e30c9be459d43ec37f27 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 29 Feb 2024 10:40:08 +0200 Subject: [PATCH 14/63] fix(date-input): Do not emit change event when readonly --- src/components/date-time-input/date-time-input.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 290045a0a..9c11098a0 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -689,7 +689,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< this.updateMask(); } - if (this._oldValue !== this.value) { + if (!this.readOnly && this._oldValue !== this.value) { this.handleChange(); } this.checkValidity(); From 83bb4073167216ed9ce3b9770af7d556d654d78c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 29 Feb 2024 11:59:44 +0200 Subject: [PATCH 15/63] feat: Added keyboard shortcuts for showing/hiding the picker --- src/components/common/util.ts | 4 ++-- src/components/date-picker/date-picker.ts | 8 +++++++- src/components/date-time-input/date-time-input.ts | 9 +++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 2351170c5..2befd4c0d 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -8,6 +8,8 @@ export const partNameMap = (partNameInfo: PartNameInfo) => { .join(' '); }; +export function noop() {} + export const asPercent = (part: number, whole: number) => (part / whole) * 100; export const clamp = (number: number, min: number, max: number) => @@ -153,8 +155,6 @@ export function* iterNodesShadow( ); let node = iter.nextNode() as T; - // XXX: What about slotted content hmm? - while (node) { if (isElement(node) && node.shadowRoot) { yield* iterNodesShadow(node.shadowRoot, whatToShow, filter); diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 9c5c18ae3..b16f5ed29 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -10,6 +10,9 @@ import { } from '../calendar/common/calendar.model.js'; import { addKeybindings, + altKey, + arrowDown, + arrowUp, escapeKey, } from '../common/controllers/key-bindings.js'; import { addRootClickHandler } from '../common/controllers/root-click.js'; @@ -344,7 +347,10 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( addKeybindings(this, { skip: () => this.disabled, bindingDefaults: { preventDefault: true }, - }).set(escapeKey, this.onEscapeKey); + }) + .set([altKey, arrowDown], this._show.bind(this, true)) + .set([altKey, arrowUp], this.onEscapeKey) + .set(escapeKey, this.onEscapeKey); } /** Clears the input part of the component of any user input */ diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 9c11098a0..3769a7a78 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -12,6 +12,7 @@ import { } from './date-util.js'; import { addKeybindings, + altKey, arrowDown, arrowLeft, arrowRight, @@ -23,7 +24,7 @@ import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { partNameMap } from '../common/util.js'; +import { noop, partNameMap } from '../common/util.js'; import { Validator, maxDateValidator, @@ -346,8 +347,12 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< addKeybindings(this, { skip: () => this.readOnly, - bindingDefaults: { preventDefault: true }, + bindingDefaults: { preventDefault: true, triggers: ['keydownRepeat'] }, }) + // Skip default spin when in the context of a date picker + .set([altKey, arrowUp], noop) + .set([altKey, arrowDown], noop) + .set([ctrlKey, ';'], this.setToday) .set(arrowUp, this.keyboardSpin.bind(this, 'up')) .set(arrowDown, this.keyboardSpin.bind(this, 'down')) From c77eebafe592eaa3cf08417d0a86c1d0a50e874f Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 29 Feb 2024 12:30:07 +0200 Subject: [PATCH 16/63] fix: Initial validity status --- src/components/date-picker/date-picker.ts | 5 +++++ stories/datepicker.stories.ts | 24 +++++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index b16f5ed29..713dfb27d 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -353,6 +353,11 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( .set(escapeKey, this.onEscapeKey); } + public override connectedCallback() { + super.connectedCallback(); + this.updateValidity(); + } + /** Clears the input part of the component of any user input */ public clear() { this.value = null; diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index a77b09614..ae7a11811 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -392,15 +392,17 @@ const disabledDates: DateRangeDescriptor[] = [ export const Form: Story = { argTypes: disableStoryControls(metadata), - render: () => html` + args: { + value: new Date(2024, 1, 29), + }, + render: (args) => html`
+
+ +

+ Choose a date after ${minDate.toLocaleDateString()} +

+
+ + +

+ Choose a date before ${maxDate.toLocaleDateString()} +

+
+
+
From b10bd6eb0e44fe13701b85dbf6f60a347789ce2e Mon Sep 17 00:00:00 2001 From: ddaribo Date: Thu, 29 Feb 2024 15:36:22 +0200 Subject: [PATCH 17/63] fix: update picker value according to input on setRangeText --- .../date-picker/date-picker.spec.ts | 19 +++++++++++++++++-- src/components/date-picker/date-picker.ts | 3 +-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 917e0b587..6fa6ee87a 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -161,6 +161,21 @@ describe('Date picker', () => { } }); + it('should be successfully initialized with value', async () => { + picker = await fixture( + html`` + ); + dateTimeInput = picker.shadowRoot!.querySelector( + IgcDateTimeInputComponent.tagName + ) as IgcDateTimeInputComponent; + + const expectedValue = new Date(2024, 1, 29); + expect(picker.value).not.to.be.null; + checkDatesEqual(picker.value!, expectedValue); + expect(dateTimeInput.value).not.to.be.null; + checkDatesEqual(dateTimeInput.value!, expectedValue); + }); + it('should be successfully initialized in open state in dropdown mode', async () => { picker = await fixture( html`` @@ -605,8 +620,8 @@ describe('Date picker', () => { expect(setRangeTextSpy).to.be.called; checkDatesEqual(new Date(input.value), expectedValue); - expect(picker.value).to.eq(expectedValue); - expect(dateTimeInput.value).to.eq(expectedValue); + checkDatesEqual(picker.value!, expectedValue); + checkDatesEqual(dateTimeInput.value!, expectedValue); }); }); diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 713dfb27d..6e1fcf6d5 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -395,9 +395,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( end: number, mode?: 'select' | 'start' | 'end' | 'preserve' ): void { - // currently does not work, would depend on the fix of this issue: - // https://github.com/IgniteUI/igniteui-webcomponents/issues/1075 this._input.setRangeText(replacement, start, end, mode); + this.value = this._input.value; } protected async onEscapeKey() { From e539c2008db5699687644bd64a2d5ef80817dfd7 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 29 Feb 2024 15:44:36 +0200 Subject: [PATCH 18/63] test(date-input): Added tests for new behaviors --- .../date-time-input/date-time-input.spec.ts | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/components/date-time-input/date-time-input.spec.ts b/src/components/date-time-input/date-time-input.spec.ts index bbe79b7f9..84f092361 100644 --- a/src/components/date-time-input/date-time-input.spec.ts +++ b/src/components/date-time-input/date-time-input.spec.ts @@ -10,6 +10,7 @@ import { spy } from 'sinon'; import IgcDateTimeInputComponent from './date-time-input.js'; import { DatePart, DatePartDeltas, DateTimeUtil } from './date-util.js'; import { + altKey, arrowDown, arrowLeft, arrowRight, @@ -467,17 +468,58 @@ describe('Date Time Input component', () => { expect(el.value!.getFullYear()).to.equal(value.getFullYear() - 1); }); - it('Up/Down arrow readonly', async () => { + it('Up/Down arrow readonly is a no-op', async () => { const value = new Date(2020, 2, 3); el.readOnly = true; el.value = value; el.focus(); await elementUpdated(el); - simulateKeyboard(input, arrowDown); + const eventSpy = spy(el, 'emitEvent'); + + simulateKeyboard(input, [altKey, arrowUp]); await elementUpdated(el); - expect(el.value.getFullYear()).to.equal(value.getFullYear()); + expect(eventSpy).not.to.have.been.called; + + simulateKeyboard(input, [altKey, arrowDown]); + await elementUpdated(el); + + expect(eventSpy).not.to.have.been.called; + }); + + it('Alt + ArrowUp/Down is a no-op', async () => { + const value = new Date(202, 2, 3); + el.value = value; + el.focus(); + await elementUpdated(el); + + const eventSpy = spy(el, 'emitEvent'); + + simulateKeyboard(input, [altKey, arrowUp]); + await elementUpdated(el); + + expect(eventSpy).not.to.have.been.called; + + simulateKeyboard(input, [altKey, arrowDown]); + await elementUpdated(el); + + expect(eventSpy).not.to.have.been.called; + }); + + it('should not emit change event when readonly', async () => { + const eventSpy = spy(el, 'emitEvent'); + + el.value = new Date(2023, 5, 1); + el.readOnly = true; + el.focus(); + await elementUpdated(el); + + el.blur(); + await elementUpdated(el); + + // -> [igcFocus, igcBlur] + expect(eventSpy.getCalls()).lengthOf(2); }); it('should not move input selection (caret) from a focused part when stepUp/stepDown are invoked', async () => { From 4b8bb01a3d13d1b4354db582c05b4518ec24277d Mon Sep 17 00:00:00 2001 From: ddaribo Date: Thu, 29 Feb 2024 15:48:35 +0200 Subject: [PATCH 19/63] fix: lint error in test --- src/components/date-picker/date-picker.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 6fa6ee87a..003d04fc5 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -162,14 +162,14 @@ describe('Date picker', () => { }); it('should be successfully initialized with value', async () => { + const expectedValue = new Date(2024, 1, 29); picker = await fixture( - html`` + html`` ); dateTimeInput = picker.shadowRoot!.querySelector( IgcDateTimeInputComponent.tagName ) as IgcDateTimeInputComponent; - const expectedValue = new Date(2024, 1, 29); expect(picker.value).not.to.be.null; checkDatesEqual(picker.value!, expectedValue); expect(dateTimeInput.value).not.to.be.null; From 5ab0a5fd6be8511079bef2e976a6e5a02765b323 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Fri, 1 Mar 2024 11:34:50 +0200 Subject: [PATCH 20/63] feat: add more tests --- .../date-picker/date-picker.spec.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 003d04fc5..27285d82c 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -219,6 +219,15 @@ describe('Date picker', () => { new Date().setDate(currentDate.getDate() + 1) ); + it('should set the value trough attribute correctly', async () => { + expect(picker.value).to.be.null; + const expectedValue = new Date(2024, 2, 1); + picker.setAttribute('value', expectedValue.toDateString()); + await elementUpdated(picker); + + checkDatesEqual(picker.value!, expectedValue); + }); + it('should show/hide the picker based on the value of the open attribute', async () => { expect(picker.open).to.equal(false); picker.open = true; @@ -707,6 +716,38 @@ describe('Date picker', () => { expect(eventSpy).calledWith('igcClosing'); expect(eventSpy).calledWith('igcClosed'); }); + + it('should emit or not igcInput according to nonEditable property', async () => { + const expectedValue = new Date(); + const eventSpy = spy(picker, 'emitEvent'); + + dateTimeInput.focus(); + simulateKeyboard(dateTimeInput, arrowUp); + await elementUpdated(picker); + + expect(eventSpy).calledOnceWith('igcInput'); + eventSpy.resetHistory(); + checkDatesEqual(picker.value as Date, expectedValue); + + picker.value = null; + picker.nonEditable = true; + await elementUpdated(picker); + + dateTimeInput.focus(); + simulateKeyboard(dateTimeInput, arrowUp); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.value).to.be.null; + + dateTimeInput.dispatchEvent( + new CustomEvent('igcInput', { detail: expectedValue }) + ); + await elementUpdated(picker); + + expect(eventSpy).not.called; + expect(picker.value).to.be.null; + }); }); describe('Form integration', () => { From ddbc279f7cb5398d007a1fa9c217dc374f07e118 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 1 Mar 2024 12:51:41 +0200 Subject: [PATCH 21/63] refactor: Focus trap slotted content * Added tests --- src/components/common/util.ts | 65 ----------- src/components/focus-trap/focus-trap.spec.ts | 112 +++++++++++++++++++ src/components/focus-trap/focus-trap.ts | 105 ++++++++++++++++- stories/datepicker.stories.ts | 11 +- 4 files changed, 215 insertions(+), 78 deletions(-) create mode 100644 src/components/focus-trap/focus-trap.spec.ts diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 2befd4c0d..53f88e769 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -144,34 +144,6 @@ export function* iterNodes( } } -export function* iterNodesShadow( - root: Node | ShadowRoot, - whatToShow?: keyof typeof NodeFilter, - filter?: (node: T) => boolean -): Generator { - const iter = document.createTreeWalker( - root, - NodeFilter[whatToShow ?? 'SHOW_ALL'] - ); - let node = iter.nextNode() as T; - - while (node) { - if (isElement(node) && node.shadowRoot) { - yield* iterNodesShadow(node.shadowRoot, whatToShow, filter); - } else { - if (filter) { - if (filter(node)) { - yield node; - } - } else { - yield node; - } - } - - node = iter.nextNode() as T; - } -} - export function getElementByIdFromRoot(root: HTMLElement, id: string) { return (root.getRootNode() as Document | ShadowRoot).getElementById(id); } @@ -193,40 +165,3 @@ export function groupBy(array: T[], key: keyof T | ((item: T) => any)) { return result; } - -const _baseSelectors = [ - '[tabindex]', - 'a[href]', - 'button', - 'input', - 'select', - 'textarea', -]; - -function isHidden(node: HTMLElement) { - return ( - node.hasAttribute('hidden') || - node.hasAttribute('inert') || - (node.hasAttribute('aria-hidden') && - node.getAttribute('aria-hidden') !== 'false') - ); -} - -function isDisabled(node: HTMLElement) { - return node.hasAttribute('disabled') || node.hasAttribute('inert'); -} - -/** - * Whether the passed in `node` is a focusable element. - */ -export function isFocusable(node: HTMLElement) { - if ( - node.getAttribute('tabindex') === '-1' || - isHidden(node) || - isDisabled(node) - ) { - return false; - } - - return _baseSelectors.some((qs) => node.matches(qs)); -} diff --git a/src/components/focus-trap/focus-trap.spec.ts b/src/components/focus-trap/focus-trap.spec.ts new file mode 100644 index 000000000..0f8f9a2cb --- /dev/null +++ b/src/components/focus-trap/focus-trap.spec.ts @@ -0,0 +1,112 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +import IgcFocusTrapComponent from './focus-trap.js'; +import IgcCalendarComponent from '../calendar/calendar.js'; +import type IgcDaysViewComponent from '../calendar/days-view/days-view.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; + +describe('Focus trap', () => { + before(() => defineComponents(IgcFocusTrapComponent, IgcCalendarComponent)); + + let trap: IgcFocusTrapComponent; + + describe('Light DOM', () => { + beforeEach(async () => { + trap = await fixture( + html` + + + + + + + +
+ +
+ +
` + ); + }); + + it('has correct number of focusable elements', () => { + // 3 "active" buttons and an input that is not inside a "disabled" parent + expect(trap.focusableElements).lengthOf(4); + }); + + it('`focused` property reflects focus within', () => { + expect(trap.focused).to.be.false; + + trap.focusableElements.at(0)?.focus(); + + expect(trap.focused).to.be.true; + }); + + it('correctly focuses first/last focusable element', () => { + expect(document.activeElement).to.equal(document.body); + + trap['focusFirstElement'](); + expect(document.activeElement).instanceOf(HTMLButtonElement); + + trap['focusLastElement'](); + expect(document.activeElement).instanceOf(HTMLInputElement); + }); + }); + + describe('Shadow DOM', () => { + beforeEach(async () => { + trap = await fixture( + html` + +
+ +
+
+
` + ); + }); + + it('has correct number of focusable elements', () => { + let elements = trap.focusableElements; + + // One projected input + 4 navigation buttons in the calendar default view + 1 active date element = 6 + expect(elements).lengthOf(6); + expect(elements.at(0)).instanceOf(HTMLInputElement); + expect(elements.at(-1)).instanceOf(HTMLSpanElement); + + trap.querySelector('section')!.hidden = true; + + elements = trap.focusableElements; + + // The input is dropped since its parent is hidden in this case + expect(elements).lengthOf(5); + expect(elements.at(0)).instanceOf(HTMLButtonElement); + expect(elements.at(-1)).instanceOf(HTMLSpanElement); + }); + + it('`focused` property reflects focus within', () => { + expect(trap.focused).to.be.false; + + trap.focusableElements.at(0)?.focus(); + + expect(trap.focused).to.be.true; + }); + + it('correctly focuses first/last focusable element', () => { + const daysView = trap.querySelector(IgcCalendarComponent.tagName)![ + 'daysViews' + ][0] as IgcDaysViewComponent; + + expect(document.activeElement).to.equal(document.body); + + trap['focusFirstElement'](); + expect(document.activeElement).instanceOf(HTMLInputElement); + + trap['focusLastElement'](); + expect(document.activeElement).instanceOf(IgcCalendarComponent); + expect(daysView.shadowRoot?.activeElement).instanceOf(HTMLSpanElement); + }); + }); +}); diff --git a/src/components/focus-trap/focus-trap.ts b/src/components/focus-trap/focus-trap.ts index 71cb1358c..4637ae5f6 100644 --- a/src/components/focus-trap/focus-trap.ts +++ b/src/components/focus-trap/focus-trap.ts @@ -2,7 +2,6 @@ import { LitElement, css, html, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; -import { isFocusable, iterNodesShadow } from '../common/util.js'; /** * @@ -42,11 +41,7 @@ export default class IgcFocusTrapComponent extends LitElement { /** An array of focusable elements including elements in Shadow roots */ public get focusableElements() { - return Array.from( - iterNodesShadow(this, 'SHOW_ELEMENT', (node) => { - return isFocusable(node); - }) - ); + return Array.from(getFocusableElements(this)); } constructor() { @@ -88,3 +83,101 @@ declare global { 'igc-focus-trap': IgcFocusTrapComponent; } } + +const defaultSelectors = [ + '[tabindex]', + 'a[href]', + 'button', + 'input', + 'select', + 'textarea', +]; + +/** Returns whether the element is hidden. */ +function isHidden(node: HTMLElement) { + return ( + node.hasAttribute('hidden') || + node.hasAttribute('inert') || + (node.hasAttribute('aria-hidden') && + node.getAttribute('aria-hidden') !== 'false') + ); +} + +/** Returns whether the element is disabled. */ +function isDisabled(node: HTMLElement) { + return node.hasAttribute('disabled') || node.hasAttribute('inert'); +} + +/** + * Returns whether the element can be focused. + */ +function isFocusable(node: HTMLElement) { + if (node.tabIndex === -1 || isHidden(node) || isDisabled(node)) { + return false; + } + + return defaultSelectors.some((selector) => node.matches(selector)); +} + +/** + * Filter function for the tree walker instance skipping over nodes and their children + * if the `node` is hidden/disabled or it was already visited and resides in `cache`. + */ +function shouldSkipElements(node: Node, cache?: WeakSet) { + const element = node as HTMLElement; + + return isHidden(element) || isDisabled(element) || cache?.has(element) + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_ACCEPT; +} + +/** Returns the slotted elements and the parent element containing the slot */ +function getSlottedElements(node: HTMLElement) { + const slot = node as HTMLSlotElement; + const elements = slot.assignedElements() as HTMLElement[]; + return { elements, parent: elements.at(0)?.parentElement }; +} + +/** + * Traverses and yields all focusable elements starting at `root`. + */ +function* getFocusableElements( + root: HTMLElement | ShadowRoot, + cache?: WeakSet +): Generator { + let node: T; + cache = cache ?? new WeakSet(); + + const visitor = document.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT, + (node) => shouldSkipElements(node, cache) + ); + + while ((node = visitor.nextNode() as T)) { + if (cache.has(node)) { + continue; + } + + if (node.shadowRoot) { + yield* getFocusableElements(node.shadowRoot, cache); + continue; + } + + if (node.tagName === 'SLOT') { + const { elements, parent } = getSlottedElements(node); + + if (elements.length > 0) { + for (const element of elements) { + yield* getFocusableElements(parent!, cache); + cache.add(element); + } + } + continue; + } + + if (isFocusable(node)) { + yield node; + } + } +} diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index ae7a11811..f8357111f 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -312,14 +312,13 @@ type Story = StoryObj; export const Default: Story = { args: { label: 'Pick a date', - value: new Date(), }, render: (args) => html`
Date: Wed, 13 Mar 2024 10:24:09 +0200 Subject: [PATCH 22/63] refactor: Rendering logic, default icon --- src/components/date-picker/date-picker.ts | 253 ++++++++++++---------- src/components/focus-trap/focus-trap.ts | 17 +- src/components/icon/internal-icons-lib.ts | 3 + 3 files changed, 158 insertions(+), 115 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 6e1fcf6d5..adb468ef7 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -1,5 +1,5 @@ -import { ComplexAttributeConverter, LitElement, html } from 'lit'; -import { property, query, queryAssignedElements } from 'lit/decorators.js'; +import { ComplexAttributeConverter, LitElement, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; @@ -38,6 +38,7 @@ import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import { DatePart } from '../date-time-input/date-util.js'; import IgcDialogComponent from '../dialog/dialog.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; +import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent from '../popover/popover.js'; export interface IgcDatepickerEventMap { @@ -110,6 +111,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( IgcCalendarComponent, IgcDateTimeInputComponent, IgcFocusTrapComponent, + IgcIconComponent, IgcPopoverComponent, IgcDialogComponent ); @@ -124,24 +126,16 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( hideCallback: () => this._hide(true), }); - @query(IgcDateTimeInputComponent.tagName, true) + private get isDropDown() { + return this.mode === 'dropdown'; + } + + @query(IgcDateTimeInputComponent.tagName) private _input!: IgcDateTimeInputComponent; @query(IgcCalendarComponent.tagName) private _calendar!: IgcCalendarComponent; - @queryAssignedElements({ slot: 'calendar-icon' }) - protected calendarIcon!: Array; - - @queryAssignedElements({ slot: 'calendar-icon-open' }) - protected calendarIconOpen!: Array; - - @queryAssignedElements({ slot: 'clear-icon' }) - protected clearIcon!: Array; - - @queryAssignedElements({ slot: 'title' }) - protected titleSlot!: Array; - /** * Sets the state of the datepicker dropdown. * @attr @@ -341,6 +335,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( | 'friday' | 'saturday' = 'sunday'; + @watch('min', { waitUntilFirstUpdate: true }) + @watch('max', { waitUntilFirstUpdate: true }) + @watch('disabledDates', { waitUntilFirstUpdate: true }) + protected constraintChange() { + this.updateValidity(); + } + constructor() { super(); @@ -445,107 +446,116 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this.emitEvent('igcInput', { detail: this.value }); } - @watch('min', { waitUntilFirstUpdate: true }) - @watch('max', { waitUntilFirstUpdate: true }) - @watch('disabledDates', { waitUntilFirstUpdate: true }) - protected constraintChange() { - this.updateValidity(); + protected onSlotChange() { + this.requestUpdate(); } private renderClearIcon() { - return ( - this.value && - html` - - - - ` - ); + return !this.value + ? nothing + : html` + + + + + + `; } private renderCalendarIcon() { - const defaultIcon = '📅'; - return html` - ${this.open - ? html`${defaultIcon}` - : html`${defaultIcon}`} - `; + const defaultIcon = html` + + `; + + const state = this.open ? 'calendar-icon-open' : 'calendar-icon'; + + return html` + + + ${defaultIcon} + + + `; } private renderCalendar(id: string) { - const calendarDisabled = !this.open || this.disabled; - const role = this.mode === 'dropdown' ? 'dialog' : undefined; - const hideHeader = - (this.mode === 'dialog' && this.hideHeader) || - (this.mode === 'dropdown' && this.titleSlot!.length === 0); - - const calendar = html` - ${this.mode === 'dropdown' && - html``} - `; - - if (this.mode === 'dropdown') { - return html` - - ${calendar} - - `; - } else { - return html` this._hide(true)} + const role = this.isDropDown ? 'dialog' : undefined; + const hideHeader = this.isDropDown ? true : this.hideHeader; + + return html` + - ${calendar} - `; - } + ${!this.isDropDown + ? html`` + : nothing} + + `; } - protected override render() { - const id = this.id || this.inputId; - const displayFormat = formats.has(this._displayFormat!) + protected renderPicker(id: string) { + return this.isDropDown + ? html` + + + ${this.renderCalendar(id)} + + + ` + : html` + this._hide(true)} + > + ${this.renderCalendar(id)} + + `; + } + + protected renderInput(id: string) { + const format = formats.has(this._displayFormat!) ? `${this._displayFormat}Date` : this._displayFormat; @@ -553,13 +563,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( - - ${this.renderClearIcon()} ${this.renderCalendarIcon()} - - + + ${this.renderClearIcon()}${this.renderCalendarIcon()} + + - ${this.renderCalendar(id)} `; } + + protected override render() { + const id = this.id || this.inputId; + + return html`${this.renderInput(id)}${this.renderPicker(id)}`; + } } declare global { diff --git a/src/components/focus-trap/focus-trap.ts b/src/components/focus-trap/focus-trap.ts index 4637ae5f6..de2d852c2 100644 --- a/src/components/focus-trap/focus-trap.ts +++ b/src/components/focus-trap/focus-trap.ts @@ -2,6 +2,7 @@ import { LitElement, css, html, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; +import { isDefined } from '../common/util.js'; /** * @@ -47,8 +48,16 @@ export default class IgcFocusTrapComponent extends LitElement { constructor() { super(); - this.addEventListener('focusin', () => (this._focused = true)); - this.addEventListener('focusout', () => (this._focused = false)); + this.addEventListener('focusin', this.onFocusIn); + this.addEventListener('focusout', this.onFocusOut); + } + + private onFocusIn() { + this._focused = true; + } + + private onFocusOut() { + this._focused = false; } protected focusFirstElement() { @@ -145,6 +154,10 @@ function* getFocusableElements( root: HTMLElement | ShadowRoot, cache?: WeakSet ): Generator { + if (!isDefined(globalThis.document)) { + return; + } + let node: T; cache = cache ?? new WeakSet(); diff --git a/src/components/icon/internal-icons-lib.ts b/src/components/icon/internal-icons-lib.ts index 861867954..4b859bdf5 100644 --- a/src/components/icon/internal-icons-lib.ts +++ b/src/components/icon/internal-icons-lib.ts @@ -46,4 +46,7 @@ export const internalIcons: IconCollection = { arrow_upward: { svg: ``, }, + calendar: { + svg: ``, + }, }; From ad11df95a8d94f65dee3a6ec5b1a00607533732d Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 13 Mar 2024 10:36:31 +0200 Subject: [PATCH 23/63] fix: Slot event, title ARIA --- src/components/date-picker/date-picker.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index adb468ef7..df3d442a3 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -454,13 +454,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( return !this.value ? nothing : html` - - + + ` + > + Select date + ` : nothing} `; From 202e4d26ffeab161677c347fa26c976f3359418f Mon Sep 17 00:00:00 2001 From: ddaribo Date: Wed, 13 Mar 2024 14:05:17 +0200 Subject: [PATCH 24/63] fix: adjust slot tests according to changes --- src/components/date-picker/date-picker.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 27285d82c..c90a1c950 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -106,6 +106,7 @@ describe('Date picker', () => { slot: 'title', tagName: 'p', content: 'Custom title', + prerequisite: () => (picker.mode = 'dialog'), parent: picker, }, { @@ -161,6 +162,20 @@ describe('Date picker', () => { } }); + it('should not render title slot elements in dropdown mode', async () => { + picker = await fixture( + html` +

Custom title

+
` + ); + await elementUpdated(picker); + + const slot = picker.shadowRoot!.querySelector( + `slot[name="title"]` + ) as HTMLSlotElement; + expect(slot).to.be.null; + }); + it('should be successfully initialized with value', async () => { const expectedValue = new Date(2024, 1, 29); picker = await fixture( From 923eb5aee8d141d5b23b1bbd93263bcdd97a887e Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 13 Mar 2024 15:54:25 +0200 Subject: [PATCH 25/63] feat: Added actions slot - More ARIA fixes --- src/components/date-picker/date-picker.ts | 20 ++++++++-- stories/datepicker.stories.ts | 46 ++++++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index df3d442a3..4c780854c 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -67,6 +67,7 @@ const formats = new Set(['short', 'medium', 'long', 'full']); * @slot clear-icon - Renders a clear icon template. * @slot calendar-icon - Renders the icon/content for the calendar picker. * @slot calendar-icon-open - Renders the icon/content for the picker in open state. + * @slot actions - Renders content in the action part of the picker in open state. * * @fires igcOpening - Emitted just before the calendar dropdown is shown. * @fires igcOpened - Emitted after the calendar dropdown is shown. @@ -522,6 +523,18 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( `; } + protected renderActions() { + // If in dialog mode use the dialog footer slot + return html` +
+ +
+ `; + } + protected renderPicker(id: string) { return this.isDropDown ? html` @@ -534,19 +547,20 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( same-width > - ${this.renderCalendar(id)} + ${this.renderCalendar(id)}${this.renderActions()} ` : html` this._hide(true)} > - ${this.renderCalendar(id)} + ${this.renderCalendar(id)}${this.renderActions()} `; } @@ -559,7 +573,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( return html` = { @@ -347,6 +348,22 @@ export const Default: Story = { `, }; +function showTrimester() { + const picker = document.querySelector('#picker')!; + picker.visibleMonths = 3; +} + +function showSingleMonth() { + const picker = document.querySelector('#picker')!; + picker.visibleMonths = 1; +} + +function selectToday() { + const picker = document.querySelector('#picker')!; + picker.value = new Date(); + picker.hide(); +} + export const Slots: Story = { args: { label: 'Pick a date', @@ -354,8 +371,23 @@ export const Slots: Story = { render: (args) => html`
👩‍💻 👨‍💻 🗑️ + +
+ Select today + Trimester view + Single month view +
`, From f770974c5421b033c7463f91f9d30a5c7eab3317 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 15 Mar 2024 11:02:03 +0200 Subject: [PATCH 26/63] fix: Mixin types --- src/components/date-picker/date-picker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 4c780854c..fa8811184 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -24,7 +24,7 @@ import { } from '../common/i18n/calendar.resources.js'; import messages from '../common/localization/validation-en.js'; import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; -import type { Constructor } from '../common/mixins/constructor.js'; +import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; import { createCounter, format } from '../common/util.js'; @@ -79,7 +79,7 @@ const formats = new Set(['short', 'medium', 'long', 'full']); export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( EventEmitterMixin< IgcDatepickerEventMap, - Constructor + AbstractConstructor >(IgcBaseComboBoxLikeComponent) ) { public static readonly tagName = 'igc-datepicker'; From 1598e15fabcf440deb23a336b61d99c6ec6c5dbd Mon Sep 17 00:00:00 2001 From: ddaribo Date: Mon, 18 Mar 2024 09:40:08 +0200 Subject: [PATCH 27/63] chore: add actions slot rendering test case --- src/components/date-picker/date-picker.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index c90a1c950..c23725c78 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -69,7 +69,7 @@ describe('Date picker', () => { await expect(picker).lightDom.to.be.accessible(); }); - it('should render slotted elements - prefix, suffix, clear-icon, calendar-icon(-open), helper-text, title', async () => { + it('should render slotted elements - prefix, suffix, clear-icon, calendar-icon(-open), helper-text, title, actions', async () => { picker = await fixture( html` $ @@ -79,6 +79,7 @@ describe('Date picker', () => { v ^ X + ` ); await elementUpdated(picker); @@ -135,6 +136,13 @@ describe('Date picker', () => { prerequisite: () => (picker.value = new Date()), parent: picker, }, + { + slot: 'actions', + tagName: 'button', + content: 'Custom action', + prerequisite: () => picker.show(), + parent: picker, + }, ]; for (let i = 0; i < slotTests.length; i++) { From e875a27128b5494c85775a797e1326260c2f431c Mon Sep 17 00:00:00 2001 From: ddaribo Date: Tue, 2 Apr 2024 11:51:18 +0300 Subject: [PATCH 28/63] fix: only slot input prefix/suffix if there are assigned elements --- src/components/date-picker/date-picker.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index fa8811184..f0e06a69f 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -1,5 +1,5 @@ import { ComplexAttributeConverter, LitElement, html, nothing } from 'lit'; -import { property, query } from 'lit/decorators.js'; +import { property, query, queryAssignedElements } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; @@ -137,6 +137,12 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @query(IgcCalendarComponent.tagName) private _calendar!: IgcCalendarComponent; + @queryAssignedElements({ slot: 'prefix' }) + private prefixes!: Array; + + @queryAssignedElements({ slot: 'suffix' }) + private suffixes!: Array; + /** * Sets the state of the datepicker dropdown. * @attr @@ -594,13 +600,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( > ${this.renderClearIcon()}${this.renderCalendarIcon()} Date: Tue, 2 Apr 2024 15:44:00 +0300 Subject: [PATCH 29/63] feat(date-picker): initial styling --- src/components/date-picker/date-picker.ts | 6 +++ .../date-picker/themes/dark/_themes.scss | 7 +++ .../themes/dark/date-picker.bootstrap.scss | 7 +++ .../themes/dark/date-picker.fluent.scss | 7 +++ .../themes/dark/date-picker.indigo.scss | 7 +++ .../themes/dark/date-picker.material.scss | 7 +++ .../date-picker/themes/date-picker.base.scss | 27 ++++++++++ .../date-picker/themes/light/_themes.scss | 8 +++ .../themes/light/date-picker.bootstrap.scss | 8 +++ .../themes/light/date-picker.fluent.scss | 8 +++ .../themes/light/date-picker.indigo.scss | 8 +++ .../themes/light/date-picker.material.scss | 8 +++ .../themes/light/date-picker.shared.scss | 6 +++ .../themes/shared/date-picker.common.scss | 15 ++++++ src/components/date-picker/themes/themes.ts | 53 +++++++++++++++++++ 15 files changed, 182 insertions(+) create mode 100644 src/components/date-picker/themes/dark/_themes.scss create mode 100644 src/components/date-picker/themes/dark/date-picker.bootstrap.scss create mode 100644 src/components/date-picker/themes/dark/date-picker.fluent.scss create mode 100644 src/components/date-picker/themes/dark/date-picker.indigo.scss create mode 100644 src/components/date-picker/themes/dark/date-picker.material.scss create mode 100644 src/components/date-picker/themes/date-picker.base.scss create mode 100644 src/components/date-picker/themes/light/_themes.scss create mode 100644 src/components/date-picker/themes/light/date-picker.bootstrap.scss create mode 100644 src/components/date-picker/themes/light/date-picker.fluent.scss create mode 100644 src/components/date-picker/themes/light/date-picker.indigo.scss create mode 100644 src/components/date-picker/themes/light/date-picker.material.scss create mode 100644 src/components/date-picker/themes/light/date-picker.shared.scss create mode 100644 src/components/date-picker/themes/shared/date-picker.common.scss create mode 100644 src/components/date-picker/themes/themes.ts diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index f0e06a69f..f06b7f917 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -3,6 +3,10 @@ import { property, query, queryAssignedElements } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; +import { styles } from './themes/date-picker.base.css.js'; +import { styles as shared } from './themes/shared/date-picker.common.css.js'; +import { all } from './themes/themes.js'; +import { themes } from '../../theming/theming-decorator.js'; import IgcCalendarComponent from '../calendar/calendar.js'; import { DateRangeDescriptor, @@ -76,6 +80,7 @@ const formats = new Set(['short', 'medium', 'long', 'full']); * @fires igcChange - Emitted when the user modifies and commits the elements's value. * @fires igcInput - Emitted when when the user types in the element. */ +@themes(all) export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( EventEmitterMixin< IgcDatepickerEventMap, @@ -83,6 +88,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( >(IgcBaseComboBoxLikeComponent) ) { public static readonly tagName = 'igc-datepicker'; + public static styles = [styles, shared]; protected static shadowRootOptions = { ...LitElement.shadowRootOptions, diff --git a/src/components/date-picker/themes/dark/_themes.scss b/src/components/date-picker/themes/dark/_themes.scss new file mode 100644 index 000000000..69c84e330 --- /dev/null +++ b/src/components/date-picker/themes/dark/_themes.scss @@ -0,0 +1,7 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/dark/calendar' as *; + +$material: digest-schema($dark-material-calendar); +$bootstrap: digest-schema($dark-bootstrap-calendar); +$fluent: digest-schema($dark-fluent-calendar); +$indigo: digest-schema($dark-indigo-calendar); diff --git a/src/components/date-picker/themes/dark/date-picker.bootstrap.scss b/src/components/date-picker/themes/dark/date-picker.bootstrap.scss new file mode 100644 index 000000000..e6611c2e4 --- /dev/null +++ b/src/components/date-picker/themes/dark/date-picker.bootstrap.scss @@ -0,0 +1,7 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +:host { + @include css-vars-from-theme(diff(light.$base, $bootstrap), 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/dark/date-picker.fluent.scss b/src/components/date-picker/themes/dark/date-picker.fluent.scss new file mode 100644 index 000000000..6ec00e96a --- /dev/null +++ b/src/components/date-picker/themes/dark/date-picker.fluent.scss @@ -0,0 +1,7 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +:host { + @include css-vars-from-theme(diff(light.$base, $fluent), 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/dark/date-picker.indigo.scss b/src/components/date-picker/themes/dark/date-picker.indigo.scss new file mode 100644 index 000000000..cea5626d0 --- /dev/null +++ b/src/components/date-picker/themes/dark/date-picker.indigo.scss @@ -0,0 +1,7 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +:host { + @include css-vars-from-theme(diff(light.$base, $indigo), 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/dark/date-picker.material.scss b/src/components/date-picker/themes/dark/date-picker.material.scss new file mode 100644 index 000000000..599d159c8 --- /dev/null +++ b/src/components/date-picker/themes/dark/date-picker.material.scss @@ -0,0 +1,7 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +:host { + @include css-vars-from-theme(diff(light.$base, $material), 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss new file mode 100644 index 000000000..3e0157637 --- /dev/null +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -0,0 +1,27 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + igc-focus-trap { + @include sizable(); + --dropdown-width: #{sizable(rem(290px), rem(314px), rem(360px))}; + display: flex; + flex: 1 0 0; + flex-direction: column; + max-width: var(--dropdown-width); + overflow: hidden; + box-shadow: var(--ig-elevation-3); + + igc-calendar { + border: none; + } + } +} + +[part='actions'] { + min-height: #{sizable(rem(40px), rem(46px), rem(52px))}; + display: flex; + justify-content: flex-end; + padding: rem(8px); + gap: rem(8px); +} diff --git a/src/components/date-picker/themes/light/_themes.scss b/src/components/date-picker/themes/light/_themes.scss new file mode 100644 index 000000000..7aedf03eb --- /dev/null +++ b/src/components/date-picker/themes/light/_themes.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/light/calendar' as *; + +$base: digest-schema($light-calendar); +$material: digest-schema($material-calendar); +$bootstrap: digest-schema($bootstrap-calendar); +$fluent: digest-schema($fluent-calendar); +$indigo: digest-schema($indigo-calendar); diff --git a/src/components/date-picker/themes/light/date-picker.bootstrap.scss b/src/components/date-picker/themes/light/date-picker.bootstrap.scss new file mode 100644 index 000000000..c45112f68 --- /dev/null +++ b/src/components/date-picker/themes/light/date-picker.bootstrap.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/light/date-picker.fluent.scss b/src/components/date-picker/themes/light/date-picker.fluent.scss new file mode 100644 index 000000000..c15d56e03 --- /dev/null +++ b/src/components/date-picker/themes/light/date-picker.fluent.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/light/date-picker.indigo.scss b/src/components/date-picker/themes/light/date-picker.indigo.scss new file mode 100644 index 000000000..297a9046d --- /dev/null +++ b/src/components/date-picker/themes/light/date-picker.indigo.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/light/date-picker.material.scss b/src/components/date-picker/themes/light/date-picker.material.scss new file mode 100644 index 000000000..58f5280fe --- /dev/null +++ b/src/components/date-picker/themes/light/date-picker.material.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/light/date-picker.shared.scss b/src/components/date-picker/themes/light/date-picker.shared.scss new file mode 100644 index 000000000..bce8e7abc --- /dev/null +++ b/src/components/date-picker/themes/light/date-picker.shared.scss @@ -0,0 +1,6 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +:host { + @include css-vars-from-theme($base, 'ig-datepicker'); +} diff --git a/src/components/date-picker/themes/shared/date-picker.common.scss b/src/components/date-picker/themes/shared/date-picker.common.scss new file mode 100644 index 000000000..a8a036b88 --- /dev/null +++ b/src/components/date-picker/themes/shared/date-picker.common.scss @@ -0,0 +1,15 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $base; + +:host { + igc-focus-trap { + border-radius: var-get($theme, 'border-radius'); + background: var-get($theme, 'content-background'); + } +} + +[part='actions'] { + border-block-start: rem(1px) solid var-get($theme, 'border-color'); +} diff --git a/src/components/date-picker/themes/themes.ts b/src/components/date-picker/themes/themes.ts new file mode 100644 index 000000000..b8ca99826 --- /dev/null +++ b/src/components/date-picker/themes/themes.ts @@ -0,0 +1,53 @@ +import { css } from 'lit'; + +// Dark Overrides +import { styles as bootstrapDark } from './dark/date-picker.bootstrap.css.js'; +import { styles as fluentDark } from './dark/date-picker.fluent.css.js'; +import { styles as indigoDark } from './dark/date-picker.indigo.css.js'; +import { styles as materialDark } from './dark/date-picker.material.css.js'; +// Light Overrides +import { styles as bootstrapLight } from './light/date-picker.bootstrap.css.js'; +import { styles as fluentLight } from './light/date-picker.fluent.css.js'; +import { styles as indigoLight } from './light/date-picker.indigo.css.js'; +import { styles as materialLight } from './light/date-picker.material.css.js'; +// Shared Styles +import { styles as shared } from './light/date-picker.shared.css.js'; +import { Themes } from '../../../theming/types.js'; + +const light = { + shared: css` + ${shared} + `, + bootstrap: css` + ${bootstrapLight} + `, + material: css` + ${materialLight} + `, + fluent: css` + ${fluentLight} + `, + indigo: css` + ${indigoLight} + `, +}; + +const dark = { + shared: css` + ${shared} + `, + bootstrap: css` + ${bootstrapDark} + `, + material: css` + ${materialDark} + `, + fluent: css` + ${fluentDark} + `, + indigo: css` + ${indigoDark} + `, +}; + +export const all: Themes = { light, dark }; From 1099cdb7eb09bd0278176f8e89c177460247b859 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Tue, 2 Apr 2024 15:45:57 +0300 Subject: [PATCH 30/63] refactor: handle slots --- src/components/date-picker/date-picker.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index f06b7f917..528fd05c7 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -149,6 +149,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @queryAssignedElements({ slot: 'suffix' }) private suffixes!: Array; + @queryAssignedElements({ slot: 'actions' }) + private actions!: Array; + /** * Sets the state of the datepicker dropdown. * @attr @@ -540,7 +543,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( return html`
@@ -606,13 +611,13 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( > ${this.renderClearIcon()}${this.renderCalendarIcon()} Date: Tue, 2 Apr 2024 15:49:47 +0300 Subject: [PATCH 31/63] fix(date-picker): lint error --- src/components/date-picker/themes/date-picker.base.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 3e0157637..189e504ef 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -4,7 +4,9 @@ :host { igc-focus-trap { @include sizable(); + --dropdown-width: #{sizable(rem(290px), rem(314px), rem(360px))}; + display: flex; flex: 1 0 0; flex-direction: column; From 6c74c10e2cfaee9c921b661b69a6ed537e091692 Mon Sep 17 00:00:00 2001 From: sivanova Date: Tue, 2 Apr 2024 16:17:04 +0300 Subject: [PATCH 32/63] feat(date-picker): theme specific styles --- .../date-picker/themes/date-picker.base.scss | 7 +------ .../themes/shared/date-picker.bootstrap.scss | 5 +++++ .../themes/shared/date-picker.common.scss | 5 +++++ .../themes/shared/date-picker.fluent.scss | 5 +++++ .../themes/shared/date-picker.indigo.scss | 6 ++++++ src/components/date-picker/themes/themes.ts | 17 ++++++++++------- 6 files changed, 32 insertions(+), 13 deletions(-) create mode 100644 src/components/date-picker/themes/shared/date-picker.bootstrap.scss create mode 100644 src/components/date-picker/themes/shared/date-picker.fluent.scss create mode 100644 src/components/date-picker/themes/shared/date-picker.indigo.scss diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 189e504ef..9baef74a9 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -3,14 +3,10 @@ :host { igc-focus-trap { - @include sizable(); - - --dropdown-width: #{sizable(rem(290px), rem(314px), rem(360px))}; - display: flex; flex: 1 0 0; flex-direction: column; - max-width: var(--dropdown-width); + max-width: #{sizable(rem(290px), rem(314px), rem(360px))}; overflow: hidden; box-shadow: var(--ig-elevation-3); @@ -21,7 +17,6 @@ } [part='actions'] { - min-height: #{sizable(rem(40px), rem(46px), rem(52px))}; display: flex; justify-content: flex-end; padding: rem(8px); diff --git a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss new file mode 100644 index 000000000..78f857ff7 --- /dev/null +++ b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss @@ -0,0 +1,5 @@ +@use 'styles/utilities' as *; + +[part='actions'] { + min-height: #{sizable(rem(47px), rem(54px), rem(64px))}; +} diff --git a/src/components/date-picker/themes/shared/date-picker.common.scss b/src/components/date-picker/themes/shared/date-picker.common.scss index a8a036b88..739c83cc7 100644 --- a/src/components/date-picker/themes/shared/date-picker.common.scss +++ b/src/components/date-picker/themes/shared/date-picker.common.scss @@ -5,11 +5,16 @@ $theme: $base; :host { igc-focus-trap { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + border-radius: var-get($theme, 'border-radius'); background: var-get($theme, 'content-background'); } } [part='actions'] { + min-height: #{sizable(rem(40px), rem(46px), rem(52px))}; border-block-start: rem(1px) solid var-get($theme, 'border-color'); } diff --git a/src/components/date-picker/themes/shared/date-picker.fluent.scss b/src/components/date-picker/themes/shared/date-picker.fluent.scss new file mode 100644 index 000000000..10693141a --- /dev/null +++ b/src/components/date-picker/themes/shared/date-picker.fluent.scss @@ -0,0 +1,5 @@ +@use 'styles/utilities' as *; + +[part='actions'] { + min-height: #{sizable(rem(40px), rem(48px), rem(54px))}; +} diff --git a/src/components/date-picker/themes/shared/date-picker.indigo.scss b/src/components/date-picker/themes/shared/date-picker.indigo.scss new file mode 100644 index 000000000..b0256271d --- /dev/null +++ b/src/components/date-picker/themes/shared/date-picker.indigo.scss @@ -0,0 +1,6 @@ +@use 'styles/utilities' as *; + +[part='actions'] { + min-height: #{sizable(rem(40px), rem(44px), rem(48px))}; + padding: rem(8px) rem(16px); +} diff --git a/src/components/date-picker/themes/themes.ts b/src/components/date-picker/themes/themes.ts index b8ca99826..e42b8eed2 100644 --- a/src/components/date-picker/themes/themes.ts +++ b/src/components/date-picker/themes/themes.ts @@ -10,8 +10,11 @@ import { styles as bootstrapLight } from './light/date-picker.bootstrap.css.js'; import { styles as fluentLight } from './light/date-picker.fluent.css.js'; import { styles as indigoLight } from './light/date-picker.indigo.css.js'; import { styles as materialLight } from './light/date-picker.material.css.js'; -// Shared Styles import { styles as shared } from './light/date-picker.shared.css.js'; +// Shared Styles +import { styles as bootstrap } from './shared/date-picker.bootstrap.css.js'; +import { styles as fluent } from './shared/date-picker.fluent.css.js'; +import { styles as indigo } from './shared/date-picker.indigo.css.js'; import { Themes } from '../../../theming/types.js'; const light = { @@ -19,16 +22,16 @@ const light = { ${shared} `, bootstrap: css` - ${bootstrapLight} + ${bootstrap} ${bootstrapLight} `, material: css` ${materialLight} `, fluent: css` - ${fluentLight} + ${fluent} ${fluentLight} `, indigo: css` - ${indigoLight} + ${indigo} ${indigoLight} `, }; @@ -37,16 +40,16 @@ const dark = { ${shared} `, bootstrap: css` - ${bootstrapDark} + ${bootstrap} ${bootstrapDark} `, material: css` ${materialDark} `, fluent: css` - ${fluentDark} + ${fluent} ${fluentDark} `, indigo: css` - ${indigoDark} + ${indigo} ${indigoDark} `, }; From 03b422e297c3e320936edae7ea63101f7783d368 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Wed, 3 Apr 2024 10:33:16 +0300 Subject: [PATCH 33/63] fix: add hidden part for actions div when no elements --- src/components/date-picker/date-picker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 528fd05c7..8b7c4c07f 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -31,7 +31,7 @@ import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; -import { createCounter, format } from '../common/util.js'; +import { createCounter, format, partNameMap } from '../common/util.js'; import { Validator, maxDateValidator, @@ -542,7 +542,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( // If in dialog mode use the dialog footer slot return html`
Date: Thu, 4 Apr 2024 11:35:26 +0300 Subject: [PATCH 34/63] feat(styling): borders and elevations --- .../date-picker/themes/date-picker.base.scss | 11 ++++++++ .../themes/shared/date-picker.bootstrap.scss | 19 ++++++++++++++ .../themes/shared/date-picker.common.scss | 25 +++++++++++++++++++ .../themes/shared/date-picker.fluent.scss | 9 +++++++ .../themes/shared/date-picker.indigo.scss | 23 +++++++++++++++++ 5 files changed, 87 insertions(+) diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 9baef74a9..76b195908 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -12,8 +12,14 @@ igc-calendar { border: none; + box-shadow: none; } } + + igc-calendar[header-orientation='vertical'] { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } } [part='actions'] { @@ -21,4 +27,9 @@ justify-content: flex-end; padding: rem(8px); gap: rem(8px); + + ::slotted(*) { + display: flex; + gap: rem(8px); + } } diff --git a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss index 78f857ff7..9672e037f 100644 --- a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss +++ b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss @@ -1,4 +1,23 @@ @use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $bootstrap; + +:host { + igc-dialog { + igc-calendar { + border: none; + } + } + + igc-dialog::part(base) { + border: rem(1px) solid var-get($theme, 'border-color'); + } + + igc-focus-trap { + border: rem(1px) solid var-get($theme, 'border-color'); + } +} [part='actions'] { min-height: #{sizable(rem(47px), rem(54px), rem(64px))}; diff --git a/src/components/date-picker/themes/shared/date-picker.common.scss b/src/components/date-picker/themes/shared/date-picker.common.scss index 739c83cc7..791c1054e 100644 --- a/src/components/date-picker/themes/shared/date-picker.common.scss +++ b/src/components/date-picker/themes/shared/date-picker.common.scss @@ -4,6 +4,31 @@ $theme: $base; :host { + igc-dialog { + box-shadow: none; + border: none; + + [part='actions'] { + border: none; + } + } + + igc-dialog[open]::part(base) { + box-shadow: var(--ig-elevation-24); + } + + igc-dialog::part(content), + igc-dialog::part(footer) { + padding: 0; + } + + igc-dialog::part(footer) { + background: var-get($theme, 'content-background'); + // Should be fixed + // border-block-start: rem(1px) solid var-get($theme, 'border-color'); + border-block-start: rem(1px) solid color(gray, 200); + } + igc-focus-trap { @include sizable(); diff --git a/src/components/date-picker/themes/shared/date-picker.fluent.scss b/src/components/date-picker/themes/shared/date-picker.fluent.scss index 10693141a..795376fcc 100644 --- a/src/components/date-picker/themes/shared/date-picker.fluent.scss +++ b/src/components/date-picker/themes/shared/date-picker.fluent.scss @@ -1,4 +1,13 @@ @use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $fluent; + +:host { + igc-dialog[open]::part(base) { + border-radius: 0; + } +} [part='actions'] { min-height: #{sizable(rem(40px), rem(48px), rem(54px))}; diff --git a/src/components/date-picker/themes/shared/date-picker.indigo.scss b/src/components/date-picker/themes/shared/date-picker.indigo.scss index b0256271d..d0872e672 100644 --- a/src/components/date-picker/themes/shared/date-picker.indigo.scss +++ b/src/components/date-picker/themes/shared/date-picker.indigo.scss @@ -1,4 +1,27 @@ @use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $indigo; + +:host { + igc-dialog[open]::part(base) { + border-radius: rem(6px); + box-shadow: var(--ig-elevation-5); + // Should be fixed + // border: rem(1px) solid var-get($theme, 'border-color'); + border: rem(1px) solid color(gray, 400); + } + + igc-dialog::part(footer) { + // Should be fixed + // border-block-start: rem(1px) solid var-get($theme, 'border-color'); + border-block-start: rem(1px) solid color(gray, 400); + } + + igc-focus-trap { + border: rem(1px) solid var-get($theme, 'border-color'); + } +} [part='actions'] { min-height: #{sizable(rem(40px), rem(44px), rem(48px))}; From 5e91fc2dcbb2bc09f533839adbe43d3315da5576 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Thu, 4 Apr 2024 16:21:12 +0300 Subject: [PATCH 35/63] fix: render own label and helper-text --- .../date-picker/date-picker.spec.ts | 1 - src/components/date-picker/date-picker.ts | 29 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index c23725c78..aa092a06c 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -410,7 +410,6 @@ describe('Date picker', () => { it('should set properties of the input correctly', async () => { const props = { required: true, - label: 'Sample Label', disabled: true, placeholder: 'Sample placeholder', outlined: true, diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 8b7c4c07f..4200d7772 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -31,7 +31,7 @@ import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; -import { createCounter, format, partNameMap } from '../common/util.js'; +import { createCounter, format } from '../common/util.js'; import { Validator, maxDateValidator, @@ -152,6 +152,9 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @queryAssignedElements({ slot: 'actions' }) private actions!: Array; + @queryAssignedElements({ slot: 'helper-text' }) + private helperText!: Array; + /** * Sets the state of the datepicker dropdown. * @attr @@ -542,7 +545,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( // If in dialog mode use the dialog footer slot return html`
${this.label}` + : nothing; + } + + private renderHelperText() { + return html`
+ +
`; + } + protected renderInput(id: string) { const format = formats.has(this._displayFormat!) ? `${this._displayFormat}Date` @@ -592,7 +608,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( id=${id} aria-haspopup="dialog" aria-expanded=${this.open} - label=${ifDefined(this.label)} input-format=${ifDefined(this._inputFormat)} display-format=${ifDefined(format)} ?disabled=${this.disabled} @@ -620,11 +635,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( slot="${ifDefined(!this.suffixes.length ? undefined : 'suffix')}" @slotchange=${this.onSlotChange} > - `; } @@ -632,7 +642,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( protected override render() { const id = this.id || this.inputId; - return html`${this.renderInput(id)}${this.renderPicker(id)}`; + return html`${this.renderLabel(id)}${this.renderInput(id)} + ${this.renderPicker(id)} ${this.renderHelperText()}`; } } From 1e152331d197d3eae582604db8b185d33fb67626 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Thu, 4 Apr 2024 16:39:13 +0300 Subject: [PATCH 36/63] chore: add test for label --- src/components/date-picker/date-picker.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index aa092a06c..d63ef4a1e 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -423,6 +423,17 @@ describe('Date picker', () => { } }); + it('should render the label correctly', async () => { + picker.label = 'Test label'; + await elementUpdated(picker); + + const label = picker.shadowRoot?.querySelector( + 'label[part="label"]' + ) as HTMLLabelElement; + expect(label).not.to.be.undefined; + expect(label.innerText).to.equal('Test label'); + }); + describe('Active date', () => { const tomorrowDate = new Date( new Date().setDate(currentDate.getDate() + 1) From 85c9625eb3a2b36fdbc28f79c87aa3d91529e98c Mon Sep 17 00:00:00 2001 From: sivanova Date: Thu, 4 Apr 2024 17:25:15 +0300 Subject: [PATCH 37/63] fix(date-picker): helper text styles --- .../date-picker/themes/dark/_themes.scss | 26 ++++++++++++++++--- .../date-picker/themes/date-picker.base.scss | 10 +++++++ .../date-picker/themes/light/_themes.scss | 26 ++++++++++++++++--- .../themes/shared/date-picker.bootstrap.scss | 8 ++++++ .../themes/shared/date-picker.common.scss | 25 ++++++++++++++++++ .../themes/shared/date-picker.fluent.scss | 5 ++++ .../themes/shared/date-picker.indigo.scss | 6 +++++ 7 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/components/date-picker/themes/dark/_themes.scss b/src/components/date-picker/themes/dark/_themes.scss index 69c84e330..dbf6de2c8 100644 --- a/src/components/date-picker/themes/dark/_themes.scss +++ b/src/components/date-picker/themes/dark/_themes.scss @@ -1,7 +1,25 @@ +@use 'sass:map'; @use 'styles/utilities' as *; @use 'igniteui-theming/sass/themes/schemas/components/dark/calendar' as *; +@use 'components/input/themes/dark/themes' as input-theme; -$material: digest-schema($dark-material-calendar); -$bootstrap: digest-schema($dark-bootstrap-calendar); -$fluent: digest-schema($dark-fluent-calendar); -$indigo: digest-schema($dark-indigo-calendar); +$material: map.merge(digest-schema($dark-material-calendar), ( + helper-text-color: map.get(input-theme.$material, 'helper-text-color'), + disabled-text-color: map.get(input-theme.$material, 'disabled-text-color'), + error-secondary-color: map.get(input-theme.$material, 'error-secondary-color') +)); +$bootstrap: map.merge(digest-schema($dark-bootstrap-calendar), ( + helper-text-color: map.get(input-theme.$bootstrap, 'helper-text-color'), + disabled-text-color: map.get(input-theme.$bootstrap, 'disabled-text-color'), + error-secondary-color: map.get(input-theme.$bootstrap, 'error-secondary-color') +)); +$fluent: map.merge(digest-schema($dark-fluent-calendar), ( + helper-text-color: map.get(input-theme.$fluent, 'helper-text-color'), + disabled-text-color: map.get(input-theme.$fluent, 'disabled-text-color'), + error-secondary-color: map.get(input-theme.$fluent, 'error-secondary-color') +)); +$indigo: map.merge(digest-schema($dark-indigo-calendar), ( + helper-text-color: map.get(input-theme.$indigo, 'helper-text-color'), + disabled-text-color: map.get(input-theme.$indigo, 'disabled-text-color'), + error-secondary-color: map.get(input-theme.$indigo, 'error-secondary-color') +)); diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 76b195908..94d47aece 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -22,6 +22,16 @@ } } +[part='helper-text'] { + line-height: 1; + + ::slotted([slot='helper-text']) { + @include type-style('caption'); + + line-height: inherit; + } +} + [part='actions'] { display: flex; justify-content: flex-end; diff --git a/src/components/date-picker/themes/light/_themes.scss b/src/components/date-picker/themes/light/_themes.scss index 7aedf03eb..3a953b7be 100644 --- a/src/components/date-picker/themes/light/_themes.scss +++ b/src/components/date-picker/themes/light/_themes.scss @@ -1,8 +1,26 @@ +@use 'sass:map'; @use 'styles/utilities' as *; @use 'igniteui-theming/sass/themes/schemas/components/light/calendar' as *; +@use 'components/input/themes/light/themes' as input-theme; $base: digest-schema($light-calendar); -$material: digest-schema($material-calendar); -$bootstrap: digest-schema($bootstrap-calendar); -$fluent: digest-schema($fluent-calendar); -$indigo: digest-schema($indigo-calendar); +$material: map.merge(digest-schema($material-calendar), ( + helper-text-color: map.get(input-theme.$material, 'helper-text-color'), + disabled-text-color: map.get(input-theme.$material, 'disabled-text-color'), + error-secondary-color: map.get(input-theme.$material, 'error-secondary-color') +)); +$bootstrap: map.merge(digest-schema($bootstrap-calendar), ( + helper-text-color: map.get(input-theme.$bootstrap, 'helper-text-color'), + disabled-text-color: map.get(input-theme.$bootstrap, 'disabled-text-color'), + error-secondary-color: map.get(input-theme.$bootstrap, 'error-secondary-color') +)); +$fluent: map.merge(digest-schema($fluent-calendar), ( + helper-text-color: map.get(input-theme.$fluent, 'helper-text-color'), + disabled-text-color: map.get(input-theme.$fluent, 'disabled-text-color'), + error-secondary-color: map.get(input-theme.$fluent, 'error-secondary-color') +)); +$indigo: map.merge(digest-schema($indigo-calendar), ( + helper-text-color: map.get(input-theme.$indigo, 'helper-text-color'), + disabled-text-color: map.get(input-theme.$indigo, 'disabled-text-color'), + error-secondary-color: map.get(input-theme.$indigo, 'error-secondary-color') +)); diff --git a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss index 9672e037f..b0e9d71fe 100644 --- a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss +++ b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss @@ -17,6 +17,14 @@ $theme: $bootstrap; igc-focus-trap { border: rem(1px) solid var-get($theme, 'border-color'); } + + [part='helper-text'] { + padding-inline: 0; + + ::slotted([slot='helper-text']) { + @include type-style('body-2'); + } + } } [part='actions'] { diff --git a/src/components/date-picker/themes/shared/date-picker.common.scss b/src/components/date-picker/themes/shared/date-picker.common.scss index 791c1054e..e0d62afa0 100644 --- a/src/components/date-picker/themes/shared/date-picker.common.scss +++ b/src/components/date-picker/themes/shared/date-picker.common.scss @@ -1,7 +1,9 @@ @use 'styles/utilities' as *; @use '../light/themes' as *; +@use '../../../input/themes/light/themes' as input-theme; $theme: $base; +$input-theme: input-theme.$material; :host { igc-dialog { @@ -24,6 +26,7 @@ $theme: $base; igc-dialog::part(footer) { background: var-get($theme, 'content-background'); + // Should be fixed // border-block-start: rem(1px) solid var-get($theme, 'border-color'); border-block-start: rem(1px) solid color(gray, 200); @@ -37,9 +40,31 @@ $theme: $base; border-radius: var-get($theme, 'border-radius'); background: var-get($theme, 'content-background'); } + + [part='helper-text'] { + margin-top: rem(4px); + padding-inline: pad-inline(rem(14px), rem(16px), rem(18px)); + } + + ::slotted([slot='helper-text']) { + color: var-get($input-theme, 'helper-text-color'); + } } [part='actions'] { min-height: #{sizable(rem(40px), rem(46px), rem(52px))}; border-block-start: rem(1px) solid var-get($theme, 'border-color'); } + +:host([invalid]) { + ::slotted([slot='helper-text']) { + color: var-get($input-theme, 'error-secondary-color'); + } +} + +:host(:disabled), +:host([disabled]) { + ::slotted([slot='helper-text']) { + color: var-get($input-theme, 'disabled-text-color'); + } +} diff --git a/src/components/date-picker/themes/shared/date-picker.fluent.scss b/src/components/date-picker/themes/shared/date-picker.fluent.scss index 795376fcc..70ed3977e 100644 --- a/src/components/date-picker/themes/shared/date-picker.fluent.scss +++ b/src/components/date-picker/themes/shared/date-picker.fluent.scss @@ -7,6 +7,11 @@ $theme: $fluent; igc-dialog[open]::part(base) { border-radius: 0; } + + [part='helper-text'] { + margin-top: rem(5px); + padding-inline: 0; + } } [part='actions'] { diff --git a/src/components/date-picker/themes/shared/date-picker.indigo.scss b/src/components/date-picker/themes/shared/date-picker.indigo.scss index d0872e672..31c701870 100644 --- a/src/components/date-picker/themes/shared/date-picker.indigo.scss +++ b/src/components/date-picker/themes/shared/date-picker.indigo.scss @@ -7,12 +7,14 @@ $theme: $indigo; igc-dialog[open]::part(base) { border-radius: rem(6px); box-shadow: var(--ig-elevation-5); + // Should be fixed // border: rem(1px) solid var-get($theme, 'border-color'); border: rem(1px) solid color(gray, 400); } igc-dialog::part(footer) { + // Should be fixed // border-block-start: rem(1px) solid var-get($theme, 'border-color'); border-block-start: rem(1px) solid color(gray, 400); @@ -21,6 +23,10 @@ $theme: $indigo; igc-focus-trap { border: rem(1px) solid var-get($theme, 'border-color'); } + + [part='helper-text'] { + padding: 0; + } } [part='actions'] { From 509bed68d852bd9097507e21691a71afcec056e6 Mon Sep 17 00:00:00 2001 From: Silvia Ivanova <59446295+SisIvanova@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:29:56 +0300 Subject: [PATCH 38/63] Update date-picker.indigo.scss --- src/components/date-picker/themes/shared/date-picker.indigo.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/date-picker/themes/shared/date-picker.indigo.scss b/src/components/date-picker/themes/shared/date-picker.indigo.scss index 31c701870..af2d2c57a 100644 --- a/src/components/date-picker/themes/shared/date-picker.indigo.scss +++ b/src/components/date-picker/themes/shared/date-picker.indigo.scss @@ -14,7 +14,6 @@ $theme: $indigo; } igc-dialog::part(footer) { - // Should be fixed // border-block-start: rem(1px) solid var-get($theme, 'border-color'); border-block-start: rem(1px) solid color(gray, 400); From 7e4b683e9fad86b8674d00dc77e374d17fcde2e1 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Thu, 4 Apr 2024 18:09:47 +0300 Subject: [PATCH 39/63] fix: input handles label for material theme --- src/components/date-picker/date-picker.spec.ts | 2 +- src/components/date-picker/date-picker.ts | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index d63ef4a1e..df6d7af0c 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -423,7 +423,7 @@ describe('Date picker', () => { } }); - it('should render the label correctly', async () => { + it('should render the label correctly (valid for theme !== material)', async () => { picker.label = 'Test label'; await elementUpdated(picker); diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 4200d7772..49fe821b1 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -6,7 +6,8 @@ import { live } from 'lit/directives/live.js'; import { styles } from './themes/date-picker.base.css.js'; import { styles as shared } from './themes/shared/date-picker.common.css.js'; import { all } from './themes/themes.js'; -import { themes } from '../../theming/theming-decorator.js'; +import { themeSymbol, themes } from '../../theming/theming-decorator.js'; +import { Theme } from '../../theming/types.js'; import IgcCalendarComponent from '../calendar/calendar.js'; import { DateRangeDescriptor, @@ -80,7 +81,7 @@ const formats = new Set(['short', 'medium', 'long', 'full']); * @fires igcChange - Emitted when the user modifies and commits the elements's value. * @fires igcInput - Emitted when when the user types in the element. */ -@themes(all) +@themes(all, true) export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( EventEmitterMixin< IgcDatepickerEventMap, @@ -98,6 +99,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private static readonly increment = createCounter(); protected inputId = `datepicker-${IgcDatepickerComponent.increment()}`; + private declare readonly [themeSymbol]: Theme; + public override validators: Validator[] = [ requiredValidator, minDateValidator, @@ -137,6 +140,10 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( return this.mode === 'dropdown'; } + private get isMaterialTheme() { + return this[themeSymbol] === 'material'; + } + @query(IgcDateTimeInputComponent.tagName) private _input!: IgcDateTimeInputComponent; @@ -608,6 +615,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( id=${id} aria-haspopup="dialog" aria-expanded=${this.open} + label=${ifDefined(this.isMaterialTheme ? this.label : undefined)} input-format=${ifDefined(this._inputFormat)} display-format=${ifDefined(format)} ?disabled=${this.disabled} @@ -642,8 +650,10 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( protected override render() { const id = this.id || this.inputId; - return html`${this.renderLabel(id)}${this.renderInput(id)} - ${this.renderPicker(id)} ${this.renderHelperText()}`; + return html` + ${!this.isMaterialTheme ? this.renderLabel(id) : nothing} + ${this.renderInput(id)}${this.renderPicker(id)}${this.renderHelperText()} + `; } } From a471986e4e50344e52946fe8d67e93f059d7dafa Mon Sep 17 00:00:00 2001 From: sivanova Date: Fri, 5 Apr 2024 15:45:39 +0300 Subject: [PATCH 40/63] fix(date-picker): label styles --- .../date-picker/themes/dark/_themes.scss | 5 ++++ .../date-picker/themes/date-picker.base.scss | 13 +++++++++++ .../date-picker/themes/light/_themes.scss | 5 ++++ .../themes/shared/date-picker.bootstrap.scss | 10 ++++++++ .../themes/shared/date-picker.common.scss | 5 ++++ .../themes/shared/date-picker.fluent.scss | 23 +++++++++++++++++++ .../themes/shared/date-picker.indigo.scss | 12 ++++++++++ 7 files changed, 73 insertions(+) diff --git a/src/components/date-picker/themes/dark/_themes.scss b/src/components/date-picker/themes/dark/_themes.scss index dbf6de2c8..dccfbc480 100644 --- a/src/components/date-picker/themes/dark/_themes.scss +++ b/src/components/date-picker/themes/dark/_themes.scss @@ -4,21 +4,26 @@ @use 'components/input/themes/dark/themes' as input-theme; $material: map.merge(digest-schema($dark-material-calendar), ( + idle-secondary-color: map.get(input-theme.$material, 'idle-secondary-color'), helper-text-color: map.get(input-theme.$material, 'helper-text-color'), disabled-text-color: map.get(input-theme.$material, 'disabled-text-color'), error-secondary-color: map.get(input-theme.$material, 'error-secondary-color') )); $bootstrap: map.merge(digest-schema($dark-bootstrap-calendar), ( + idle-secondary-color: map.get(input-theme.$bootstrap, 'idle-secondary-color'), helper-text-color: map.get(input-theme.$bootstrap, 'helper-text-color'), disabled-text-color: map.get(input-theme.$bootstrap, 'disabled-text-color'), error-secondary-color: map.get(input-theme.$bootstrap, 'error-secondary-color') )); $fluent: map.merge(digest-schema($dark-fluent-calendar), ( + idle-secondary-color: map.get(input-theme.$fluent, 'idle-secondary-color'), helper-text-color: map.get(input-theme.$fluent, 'helper-text-color'), disabled-text-color: map.get(input-theme.$fluent, 'disabled-text-color'), error-secondary-color: map.get(input-theme.$fluent, 'error-secondary-color') )); $indigo: map.merge(digest-schema($dark-indigo-calendar), ( + idle-secondary-color: map.get(input-theme.$indigo, 'idle-secondary-color'), + focused-secondary-color: map.get(input-theme.$indigo, 'focused-secondary-color'), helper-text-color: map.get(input-theme.$indigo, 'helper-text-color'), disabled-text-color: map.get(input-theme.$indigo, 'disabled-text-color'), error-secondary-color: map.get(input-theme.$indigo, 'error-secondary-color') diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 94d47aece..048d8bed0 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -22,6 +22,12 @@ } } +:host([required]) { + [part='label']::after { + content: '*'; + } +} + [part='helper-text'] { line-height: 1; @@ -32,6 +38,13 @@ } } +[part='label'] { + display: inline-block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + [part='actions'] { display: flex; justify-content: flex-end; diff --git a/src/components/date-picker/themes/light/_themes.scss b/src/components/date-picker/themes/light/_themes.scss index 3a953b7be..461d2b4b9 100644 --- a/src/components/date-picker/themes/light/_themes.scss +++ b/src/components/date-picker/themes/light/_themes.scss @@ -5,21 +5,26 @@ $base: digest-schema($light-calendar); $material: map.merge(digest-schema($material-calendar), ( + idle-secondary-color: map.get(input-theme.$material, 'idle-secondary-color'), helper-text-color: map.get(input-theme.$material, 'helper-text-color'), disabled-text-color: map.get(input-theme.$material, 'disabled-text-color'), error-secondary-color: map.get(input-theme.$material, 'error-secondary-color') )); $bootstrap: map.merge(digest-schema($bootstrap-calendar), ( + idle-secondary-color: map.get(input-theme.$bootstrap, 'idle-secondary-color'), helper-text-color: map.get(input-theme.$bootstrap, 'helper-text-color'), disabled-text-color: map.get(input-theme.$bootstrap, 'disabled-text-color'), error-secondary-color: map.get(input-theme.$bootstrap, 'error-secondary-color') )); $fluent: map.merge(digest-schema($fluent-calendar), ( + idle-secondary-color: map.get(input-theme.$fluent, 'idle-secondary-color'), helper-text-color: map.get(input-theme.$fluent, 'helper-text-color'), disabled-text-color: map.get(input-theme.$fluent, 'disabled-text-color'), error-secondary-color: map.get(input-theme.$fluent, 'error-secondary-color') )); $indigo: map.merge(digest-schema($indigo-calendar), ( + idle-secondary-color: map.get(input-theme.$indigo, 'idle-secondary-color'), + focused-secondary-color: map.get(input-theme.$indigo, 'focused-secondary-color'), helper-text-color: map.get(input-theme.$indigo, 'helper-text-color'), disabled-text-color: map.get(input-theme.$indigo, 'disabled-text-color'), error-secondary-color: map.get(input-theme.$indigo, 'error-secondary-color') diff --git a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss index b0e9d71fe..f8f951272 100644 --- a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss +++ b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss @@ -25,6 +25,16 @@ $theme: $bootstrap; @include type-style('body-2'); } } + + [part~='label'] { + @include type-style('body-1'); + + margin-bottom: rem(4px); + + &:empty { + display: none; + } + } } [part='actions'] { diff --git a/src/components/date-picker/themes/shared/date-picker.common.scss b/src/components/date-picker/themes/shared/date-picker.common.scss index e0d62afa0..d4c66ce2a 100644 --- a/src/components/date-picker/themes/shared/date-picker.common.scss +++ b/src/components/date-picker/themes/shared/date-picker.common.scss @@ -41,6 +41,10 @@ $input-theme: input-theme.$material; background: var-get($theme, 'content-background'); } + [part~='label'] { + color: var-get($input-theme, 'idle-secondary-color'); + } + [part='helper-text'] { margin-top: rem(4px); padding-inline: pad-inline(rem(14px), rem(16px), rem(18px)); @@ -64,6 +68,7 @@ $input-theme: input-theme.$material; :host(:disabled), :host([disabled]) { + [part='label'], ::slotted([slot='helper-text']) { color: var-get($input-theme, 'disabled-text-color'); } diff --git a/src/components/date-picker/themes/shared/date-picker.fluent.scss b/src/components/date-picker/themes/shared/date-picker.fluent.scss index 70ed3977e..223665c8f 100644 --- a/src/components/date-picker/themes/shared/date-picker.fluent.scss +++ b/src/components/date-picker/themes/shared/date-picker.fluent.scss @@ -8,6 +8,10 @@ $theme: $fluent; border-radius: 0; } + [part='label'] { + @include type-style('subtitle-2'); + } + [part='helper-text'] { margin-top: rem(5px); padding-inline: 0; @@ -17,3 +21,22 @@ $theme: $fluent; [part='actions'] { min-height: #{sizable(rem(40px), rem(48px), rem(54px))}; } + +:host(:focus-within) { + [part='label'] { + color: color(gray, 800); + } +} + +:host([required]) { + [part='label']::after { + color: var-get($theme, 'error-secondary-color'); + } +} + +:host(:disabled), +:host([disabled]) { + [part='label']::after { + color: var-get($theme, 'disabled-text-color'); + } +} diff --git a/src/components/date-picker/themes/shared/date-picker.indigo.scss b/src/components/date-picker/themes/shared/date-picker.indigo.scss index af2d2c57a..6868fb444 100644 --- a/src/components/date-picker/themes/shared/date-picker.indigo.scss +++ b/src/components/date-picker/themes/shared/date-picker.indigo.scss @@ -1,7 +1,9 @@ @use 'styles/utilities' as *; @use '../light/themes' as *; +@use '../../../input/themes/light/themes' as input-theme; $theme: $indigo; +$input-theme: input-theme.$indigo; :host { igc-dialog[open]::part(base) { @@ -23,6 +25,10 @@ $theme: $indigo; border: rem(1px) solid var-get($theme, 'border-color'); } + [part~='label'] { + @include type-style('caption'); + } + [part='helper-text'] { padding: 0; } @@ -32,3 +38,9 @@ $theme: $indigo; min-height: #{sizable(rem(40px), rem(44px), rem(48px))}; padding: rem(8px) rem(16px); } + +:host(:focus-within) { + [part='label'] { + color: var-get($input-theme, 'focused-secondary-color'); + } +} \ No newline at end of file From 38f5b47b76dfa04134e8e89e1243c5e8b882dd73 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Mon, 8 Apr 2024 10:25:50 +0300 Subject: [PATCH 41/63] chore: add css parts annotations --- src/components/date-picker/date-picker.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 49fe821b1..f7f82a9a4 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -80,6 +80,13 @@ const formats = new Set(['short', 'medium', 'long', 'full']); * @fires igcClosed - Emitted after the calendar dropdown is hidden. * @fires igcChange - Emitted when the user modifies and commits the elements's value. * @fires igcInput - Emitted when when the user types in the element. + * + * @csspart calendar-icon - The calendar icon wrapper for closed state. + * @csspart calendar-icon-open - The calendar icon wrapper for opened state. + * @csspart clear-icon - The clear icon wrapper. + * @csspart actions - The actions wrapper. + * @csspart label - The label element. + * @csspart helper-text - The helper text wrapper. */ @themes(all, true) export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( From d839b32b444b9742af99c3ae1d3bbaaa5f61a31a Mon Sep 17 00:00:00 2001 From: sivanova Date: Mon, 8 Apr 2024 11:09:02 +0300 Subject: [PATCH 42/63] fix(date-picker): dropdown width --- src/components/date-picker/date-picker.ts | 1 - src/components/date-picker/themes/date-picker.base.scss | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index f7f82a9a4..785d2a3d6 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -579,7 +579,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( strategy="fixed" flip shift - same-width > ${this.renderCalendar(id)}${this.renderActions()} diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 048d8bed0..029bb0dd6 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -6,7 +6,7 @@ display: flex; flex: 1 0 0; flex-direction: column; - max-width: #{sizable(rem(290px), rem(314px), rem(360px))}; + min-width: #{sizable(rem(290px), rem(314px), rem(360px))}; overflow: hidden; box-shadow: var(--ig-elevation-3); From 5fc45395381be46af5eed84e5886c225642523f6 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Mon, 8 Apr 2024 12:03:52 +0300 Subject: [PATCH 43/63] fix: bind outlined prop in story --- stories/datepicker.stories.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index df376f274..4adc46a8b 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -329,6 +329,7 @@ export const Default: Story = { .headerOrientation=${args.headerOrientation} .nonEditable=${args.nonEditable} .orientation=${args.orientation} + .outlined=${args.outlined} .mode=${args.mode} .min=${args.min} .max=${args.max} From 3127554709d97f4717acd2555254ed0cf1070eed Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 9 Apr 2024 09:56:54 +0300 Subject: [PATCH 44/63] refactor: Change tag name * Added some API documentation and form validity test --- .../date-picker/date-picker.spec.ts | 58 ++++++++++-------- src/components/date-picker/date-picker.ts | 35 +++++++---- stories/datepicker.stories.ts | 59 ++++++++++++------- 3 files changed, 95 insertions(+), 57 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index df6d7af0c..0b2e1f309 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -31,15 +31,13 @@ describe('Date picker', () => { beforeEach(async () => { picker = await fixture( - html`` + html`` ); dateTimeInput = picker.shadowRoot!.querySelector( IgcDateTimeInputComponent.tagName - ) as IgcDateTimeInputComponent; + )!; - calendar = picker.shadowRoot!.querySelector( - IgcCalendarComponent.tagName - ) as IgcCalendarComponent; + calendar = picker.shadowRoot!.querySelector(IgcCalendarComponent.tagName)!; }); describe('Rendering and initialization', () => { @@ -71,7 +69,7 @@ describe('Date picker', () => { it('should render slotted elements - prefix, suffix, clear-icon, calendar-icon(-open), helper-text, title, actions', async () => { picker = await fixture( - html` + html` $ ~

For example, select your birthday

@@ -80,13 +78,13 @@ describe('Date picker', () => { ^ X -
` + ` ); await elementUpdated(picker); dateTimeInput = picker.shadowRoot!.querySelector( IgcDateTimeInputComponent.tagName - ) as IgcDateTimeInputComponent; + )!; const slotTests = [ { @@ -172,9 +170,9 @@ describe('Date picker', () => { it('should not render title slot elements in dropdown mode', async () => { picker = await fixture( - html` + html`

Custom title

-
` + ` ); await elementUpdated(picker); @@ -187,11 +185,11 @@ describe('Date picker', () => { it('should be successfully initialized with value', async () => { const expectedValue = new Date(2024, 1, 29); picker = await fixture( - html`` + html`` ); dateTimeInput = picker.shadowRoot!.querySelector( IgcDateTimeInputComponent.tagName - ) as IgcDateTimeInputComponent; + )!; expect(picker.value).not.to.be.null; checkDatesEqual(picker.value!, expectedValue); @@ -201,11 +199,11 @@ describe('Date picker', () => { it('should be successfully initialized in open state in dropdown mode', async () => { picker = await fixture( - html`` + html`` ); calendar = picker.shadowRoot!.querySelector( IgcCalendarComponent.tagName - ) as IgcCalendarComponent; + )!; expect(picker.mode).to.equal('dropdown'); picker.show(); @@ -219,11 +217,11 @@ describe('Date picker', () => { it('should be successfully initialized in open state in dialog mode', async () => { picker = await fixture( - html`` + html`` ); calendar = picker.shadowRoot!.querySelector( IgcCalendarComponent.tagName - ) as IgcCalendarComponent; + )!; expect(picker.mode).to.equal('dialog'); picker.show(); @@ -455,7 +453,7 @@ describe('Date picker', () => { it('should initialize activeDate = value when it is not set, but value is', async () => { const valueDate = after10DaysDate; picker = await fixture( - html`` + html`` ); await elementUpdated(picker); @@ -785,7 +783,7 @@ describe('Date picker', () => { describe('Form integration', () => { const spec = new FormAssociatedTestBed( - html`` + html`` ); beforeEach(async () => { @@ -888,17 +886,31 @@ describe('Date picker', () => { spec.element.setCustomValidity(''); spec.submitValidates(); }); + + it('synchronous form validation', async () => { + spec.element.required = true; + await elementUpdated(spec.element); + + expect(spec.form.checkValidity()).to.be.false; + spec.submitFails(); + + spec.reset(); + + spec.element.value = new Date(); + expect(spec.form.checkValidity()).to.be.true; + spec.submitValidates(); + }); }); }); const selectCurrentDate = (calendar: IgcCalendarComponent) => { - const daysView = calendar.shadowRoot?.querySelector( - 'igc-days-view' - ) as IgcDaysViewComponent; + const daysView = calendar.shadowRoot!.querySelector( + IgcDaysViewComponent.tagName + )!; - const currentDaySpan = daysView.shadowRoot?.querySelector( + const currentDaySpan = daysView.shadowRoot!.querySelector( 'span[part~="current"]' - ) as HTMLElement; + )!; simulateClick(currentDaySpan?.children[0]); }; diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 785d2a3d6..6db0d1cae 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -63,7 +63,10 @@ const converter: ComplexAttributeConverter = { const formats = new Set(['short', 'medium', 'long', 'full']); /** - * @element igc-datepicker + * igc-datepicker is a feature rich component used for entering a date through manual text input or + * choosing date values from a calendar dialog that pops up. + * + * @element igc-date-picker * * @slot prefix - Renders content before the input. * @slot suffix - Renders content after the input. @@ -95,7 +98,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( AbstractConstructor >(IgcBaseComboBoxLikeComponent) ) { - public static readonly tagName = 'igc-datepicker'; + public static readonly tagName = 'igc-date-picker'; public static styles = [styles, shared]; protected static shadowRootOptions = { @@ -104,7 +107,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( }; private static readonly increment = createCounter(); - protected inputId = `datepicker-${IgcDatepickerComponent.increment()}`; + protected inputId = `date-picker-${IgcDatepickerComponent.increment()}`; private declare readonly [themeSymbol]: Theme; @@ -140,7 +143,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private _inputFormat?: string; private _rootClickController = addRootClickHandler(this, { - hideCallback: () => this._hide(true), + hideCallback: this.handleClosing, }); private get isDropDown() { @@ -222,6 +225,10 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( return this._value ?? null; } + /** + * Gets/Sets the date which is shown in the calendar picker and is highlighted. + * By default it is the current date. + */ @property({ attribute: 'active-date', converter: converter }) public set activeDate(value: Date) { this._activeDate = value; @@ -479,6 +486,10 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this.emitEvent('igcInput', { detail: this.value }); } + protected handleClosing() { + this._hide(true); + } + protected onSlotChange() { this.requestUpdate(); } @@ -520,13 +531,12 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( } private renderCalendar(id: string) { - const role = this.isDropDown ? 'dialog' : undefined; const hideHeader = this.isDropDown ? true : this.hideHeader; return html` this._hide(true)} + @igcClosing=${this.handleClosing} > ${this.renderCalendar(id)}${this.renderActions()} @@ -601,12 +611,12 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private renderLabel(id: string) { return this.label - ? html`` + ? html`` : nothing; } private renderHelperText() { - return html`
+ return html`
`; } @@ -620,7 +630,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( ${this.renderClearIcon()}${this.renderCalendarIcon()} @@ -665,6 +674,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( declare global { interface HTMLElementTagNameMap { - 'igc-datepicker': IgcDatepickerComponent; + 'igc-date-picker': IgcDatepickerComponent; } } diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index 4adc46a8b..a3895d857 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -21,7 +21,12 @@ const metadata: Meta = { title: 'Datepicker', component: 'igc-datepicker', parameters: { - docs: { description: { component: '' } }, + docs: { + description: { + component: + 'igc-datepicker is a feature rich component used for entering a date through manual text input or\nchoosing date values from a calendar dialog that pops up.', + }, + }, actions: { handles: [ 'igcOpening', @@ -70,7 +75,12 @@ const metadata: Meta = { description: 'The value of the picker', control: 'date', }, - activeDate: { type: 'Date', control: 'date' }, + activeDate: { + type: 'Date', + description: + 'Gets/Sets the date which is shown in the calendar picker and is highlighted.\nBy default it is the current date.', + control: 'date', + }, min: { type: 'Date', description: @@ -249,6 +259,10 @@ interface IgcDatepickerArgs { readOnly: boolean; /** The value of the picker */ value: Date; + /** + * Gets/Sets the date which is shown in the calendar picker and is highlighted. + * By default it is the current date. + */ activeDate: Date; /** The minimum value required for the date picker to remain valid. */ min: Date; @@ -316,7 +330,7 @@ export const Default: Story = { }, render: (args) => html`
- - +
`, }; @@ -371,7 +385,7 @@ export const Slots: Story = { }, render: (args) => html`
- Single month view
- +
`, }; @@ -440,54 +454,57 @@ export const Form: Story = { render: (args) => html`
- - + - + + >
- + >
- + >
- +

Choose a date after ${minDate.toLocaleDateString()}

-
+ - +

Choose a date before ${maxDate.toLocaleDateString()}

-
+
- + >
${formControls()} From 004234e91b2e929edd5b25a142faefc773ef223f Mon Sep 17 00:00:00 2001 From: ddaribo Date: Tue, 9 Apr 2024 18:16:04 +0300 Subject: [PATCH 45/63] feat: export nested components css parts --- src/components/date-picker/date-picker.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 6db0d1cae..087b6b95f 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -551,6 +551,11 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( .specialDates=${this.specialDates} .weekStart=${this.weekStart} @igcChange=${this.handleCalendarChangeEvent} + exportparts="header, header-title, header-date, content: calendar-content, navigation, months-navigation, + years-navigation, years-range, navigation-buttons, navigation-button, days-view-container, + days-view, months-view, years-view, days-row, label: calendar-label, week-number, week-number-inner, date, + date-inner, first, last, inactive, hidden, weekend, range, special, disabled, single, preview, + month, month-inner, year, year-inner, selected, current" > ${!this.isDropDown ? html` ${this.renderCalendar(id)}${this.renderActions()} @@ -646,6 +652,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( .invalid=${live(this.invalid)} @igcChange=${this.handleInputChangeEvent} @igcInput=${this.handleInputEvent} + exportparts="input, label, prefix, suffix" > Date: Thu, 11 Apr 2024 10:49:45 +0300 Subject: [PATCH 46/63] chore: Fixed slot showcase story --- stories/datepicker.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index a3895d857..03e903953 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -385,7 +385,7 @@ export const Slots: Story = { }, render: (args) => html`
- Date: Thu, 11 Apr 2024 15:47:02 +0300 Subject: [PATCH 47/63] refactor(date-picker): improve styles --- src/components/date-picker/themes/date-picker.base.scss | 1 - .../date-picker/themes/shared/date-picker.bootstrap.scss | 2 +- .../date-picker/themes/shared/date-picker.common.scss | 5 +++++ .../date-picker/themes/shared/date-picker.indigo.scss | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 029bb0dd6..fc4be7a6e 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -8,7 +8,6 @@ flex-direction: column; min-width: #{sizable(rem(290px), rem(314px), rem(360px))}; overflow: hidden; - box-shadow: var(--ig-elevation-3); igc-calendar { border: none; diff --git a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss index f8f951272..b459f528a 100644 --- a/src/components/date-picker/themes/shared/date-picker.bootstrap.scss +++ b/src/components/date-picker/themes/shared/date-picker.bootstrap.scss @@ -15,7 +15,7 @@ $theme: $bootstrap; } igc-focus-trap { - border: rem(1px) solid var-get($theme, 'border-color'); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'border-color'); } [part='helper-text'] { diff --git a/src/components/date-picker/themes/shared/date-picker.common.scss b/src/components/date-picker/themes/shared/date-picker.common.scss index d4c66ce2a..0686332e9 100644 --- a/src/components/date-picker/themes/shared/date-picker.common.scss +++ b/src/components/date-picker/themes/shared/date-picker.common.scss @@ -41,6 +41,11 @@ $input-theme: input-theme.$material; background: var-get($theme, 'content-background'); } + igc-popover::part(fixed) { + box-shadow: var(--ig-elevation-3); + border-radius: var-get($theme, 'border-radius'); + } + [part~='label'] { color: var-get($input-theme, 'idle-secondary-color'); } diff --git a/src/components/date-picker/themes/shared/date-picker.indigo.scss b/src/components/date-picker/themes/shared/date-picker.indigo.scss index 6868fb444..a13d1da3b 100644 --- a/src/components/date-picker/themes/shared/date-picker.indigo.scss +++ b/src/components/date-picker/themes/shared/date-picker.indigo.scss @@ -22,7 +22,7 @@ $input-theme: input-theme.$indigo; } igc-focus-trap { - border: rem(1px) solid var-get($theme, 'border-color'); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'border-color'); } [part~='label'] { From f2f7a7a8419b16536e5ca4706b6dbac44bc99cd7 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 12 Apr 2024 08:45:24 +0300 Subject: [PATCH 48/63] fix: Stop dialog closing events bubbling up --- src/components/date-picker/date-picker.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 087b6b95f..0a3d95f6d 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -63,7 +63,7 @@ const converter: ComplexAttributeConverter = { const formats = new Set(['short', 'medium', 'long', 'full']); /** - * igc-datepicker is a feature rich component used for entering a date through manual text input or + * igc-date-picker is a feature rich component used for entering a date through manual text input or * choosing date values from a calendar dialog that pops up. * * @element igc-date-picker @@ -490,6 +490,15 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this._hide(true); } + protected handleDialogClosing(event: Event) { + event.stopPropagation(); + this._hide(true); + } + + protected handleDialogClosed(event: Event) { + event.stopPropagation(); + } + protected onSlotChange() { this.requestUpdate(); } @@ -607,7 +616,8 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( ?open=${this.open} ?close-on-outside-click=${!this.keepOpenOnOutsideClick} hide-default-action - @igcClosing=${this.handleClosing} + @igcClosing=${this.handleDialogClosing} + @igcClosed=${this.handleDialogClosed} exportparts="base: dialog-base, title, footer, overlay" > ${this.renderCalendar(id)}${this.renderActions()} From 7d1d77894fb264f421f6e6686eb8c361dd88ac7f Mon Sep 17 00:00:00 2001 From: Simeon Simeonoff Date: Fri, 12 Apr 2024 11:24:34 +0300 Subject: [PATCH 49/63] fix(date-picker): default border-color not inherited from date-picker --- .../date-picker/themes/dark/date-picker.indigo.scss | 5 +++++ .../date-picker/themes/light/date-picker.indigo.scss | 5 +++++ .../date-picker/themes/shared/date-picker.indigo.scss | 8 +++----- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/date-picker/themes/dark/date-picker.indigo.scss b/src/components/date-picker/themes/dark/date-picker.indigo.scss index cea5626d0..f100796b1 100644 --- a/src/components/date-picker/themes/dark/date-picker.indigo.scss +++ b/src/components/date-picker/themes/dark/date-picker.indigo.scss @@ -1,7 +1,12 @@ +@use 'sass:map'; @use 'styles/utilities' as *; @use 'themes' as *; @use '../light/themes' as light; :host { @include css-vars-from-theme(diff(light.$base, $indigo), 'ig-datepicker'); + + igc-dialog[open]::part(base) { + --border-color: var(--ig-dialog-border-color, #{map.get($indigo, 'border-color')}); + } } diff --git a/src/components/date-picker/themes/light/date-picker.indigo.scss b/src/components/date-picker/themes/light/date-picker.indigo.scss index 297a9046d..767ffdecd 100644 --- a/src/components/date-picker/themes/light/date-picker.indigo.scss +++ b/src/components/date-picker/themes/light/date-picker.indigo.scss @@ -1,3 +1,4 @@ +@use 'sass:map'; @use 'styles/utilities' as *; @use 'themes' as *; @@ -5,4 +6,8 @@ $theme: $indigo; :host { @include css-vars-from-theme(diff($base, $theme), 'ig-datepicker'); + + igc-dialog[open]::part(base) { + --border-color: var(--ig-dialog-border-color, #{map.get($theme, 'border-color')}); + } } diff --git a/src/components/date-picker/themes/shared/date-picker.indigo.scss b/src/components/date-picker/themes/shared/date-picker.indigo.scss index a13d1da3b..af715d841 100644 --- a/src/components/date-picker/themes/shared/date-picker.indigo.scss +++ b/src/components/date-picker/themes/shared/date-picker.indigo.scss @@ -1,3 +1,4 @@ +@use 'sass:map'; @use 'styles/utilities' as *; @use '../light/themes' as *; @use '../../../input/themes/light/themes' as input-theme; @@ -9,10 +10,7 @@ $input-theme: input-theme.$indigo; igc-dialog[open]::part(base) { border-radius: rem(6px); box-shadow: var(--ig-elevation-5); - - // Should be fixed - // border: rem(1px) solid var-get($theme, 'border-color'); - border: rem(1px) solid color(gray, 400); + border: rem(1px) solid var-get($theme, 'border-color'); } igc-dialog::part(footer) { @@ -43,4 +41,4 @@ $input-theme: input-theme.$indigo; [part='label'] { color: var-get($input-theme, 'focused-secondary-color'); } -} \ No newline at end of file +} From 74359fe16437a07dfa6c5180a7f5fe262e78af35 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 12 Apr 2024 14:20:58 +0300 Subject: [PATCH 50/63] docs: Added Shadow DOM parts for API docs --- src/components/date-picker/date-picker.ts | 44 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 0a3d95f6d..463d3f05e 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -84,12 +84,52 @@ const formats = new Set(['short', 'medium', 'long', 'full']); * @fires igcChange - Emitted when the user modifies and commits the elements's value. * @fires igcInput - Emitted when when the user types in the element. * + * @csspart label - The label wrapper that renders content above the target input. + * @csspart container - The main wrapper that holds all main input elements. + * @csspart input - The native input element. + * @csspart prefix - The prefix wrapper. + * @csspart suffix - The suffix wrapper. * @csspart calendar-icon - The calendar icon wrapper for closed state. * @csspart calendar-icon-open - The calendar icon wrapper for opened state. * @csspart clear-icon - The clear icon wrapper. * @csspart actions - The actions wrapper. - * @csspart label - The label element. - * @csspart helper-text - The helper text wrapper. + * @csspart helper-text - The helper-text wrapper that renders content below the target input. + * @csspart header - The calendar header element. + * @csspart header-title - The calendar header title element. + * @csspart header-date - The calendar header date element. + * @csspart calendar-content - The calendar content element which contains the views and navigation elements. + * @csspart navigation - The calendar navigation container element. + * @csspart months-navigation - The calendar months navigation button element. + * @csspart years-navigation - The calendar years navigation button element. + * @csspart years-range - The calendar years range element. + * @csspart navigation-buttons - The calendar navigation buttons container. + * @csspart navigation-button - The calendar previous/next navigation button. + * @csspart days-view-container - The calendar days view container element. + * @csspart days-view - The calendar days view element. + * @csspart months-view - The calendar months view element. + * @csspart years-view - The calendar years view element. + * @csspart days-row - The calendar days row element. + * @csspart calendar-label - The calendar week header label element. + * @csspart week-number - The calendar week number element. + * @csspart week-number-inner - The calendar week number inner element. + * @csspart date - The calendar date element. + * @csspart date-inner - The calendar date inner element. + * @csspart first - The calendar first selected date element in range selection. + * @csspart last - The calendar last selected date element in range selection. + * @csspart inactive - The calendar inactive date element. + * @csspart hidden - The calendar hidden date element. + * @csspart weekend - The calendar weekend date element. + * @csspart range - The calendar range selected element. + * @csspart special - The calendar special date element. + * @csspart disabled - The calendar disabled date element. + * @csspart single - The calendar single selected date element. + * @csspart preview - The calendar range selection preview date element. + * @csspart month - The calendar month element. + * @csspart month-inner - The calendar month inner element. + * @csspart year - The calendar year element. + * @csspart year-inner - The calendar year inner element. + * @csspart selected - The calendar selected state for element(s). Applies to date, month and year elements. + * @csspart current - The calendar current state for element(s). Applies to date, month and year elements. */ @themes(all, true) export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( From d3ddde8ae91c958fb40d803649edbe4b2c251a26 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 12 Apr 2024 14:48:55 +0300 Subject: [PATCH 51/63] chore: Added analyzer tags for value two-way bindings --- src/components/date-picker/date-picker.ts | 1 + src/components/date-time-input/date-time-input.ts | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 463d3f05e..1628aa50f 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -247,6 +247,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( @property({ type: Boolean, reflect: true, attribute: 'readonly' }) public readOnly = false; + /* @tsTwoWayProperty(true, "igcChange", "detail", false) */ /** * The value of the picker * @attr diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index eef2257b8..d253794b0 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -19,7 +19,6 @@ import { arrowUp, ctrlKey, } from '../common/controllers/key-bindings.js'; -import { blazorTwoWayBind } from '../common/decorators/blazorTwoWayBind.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import { AbstractConstructor } from '../common/mixins/constructor.js'; @@ -144,16 +143,16 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< } } - /** - * The value of the input. - * @attr - */ public get value(): Date | null { return this._value; } + /* @tsTwoWayProperty(true, "igcChange", "detail", false) */ + /** + * The value of the input. + * @attr + */ @property({ converter: converter }) - @blazorTwoWayBind('igcChange', 'detail') public set value(val: Date | null) { this._value = val ? DateTimeUtil.isValidDate(val) From db797672293acbf467b28a9d5155cf47ce0efb08 Mon Sep 17 00:00:00 2001 From: sivanova Date: Mon, 15 Apr 2024 11:12:56 +0300 Subject: [PATCH 52/63] fix(date-picker): actions part border --- .../date-picker/themes/dark/date-picker.indigo.scss | 5 ----- .../themes/light/date-picker.indigo.scss | 5 ----- .../themes/shared/date-picker.common.scss | 13 +++++++------ .../themes/shared/date-picker.indigo.scss | 6 ------ 4 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/components/date-picker/themes/dark/date-picker.indigo.scss b/src/components/date-picker/themes/dark/date-picker.indigo.scss index f100796b1..cea5626d0 100644 --- a/src/components/date-picker/themes/dark/date-picker.indigo.scss +++ b/src/components/date-picker/themes/dark/date-picker.indigo.scss @@ -1,12 +1,7 @@ -@use 'sass:map'; @use 'styles/utilities' as *; @use 'themes' as *; @use '../light/themes' as light; :host { @include css-vars-from-theme(diff(light.$base, $indigo), 'ig-datepicker'); - - igc-dialog[open]::part(base) { - --border-color: var(--ig-dialog-border-color, #{map.get($indigo, 'border-color')}); - } } diff --git a/src/components/date-picker/themes/light/date-picker.indigo.scss b/src/components/date-picker/themes/light/date-picker.indigo.scss index 767ffdecd..297a9046d 100644 --- a/src/components/date-picker/themes/light/date-picker.indigo.scss +++ b/src/components/date-picker/themes/light/date-picker.indigo.scss @@ -1,4 +1,3 @@ -@use 'sass:map'; @use 'styles/utilities' as *; @use 'themes' as *; @@ -6,8 +5,4 @@ $theme: $indigo; :host { @include css-vars-from-theme(diff($base, $theme), 'ig-datepicker'); - - igc-dialog[open]::part(base) { - --border-color: var(--ig-dialog-border-color, #{map.get($theme, 'border-color')}); - } } diff --git a/src/components/date-picker/themes/shared/date-picker.common.scss b/src/components/date-picker/themes/shared/date-picker.common.scss index 0686332e9..f4561700a 100644 --- a/src/components/date-picker/themes/shared/date-picker.common.scss +++ b/src/components/date-picker/themes/shared/date-picker.common.scss @@ -2,7 +2,7 @@ @use '../light/themes' as *; @use '../../../input/themes/light/themes' as input-theme; -$theme: $base; +$theme: $material; $input-theme: input-theme.$material; :host { @@ -13,6 +13,10 @@ $input-theme: input-theme.$material; [part='actions'] { border: none; } + + igc-calendar { + box-shadow: none; + } } igc-dialog[open]::part(base) { @@ -26,10 +30,7 @@ $input-theme: input-theme.$material; igc-dialog::part(footer) { background: var-get($theme, 'content-background'); - - // Should be fixed - // border-block-start: rem(1px) solid var-get($theme, 'border-color'); - border-block-start: rem(1px) solid color(gray, 200); + border-block-start: rem(1px) solid var-get($theme, 'actions-divider-color'); } igc-focus-trap { @@ -62,7 +63,7 @@ $input-theme: input-theme.$material; [part='actions'] { min-height: #{sizable(rem(40px), rem(46px), rem(52px))}; - border-block-start: rem(1px) solid var-get($theme, 'border-color'); + border-block-start: rem(1px) solid var-get($theme, 'actions-divider-color'); } :host([invalid]) { diff --git a/src/components/date-picker/themes/shared/date-picker.indigo.scss b/src/components/date-picker/themes/shared/date-picker.indigo.scss index af715d841..3c5a808f9 100644 --- a/src/components/date-picker/themes/shared/date-picker.indigo.scss +++ b/src/components/date-picker/themes/shared/date-picker.indigo.scss @@ -13,12 +13,6 @@ $input-theme: input-theme.$indigo; border: rem(1px) solid var-get($theme, 'border-color'); } - igc-dialog::part(footer) { - // Should be fixed - // border-block-start: rem(1px) solid var-get($theme, 'border-color'); - border-block-start: rem(1px) solid color(gray, 400); - } - igc-focus-trap { box-shadow: 0 0 0 rem(1px) var-get($theme, 'border-color'); } From 49c26f1d136820429ed7babf0782255319505092 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 15 Apr 2024 11:50:51 +0300 Subject: [PATCH 53/63] fix: Added slotchange listeners to actions slot * Added blazor metadata for additional dependencies --- src/components/date-picker/date-picker.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 1628aa50f..ac5790560 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -21,6 +21,7 @@ import { escapeKey, } from '../common/controllers/key-bindings.js'; import { addRootClickHandler } from '../common/controllers/root-click.js'; +import { blazorAdditionalDependencies } from '../common/decorators/blazorAdditionalDependencies.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import { @@ -132,6 +133,9 @@ const formats = new Set(['short', 'medium', 'long', 'full']); * @csspart current - The calendar current state for element(s). Applies to date, month and year elements. */ @themes(all, true) +@blazorAdditionalDependencies( + 'IgcCalendarComponent, IgcDateTimeInputComponent, IgcDialogComponent, IgcIconComponent' +) export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( EventEmitterMixin< IgcDatepickerEventMap, @@ -630,7 +634,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this.isDropDown || !this.actions.length ? undefined : 'footer' )} > - +
`; } From e001c6f94fe6b86add5d7d7c7fc57616878ce1b8 Mon Sep 17 00:00:00 2001 From: sivanova Date: Thu, 18 Apr 2024 16:25:46 +0300 Subject: [PATCH 54/63] fix(date-picker): dialog mode typography issue --- src/components/dialog/themes/dialog.base.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/dialog/themes/dialog.base.scss b/src/components/dialog/themes/dialog.base.scss index 097eb4e2b..75f9d0e45 100644 --- a/src/components/dialog/themes/dialog.base.scss +++ b/src/components/dialog/themes/dialog.base.scss @@ -49,7 +49,8 @@ } } -::slotted(*) { +// Calendar should be excluded because of the date picker dialog mode +::slotted(:not(igc-calendar)) { font: inherit; letter-spacing: inherit; line-height: inherit; From ca56cf0a594d7934cb4d8d71d75fabac431c81e5 Mon Sep 17 00:00:00 2001 From: sivanova Date: Fri, 19 Apr 2024 09:57:11 +0300 Subject: [PATCH 55/63] refactor(date-picker): change calendar icon --- src/components/date-picker/date-picker.ts | 2 +- src/components/icon/internal-icons-lib.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index ac5790560..4301192ae 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -567,7 +567,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private renderCalendarIcon() { const defaultIcon = html` diff --git a/src/components/icon/internal-icons-lib.ts b/src/components/icon/internal-icons-lib.ts index 4b859bdf5..4c09aa6ad 100644 --- a/src/components/icon/internal-icons-lib.ts +++ b/src/components/icon/internal-icons-lib.ts @@ -49,4 +49,7 @@ export const internalIcons: IconCollection = { calendar: { svg: ``, }, + calendar_today: { + svg: ``, + }, }; From 8a46c331bf7c86e9c2846f77eaa4eeac06db774a Mon Sep 17 00:00:00 2001 From: sivanova Date: Fri, 19 Apr 2024 15:50:26 +0300 Subject: [PATCH 56/63] fix(date-picker): horizontal scroll in dropdown mode --- src/components/date-picker/themes/date-picker.base.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index fc4be7a6e..92b555103 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -2,12 +2,15 @@ @use 'styles/utilities' as *; :host { + --min-width: #{sizable(rem(290px), rem(314px), rem(360px))}; + igc-focus-trap { display: flex; flex: 1 0 0; flex-direction: column; - min-width: #{sizable(rem(290px), rem(314px), rem(360px))}; - overflow: hidden; + min-width: var(--min-width); + max-width: calc(var(--min-width)*2); + overflow: auto hidden; igc-calendar { border: none; From 04d848d3e4f67ff62159bfeffae986f08d20e426 Mon Sep 17 00:00:00 2001 From: sivanova Date: Fri, 19 Apr 2024 16:06:12 +0300 Subject: [PATCH 57/63] fix(date-picker): lint error --- src/components/date-picker/themes/date-picker.base.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 92b555103..9c49e8677 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -2,14 +2,15 @@ @use 'styles/utilities' as *; :host { - --min-width: #{sizable(rem(290px), rem(314px), rem(360px))}; igc-focus-trap { + --min-width: #{sizable(rem(290px), rem(314px), rem(360px))}; + display: flex; flex: 1 0 0; flex-direction: column; min-width: var(--min-width); - max-width: calc(var(--min-width)*2); + max-width: calc(var(--min-width) * 2); overflow: auto hidden; igc-calendar { From d419be879b367731c4f40bb28928f22e1d229940 Mon Sep 17 00:00:00 2001 From: Silvia Ivanova <59446295+SisIvanova@users.noreply.github.com> Date: Fri, 19 Apr 2024 16:13:07 +0300 Subject: [PATCH 58/63] Update date-picker.base.scss --- src/components/date-picker/themes/date-picker.base.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/date-picker/themes/date-picker.base.scss b/src/components/date-picker/themes/date-picker.base.scss index 9c49e8677..abd0643a5 100644 --- a/src/components/date-picker/themes/date-picker.base.scss +++ b/src/components/date-picker/themes/date-picker.base.scss @@ -2,7 +2,6 @@ @use 'styles/utilities' as *; :host { - igc-focus-trap { --min-width: #{sizable(rem(290px), rem(314px), rem(360px))}; From 22817250ca8b3f3bcb547f67fd80e31a7be307de Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 22 Apr 2024 10:03:17 +0300 Subject: [PATCH 59/63] feat: min/max disables dates in the calendar widget --- .../date-picker/date-picker.spec.ts | 2 +- src/components/date-picker/date-picker.ts | 69 ++++++++++++++----- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 0b2e1f309..1f98dc4d3 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -401,7 +401,7 @@ describe('Date picker', () => { await elementUpdated(picker); for (const [prop, value] of Object.entries(props)) { - expect((calendar as any)[prop]).to.equal(value); + expect((calendar as any)[prop]).to.eql(value); } }); diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 4301192ae..1551c83b4 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -11,6 +11,7 @@ import { Theme } from '../../theming/types.js'; import IgcCalendarComponent from '../calendar/calendar.js'; import { DateRangeDescriptor, + DateRangeType, isDateInRanges, } from '../calendar/common/calendar.model.js'; import { @@ -155,7 +156,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private declare readonly [themeSymbol]: Theme; - public override validators: Validator[] = [ + protected override validators: Validator[] = [ requiredValidator, minDateValidator, maxDateValidator, @@ -171,7 +172,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( public static register() { registerComponent( - this, + IgcDatepickerComponent, IgcCalendarComponent, IgcDateTimeInputComponent, IgcFocusTrapComponent, @@ -183,6 +184,10 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( private _value?: Date | null; private _activeDate?: Date | null; + private _min?: Date; + private _max?: Date; + private _disabledDates?: DateRangeDescriptor[]; + private _dateConstraints?: DateRangeDescriptor[]; private _displayFormat?: string; private _inputFormat?: string; @@ -288,14 +293,30 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( * @attr */ @property({ converter: converter }) - public min!: Date; + public set min(value: Date) { + this._min = value; + this.setDateConstraints(); + this.updateValidity(); + } + + public get min(): Date { + return this._min as Date; + } /** * The maximum value required for the date picker to remain valid. * @attr */ @property({ converter: converter }) - public max!: Date; + public set max(value: Date) { + this._max = value; + this.setDateConstraints(); + this.updateValidity(); + } + + public get max(): Date { + return this._max as Date; + } /** The orientation of the calendar header. * @attr header-orientation @@ -324,7 +345,15 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( /** Gets/sets disabled dates. */ @property({ attribute: false }) - public disabledDates!: DateRangeDescriptor[]; + public set disabledDates(dates: DateRangeDescriptor[]) { + this._disabledDates = dates; + this.setDateConstraints(); + this.updateValidity(); + } + + public get disabledDates() { + return this._disabledDates as DateRangeDescriptor[]; + } /** Gets/sets special dates. */ @property({ attribute: false }) @@ -420,13 +449,6 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( | 'friday' | 'saturday' = 'sunday'; - @watch('min', { waitUntilFirstUpdate: true }) - @watch('max', { waitUntilFirstUpdate: true }) - @watch('disabledDates', { waitUntilFirstUpdate: true }) - protected constraintChange() { - this.updateValidity(); - } - constructor() { super(); @@ -548,6 +570,21 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( this.requestUpdate(); } + private setDateConstraints() { + const dates: DateRangeDescriptor[] = []; + if (this._min) { + dates.push({ type: DateRangeType.Before, dateRange: [this._min] }); + } + if (this._max) { + dates.push({ type: DateRangeType.After, dateRange: [this._max] }); + } + if (this._disabledDates?.length) { + dates.push(...this.disabledDates); + } + + this._dateConstraints = dates.length ? dates : undefined; + } + private renderClearIcon() { return !this.value ? nothing @@ -601,7 +638,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( .orientation=${this.orientation} .visibleMonths=${this.visibleMonths} .locale=${this.locale} - .disabledDates=${this.disabledDates} + .disabledDates=${this._dateConstraints!} .specialDates=${this.specialDates} .weekStart=${this.weekStart} @igcChange=${this.handleCalendarChangeEvent} @@ -625,14 +662,14 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( } protected renderActions() { + const slot = this.isDropDown || !this.actions.length ? undefined : 'footer'; + // If in dialog mode use the dialog footer slot return html`
From 6944d0e1dc9a36bf41445a8c138597ed2c22a7e8 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 22 Apr 2024 10:11:22 +0300 Subject: [PATCH 60/63] docs: Added CHANGELOG entry --- CHANGELOG.md | 1 + stories/datepicker.stories.ts | 156 +++++++++++++++++----------------- 2 files changed, 77 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fd0b633..8469d6c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - Button group component now allows resetting the selection state via the `selectedItems` property [#1168](https://github.com/IgniteUI/igniteui-webcomponents/pull/1168) +- Date picker component [#174](https://github.com/IgniteUI/igniteui-webcomponents/issues/174) ### Changed - Combo, Select and Dropdown components now use the native Popover API [#1082](https://github.com/IgniteUI/igniteui-webcomponents/pull/1082) diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index 03e903953..cf0dd2507 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -329,37 +329,35 @@ export const Default: Story = { label: 'Pick a date', }, render: (args) => html` -
- - -
+ + `, }; @@ -384,56 +382,54 @@ export const Slots: Story = { label: 'Pick a date', }, render: (args) => html` -
- - $ - 🦀 -

For example, select your birthday

-

🎉 Custom title 🎉

- 👩‍💻 - 👨‍💻 - 🗑️ + + $ + 🦀 +

For example, select your birthday

+

🎉 Custom title 🎉

+ 👩‍💻 + 👨‍💻 + 🗑️ -
- Select today - Trimester view - Single month view -
-
-
+
+ Select today + Trimester view + Single month view +
+ `, }; From 29db60f8ffaa3ed801dcf8cd1638d30ce23909ce Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 24 Apr 2024 12:16:46 +0300 Subject: [PATCH 61/63] fix: Biomejs transition errors * Refactored focus-trap to be easier to test and hidden from Blazor --- src/components/common/validators.ts | 2 +- .../date-picker/date-picker.spec.ts | 14 ++-- src/components/date-picker/date-picker.ts | 26 +++--- src/components/date-picker/themes/themes.ts | 2 +- .../date-time-input/date-time-input.ts | 84 +++++++++---------- src/components/focus-trap/focus-trap.spec.ts | 16 ++-- src/components/focus-trap/focus-trap.ts | 20 +++-- stories/datepicker.stories.ts | 12 +-- 8 files changed, 86 insertions(+), 90 deletions(-) diff --git a/src/components/common/validators.ts b/src/components/common/validators.ts index aa0d8429d..d530ad022 100644 --- a/src/components/common/validators.ts +++ b/src/components/common/validators.ts @@ -1,6 +1,6 @@ +import { DateTimeUtil } from '../date-time-input/date-util.js'; import validatorMessages from './localization/validation-en.js'; import { asNumber, format, isDefined } from './util.js'; -import { DateTimeUtil } from '../date-time-input/date-util.js'; type ValidatorHandler = (host: T) => boolean; type ValidatorMessageFormat = (host: T) => string; diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 1f98dc4d3..5aadb14fd 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -1,10 +1,9 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import { spy } from 'sinon'; -import IgcDatepickerComponent from './date-picker.js'; import IgcCalendarComponent from '../calendar/calendar.js'; import { - DateRangeDescriptor, + type DateRangeDescriptor, DateRangeType, } from '../calendar/common/calendar.model.js'; import IgcDaysViewComponent from '../calendar/days-view/days-view.js'; @@ -21,6 +20,7 @@ import { simulateKeyboard, } from '../common/utils.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; +import IgcDatepickerComponent from './date-picker.js'; describe('Date picker', () => { before(() => defineComponents(IgcDatepickerComponent)); @@ -105,7 +105,9 @@ describe('Date picker', () => { slot: 'title', tagName: 'p', content: 'Custom title', - prerequisite: () => (picker.mode = 'dialog'), + prerequisite: () => { + picker.mode = 'dialog'; + }, parent: picker, }, { @@ -131,7 +133,9 @@ describe('Date picker', () => { slot: 'clear-icon', tagName: 'span', content: 'X', - prerequisite: () => (picker.value = new Date()), + prerequisite: () => { + picker.value = new Date(); + }, parent: picker, }, { @@ -518,7 +522,7 @@ describe('Date picker', () => { picker.displayFormat = format; await elementUpdated(picker); - expect(dateTimeInput.displayFormat).to.equal(format + 'Date'); + expect(dateTimeInput.displayFormat).to.equal(`${format}Date`); } }); diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 1551c83b4..83a00ac20 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -1,16 +1,13 @@ -import { ComplexAttributeConverter, LitElement, html, nothing } from 'lit'; +import { type ComplexAttributeConverter, LitElement, html, nothing } from 'lit'; import { property, query, queryAssignedElements } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; -import { styles } from './themes/date-picker.base.css.js'; -import { styles as shared } from './themes/shared/date-picker.common.css.js'; -import { all } from './themes/themes.js'; import { themeSymbol, themes } from '../../theming/theming-decorator.js'; -import { Theme } from '../../theming/types.js'; +import type { Theme } from '../../theming/types.js'; import IgcCalendarComponent from '../calendar/calendar.js'; import { - DateRangeDescriptor, + type DateRangeDescriptor, DateRangeType, isDateInRanges, } from '../calendar/common/calendar.model.js'; @@ -27,7 +24,7 @@ import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import { IgcCalendarResourceStringEN, - IgcCalendarResourceStrings, + type IgcCalendarResourceStrings, } from '../common/i18n/calendar.resources.js'; import messages from '../common/localization/validation-en.js'; import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; @@ -36,17 +33,20 @@ import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; import { createCounter, format } from '../common/util.js'; import { - Validator, + type Validator, maxDateValidator, minDateValidator, requiredValidator, } from '../common/validators.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; -import { DatePart } from '../date-time-input/date-util.js'; +import type { DatePart } from '../date-time-input/date-util.js'; import IgcDialogComponent from '../dialog/dialog.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent from '../popover/popover.js'; +import { styles } from './themes/date-picker.base.css.js'; +import { styles as shared } from './themes/shared/date-picker.common.css.js'; +import { all } from './themes/themes.js'; export interface IgcDatepickerEventMap { igcOpening: CustomEvent; @@ -679,13 +679,7 @@ export default class IgcDatepickerComponent extends FormAssociatedRequiredMixin( protected renderPicker(id: string) { return this.isDropDown ? html` - + ${this.renderCalendar(id)}${this.renderActions()} diff --git a/src/components/date-picker/themes/themes.ts b/src/components/date-picker/themes/themes.ts index e42b8eed2..52e3e1f8a 100644 --- a/src/components/date-picker/themes/themes.ts +++ b/src/components/date-picker/themes/themes.ts @@ -1,5 +1,6 @@ import { css } from 'lit'; +import type { Themes } from '../../../theming/types.js'; // Dark Overrides import { styles as bootstrapDark } from './dark/date-picker.bootstrap.css.js'; import { styles as fluentDark } from './dark/date-picker.fluent.css.js'; @@ -15,7 +16,6 @@ import { styles as shared } from './light/date-picker.shared.css.js'; import { styles as bootstrap } from './shared/date-picker.bootstrap.css.js'; import { styles as fluent } from './shared/date-picker.fluent.css.js'; import { styles as indigo } from './shared/date-picker.indigo.css.js'; -import { Themes } from '../../../theming/types.js'; const light = { shared: css` diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index d253794b0..142a42b2d 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -1,15 +1,8 @@ -import { ComplexAttributeConverter, html } from 'lit'; +import { type ComplexAttributeConverter, html } from 'lit'; import { property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; -import { - DatePart, - DatePartDeltas, - DatePartInfo, - DateParts, - DateTimeUtil, -} from './date-util.js'; import { addKeybindings, altKey, @@ -21,20 +14,27 @@ import { } from '../common/controllers/key-bindings.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; -import { AbstractConstructor } from '../common/mixins/constructor.js'; +import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { noop, partNameMap } from '../common/util.js'; import { - Validator, + type Validator, maxDateValidator, minDateValidator, requiredValidator, } from '../common/validators.js'; -import { IgcInputEventMap } from '../input/input-base.js'; +import type { IgcInputEventMap } from '../input/input-base.js'; import { IgcMaskInputBaseComponent, - MaskRange, + type MaskRange, } from '../mask-input/mask-input-base.js'; +import { + DatePart, + type DatePartDeltas, + type DatePartInfo, + DateParts, + DateTimeUtil, +} from './date-util.js'; export interface IgcDateTimeInputEventMap extends Omit { @@ -77,7 +77,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< /* blazorSuppress */ public static register() { - registerComponent(this); + registerComponent(IgcDateTimeInputComponent); } protected override validators: Validator[] = [ @@ -305,7 +305,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< } private get targetDatePart(): DatePart | undefined { - let result; + let result: DatePart | undefined; if (this.focused) { const partType = this._inputDateParts.find( @@ -318,14 +318,12 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< if (partType) { result = partType; } + } else if (this._inputDateParts.some((p) => p.type === DateParts.Date)) { + result = DatePart.Date; + } else if (this._inputDateParts.some((p) => p.type === DateParts.Hours)) { + result = DatePart.Hours; } else { - if (this._inputDateParts.some((p) => p.type === DateParts.Date)) { - result = DatePart.Date; - } else if (this._inputDateParts.some((p) => p.type === DateParts.Hours)) { - result = DatePart.Hours; - } else { - result = this._inputDateParts[0].type as string as DatePart; - } + result = this._inputDateParts[0].type as string as DatePart; } return result; @@ -477,12 +475,11 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< delta?: number, negative = false ): Date { - if (!delta) { - // default to 1 if a delta is set to 0 or any other falsy value - delta = this.datePartDeltas[datePart as keyof DatePartDeltas] || 1; - } + // default to 1 if a delta is set to 0 or any other falsy value + const _delta = + delta || this.datePartDeltas[datePart as keyof DatePartDeltas] || 1; - const spinValue = negative ? -Math.abs(delta) : Math.abs(delta); + const spinValue = negative ? -Math.abs(_delta) : Math.abs(_delta); return this.spinValue(datePart, spinValue); } @@ -492,7 +489,9 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< } const newDate = new Date(this.value.getTime()); - let formatPart, amPmFromMask; + let formatPart: DatePartInfo | undefined; + let amPmFromMask: string; + switch (datePart) { case DatePart.Date: DateTimeUtil.spinDate(delta, newDate, this.spinLoop); @@ -549,28 +548,26 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< this._defaultMask = DateTimeUtil.getDefaultMask(this.locale); } - private setMask(val: string): void { + private setMask(string: string): void { const oldFormat = this._inputDateParts?.map((p) => p.format).join(''); - this._inputDateParts = DateTimeUtil.parseDateTimeFormat(val); - val = this._inputDateParts.map((p) => p.format).join(''); + this._inputDateParts = DateTimeUtil.parseDateTimeFormat(string); + const value = this._inputDateParts.map((p) => p.format).join(''); - this._defaultMask = val; + this._defaultMask = value; - const newMask = (val || DateTimeUtil.DEFAULT_INPUT_FORMAT).replace( + const newMask = (value || DateTimeUtil.DEFAULT_INPUT_FORMAT).replace( new RegExp(/(?=[^t])[\w]/, 'g'), '0' ); this._mask = - newMask.indexOf('tt') !== -1 - ? newMask.replace(new RegExp('tt', 'g'), 'LL') - : newMask; + newMask.indexOf('tt') !== -1 ? newMask.replace(/tt/g, 'LL') : newMask; this.parser.mask = this._mask; this.parser.prompt = this.prompt; if (!this.placeholder || oldFormat === this.placeholder) { - this.placeholder = val; + this.placeholder = value; } } @@ -646,15 +643,14 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< cursorPos = cursorPos > 0 ? --cursorPos : cursorPos; } while (!literals.some((l) => l.end === cursorPos) && cursorPos > 0); return cursorPos; - } else { - do { - cursorPos++; - } while ( - !literals.some((l) => l.start === cursorPos) && - cursorPos < value.length - ); - return cursorPos; } + do { + cursorPos++; + } while ( + !literals.some((l) => l.start === cursorPos) && + cursorPos < value.length + ); + return cursorPos; } protected override async handleFocus() { diff --git a/src/components/focus-trap/focus-trap.spec.ts b/src/components/focus-trap/focus-trap.spec.ts index 0f8f9a2cb..c976a9bb8 100644 --- a/src/components/focus-trap/focus-trap.spec.ts +++ b/src/components/focus-trap/focus-trap.spec.ts @@ -1,9 +1,9 @@ import { expect, fixture, html } from '@open-wc/testing'; -import IgcFocusTrapComponent from './focus-trap.js'; import IgcCalendarComponent from '../calendar/calendar.js'; import type IgcDaysViewComponent from '../calendar/days-view/days-view.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; +import IgcFocusTrapComponent from './focus-trap.js'; describe('Focus trap', () => { before(() => defineComponents(IgcFocusTrapComponent, IgcCalendarComponent)); @@ -47,10 +47,10 @@ describe('Focus trap', () => { it('correctly focuses first/last focusable element', () => { expect(document.activeElement).to.equal(document.body); - trap['focusFirstElement'](); + trap.focusFirstElement(); expect(document.activeElement).instanceOf(HTMLButtonElement); - trap['focusLastElement'](); + trap.focusLastElement(); expect(document.activeElement).instanceOf(HTMLInputElement); }); }); @@ -95,16 +95,16 @@ describe('Focus trap', () => { }); it('correctly focuses first/last focusable element', () => { - const daysView = trap.querySelector(IgcCalendarComponent.tagName)![ - 'daysViews' - ][0] as IgcDaysViewComponent; + const calendar = trap.querySelector(IgcCalendarComponent.tagName)!; + // @ts-expect-error private access + const daysView = calendar.daysViews[0] as IgcDaysViewComponent; expect(document.activeElement).to.equal(document.body); - trap['focusFirstElement'](); + trap.focusFirstElement(); expect(document.activeElement).instanceOf(HTMLInputElement); - trap['focusLastElement'](); + trap.focusLastElement(); expect(document.activeElement).instanceOf(IgcCalendarComponent); expect(daysView.shadowRoot?.activeElement).instanceOf(HTMLSpanElement); }); diff --git a/src/components/focus-trap/focus-trap.ts b/src/components/focus-trap/focus-trap.ts index de2d852c2..47028deb6 100644 --- a/src/components/focus-trap/focus-trap.ts +++ b/src/components/focus-trap/focus-trap.ts @@ -4,6 +4,7 @@ import { property, state } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; import { isDefined } from '../common/util.js'; +/* blazorSuppress */ /** * * @element igc-focus-trap @@ -20,7 +21,7 @@ export default class IgcFocusTrapComponent extends LitElement { /* blazorSuppress */ public static register() { - registerComponent(this); + registerComponent(IgcFocusTrapComponent); } @state() @@ -60,11 +61,11 @@ export default class IgcFocusTrapComponent extends LitElement { this._focused = false; } - protected focusFirstElement() { + public focusFirstElement() { this.focusableElements.at(0)?.focus(); } - protected focusLastElement() { + public focusLastElement() { this.focusableElements.at(-1)?.focus(); } @@ -159,21 +160,22 @@ function* getFocusableElements( } let node: T; - cache = cache ?? new WeakSet(); + const _cache = cache ?? new WeakSet(); const visitor = document.createTreeWalker( root, NodeFilter.SHOW_ELEMENT, - (node) => shouldSkipElements(node, cache) + (node) => shouldSkipElements(node, _cache) ); + // biome-ignore lint/suspicious/noAssignInExpressions: short-form while ((node = visitor.nextNode() as T)) { - if (cache.has(node)) { + if (_cache.has(node)) { continue; } if (node.shadowRoot) { - yield* getFocusableElements(node.shadowRoot, cache); + yield* getFocusableElements(node.shadowRoot, _cache); continue; } @@ -182,8 +184,8 @@ function* getFocusableElements( if (elements.length > 0) { for (const element of elements) { - yield* getFocusableElements(parent!, cache); - cache.add(element); + yield* getFocusableElements(parent!, _cache); + _cache.add(element); } } continue; diff --git a/stories/datepicker.stories.ts b/stories/datepicker.stories.ts index cf0dd2507..a06bb8e7f 100644 --- a/stories/datepicker.stories.ts +++ b/stories/datepicker.stories.ts @@ -2,17 +2,17 @@ import type { Meta, StoryObj } from '@storybook/web-components'; import { html } from 'lit'; import { - disableStoryControls, - formControls, - formSubmitHandler, -} from './story.js'; -import { - DateRangeDescriptor, + type DateRangeDescriptor, DateRangeType, IgcButtonComponent, IgcDatepickerComponent, defineComponents, } from '../src/index.js'; +import { + disableStoryControls, + formControls, + formSubmitHandler, +} from './story.js'; defineComponents(IgcDatepickerComponent, IgcButtonComponent); From 71e25601430f5385d9db10b01ad5f07e2ba726a6 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 24 Apr 2024 13:06:04 +0300 Subject: [PATCH 62/63] fix(dialog): Update on slotchange Switching the datepicker between dropdown/dialog modes when there are slotted actions does not correctly update the dialog state. --- src/components/dialog/dialog.ts | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index e5f4ccded..bd1791214 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -243,15 +243,23 @@ export default class IgcDialogComponent extends EventEmitterMixin< } }; - private handleSlotChange() { + private slotChanged() { + this.requestUpdate(); + } + + private handleContentChange() { // Setup submit handling for supported forms - Array.from(this.querySelectorAll('igc-form, form')) - .filter((each) => each.getAttribute('method') === 'dialog') - .forEach((form) => { - const event = /igc-form/i.test(form.tagName) ? 'igcSubmit' : 'submit'; - form.removeEventListener(event, this.formSubmitHandler); - form.addEventListener(event, this.formSubmitHandler); - }); + for (const form of this.querySelectorAll('igc-form, form')) { + if (form.getAttribute('method') !== 'dialog') { + continue; + } + + const eventName = form.matches('form') ? 'submit' : 'igcSubmit'; + form.removeEventListener(eventName, this.formSubmitHandler); + form.addEventListener(eventName, this.formSubmitHandler); + } + + this.slotChanged(); } protected override render() { @@ -274,13 +282,15 @@ export default class IgcDialogComponent extends EventEmitterMixin< aria-labelledby=${ifDefined(labelledby)} >
- ${this.title} + ${this.title}
- +