diff --git a/.changeset/silver-buttons-fly.md b/.changeset/silver-buttons-fly.md new file mode 100644 index 0000000000..5c4239c0a0 --- /dev/null +++ b/.changeset/silver-buttons-fly.md @@ -0,0 +1,10 @@ +--- +'@solid-design-system/components': patch +'@solid-design-system/docs': patch +--- + +Fixed `sd-datepicker`: + +- `placement` attribute properly renders the calendar view on top or bottom of the input element. +- Included screenshot test `Placement`. +- Improved `readonly` attribute handling. diff --git a/packages/components/src/components/datepicker/datepicker.test.ts b/packages/components/src/components/datepicker/datepicker.test.ts index 66464bd455..3b710aa9a5 100644 --- a/packages/components/src/components/datepicker/datepicker.test.ts +++ b/packages/components/src/components/datepicker/datepicker.test.ts @@ -120,10 +120,14 @@ describe('', () => { el.show(); await el.updateComplete; - const dayButton = el.shadowRoot!.querySelector('button.day:not(.out-month):not(.disabled)')!; - await clickOnElement(dayButton); - await el.updateComplete; - + await waitUntil( + () => !!el.shadowRoot!.querySelector('button.day:not(.out-month):not(.disabled)'), + 'No enabled day rendered', + { timeout: 0 } + ); + const dayButton = el.shadowRoot!.querySelector('button.day:not(.out-month):not(.disabled)')!; + dayButton.click(); + await waitUntil(() => el.value !== null, 'Value not set after click'); expect(el.value).to.not.be.null; }); @@ -138,10 +142,14 @@ describe('', () => { el.show(); await el.updateComplete; - const dayButton = el.shadowRoot!.querySelector('button.day:not(.out-month):not(.disabled)')!; - await clickOnElement(dayButton); - await el.updateComplete; - + await waitUntil( + () => !!el.shadowRoot!.querySelector('button.day:not(.out-month):not(.disabled)'), + 'No enabled day rendered', + { timeout: 0 } + ); + const dayButton = el.shadowRoot!.querySelector('button.day:not(.out-month):not(.disabled)')!; + dayButton.click(); + await waitUntil(() => el.value !== null, 'Value not set after click'); expect(changeHandler).to.have.been.calledOnce; expect(selectHandler).to.have.been.calledOnce; }); @@ -269,13 +277,13 @@ describe('', () => { }); describe('form integration', () => { - it('readonly prevents typing but allows calendar open', async () => { + it('readonly prevents typing and calendar open', async () => { const el = await fixture(html``); const input = el.shadowRoot!.querySelector('#input')!; input.focus(); await el.updateComplete; - expect(input.ariaExpanded).to.equal('true'); + expect(input.ariaExpanded).to.equal('false'); const before = input.value; await sendKeys({ type: '15012024' }); await el.updateComplete; @@ -326,18 +334,28 @@ describe('', () => { }); describe('calendar alignment and placement', () => { - it('applies alignment classes to calendar', async () => { - const elLeft = await fixture(html``); - elLeft.show(); - await elLeft.updateComplete; - const calLeft = elLeft.shadowRoot!.querySelector('[part~="datepicker"]')!; - expect(calLeft.className).to.include('left-0'); - - const elRight = await fixture(html``); - elRight.show(); - await elRight.updateComplete; - const calRight = elRight.shadowRoot!.querySelector('[part~="datepicker"]')!; - expect(calRight.className).to.include('right-0'); + it('forwards placement to sd-popup (with alignment)', async () => { + const elBottom = await fixture( + html`` + ); + elBottom.show(); + await elBottom.updateComplete; + const popupBottom = elBottom.shadowRoot!.querySelector('sd-popup')!; + expect(popupBottom.getAttribute('placement')).to.equal('bottom-start'); + + const elTop = await fixture(html``); + elTop.show(); + await elTop.updateComplete; + const popupTop = elTop.shadowRoot!.querySelector('sd-popup')!; + expect(popupTop.getAttribute('placement')).to.equal('top-start'); + + const elBottomRight = await fixture( + html`` + ); + elBottomRight.show(); + await elBottomRight.updateComplete; + const popupBottomRight = elBottomRight.shadowRoot!.querySelector('sd-popup')!; + expect(popupBottomRight.getAttribute('placement')).to.equal('bottom-end'); }); it('updates currentPlacement from sd-popup', async () => { @@ -345,12 +363,11 @@ describe('', () => { el.show(); await el.updateComplete; - el.dispatchEvent( - new CustomEvent('sd-current-placement', { bubbles: true, composed: true, detail: { placement: 'top' } }) - ); + const popup = el.shadowRoot!.querySelector('sd-popup')!; + popup.dispatchEvent(new CustomEvent('sd-current-placement', { bubbles: true, composed: true, detail: 'top' })); await el.updateComplete; - expect(el['currentPlacement']).to.equal('bottom'); + expect(el['currentPlacement']).to.equal('top'); }); }); diff --git a/packages/components/src/components/datepicker/datepicker.ts b/packages/components/src/components/datepicker/datepicker.ts index a9ab33a52a..3d48d5fa9b 100644 --- a/packages/components/src/components/datepicker/datepicker.ts +++ b/packages/components/src/components/datepicker/datepicker.ts @@ -12,6 +12,7 @@ import { watch } from '../../internal/watch'; import cx from 'classix'; import SolidElement from '../../internal/solid-element'; import type { SolidFormControl } from '../../internal/solid-element'; +import type SdPopup from '../popup/popup'; /** * @summary Used to enter or select a date or a range of dates using a calendar view. @@ -274,6 +275,8 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr /** The text value shown in the input, synchronized with selection. */ @state() private inputValue = ''; + @query('sd-popup') popup: SdPopup; + @query('#invalid-message') invalidMessage: HTMLDivElement; @query('#input') input: HTMLInputElement; @@ -326,6 +329,13 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr this.formControlController.updateValidity(); } + @watch('open', { waitUntilFirstUpdate: true }) + handleOpenChange() { + if (this.popup) { + this.popup.active = this.open && !this.disabled && !this.visuallyDisabled; + } + } + disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('focusin', this.onFocusIn); @@ -823,7 +833,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr }; show() { - if (this.open || this.disabled || this.visuallyDisabled) { + if (this.open || this.disabled || this.visuallyDisabled || this.readonly) { this.open = false; return; } @@ -851,7 +861,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr }; private handleMouseDown(event: MouseEvent) { - if (this.visuallyDisabled || this.disabled) { + if (this.visuallyDisabled || this.disabled || this.readonly) { event.preventDefault(); event.stopPropagation(); return; @@ -869,14 +879,14 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr private handleFocus() { this.hasFocus = true; - if (!this.open && !this.disabled && !this.visuallyDisabled) { + if (!this.open && !this.disabled && !this.visuallyDisabled && !this.readonly) { this.show(); } this.emit('sd-focus'); } private handleInput = (ev: Event) => { - if (this.disabled || this.visuallyDisabled) { + if (this.disabled || this.visuallyDisabled || this.readonly) { ev.preventDefault?.(); ev.stopPropagation?.(); return; @@ -1127,8 +1137,12 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr this.handleBlur(); }; - private handleCurrentPlacement = (ev: CustomEvent<{ placement: 'top' | 'bottom' }>) => { - this.currentPlacement = ev.detail.placement; + private handleCurrentPlacement = (e: CustomEvent<'top' | 'bottom'>) => { + const incomingPlacement = e.detail; + + if (incomingPlacement) { + this.currentPlacement = incomingPlacement; + } }; private setMonth(offset: number) { @@ -1216,6 +1230,9 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr direction: -1 | 1, isLastHeaderControl: boolean ) => { + if (this.disabled || this.visuallyDisabled || this.readonly) { + return; + } // Only the last header control sends focus into the grid on Tab if (ev.key === 'Tab' && !ev.shiftKey) { if (isLastHeaderControl) { @@ -1236,6 +1253,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr }; private selectSingleDate(d: Date) { + if (this.readonly) return; if (this.isDisabled(d)) return; const localMidnight = DateUtils.startOfDayLocal(d); @@ -1263,6 +1281,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr } private selectRangeDate(d: Date) { + if (this.readonly) return; const day = DateUtils.startOfDayLocal(d); const rs = this.rangeStart ? DateUtils.parseLocalISO(this.rangeStart) : null; const re = this.rangeEnd ? DateUtils.parseLocalISO(this.rangeEnd) : null; @@ -1402,12 +1421,13 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr } private selectDate(d: Date) { + if (this.readonly) return; if (this.range) this.selectRangeDate(d); else this.selectSingleDate(d); } private onKeyDown = (ev: KeyboardEvent) => { - if (this.disabled || this.visuallyDisabled) { + if (this.disabled || this.visuallyDisabled || this.readonly) { ev.preventDefault(); ev.stopPropagation(); return; @@ -1487,7 +1507,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr }; private handleInputKeyDown = (ev: KeyboardEvent) => { - if ((this.disabled || this.visuallyDisabled) && ev.key !== 'Tab') { + if ((this.disabled || this.visuallyDisabled || this.readonly) && ev.key !== 'Tab') { ev.preventDefault(); ev.stopPropagation(); return; @@ -1513,7 +1533,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr return; } - if (ev.key === 'Enter' && !this.open && !this.disabled && !this.visuallyDisabled) { + if (ev.key === 'Enter' && !this.open && !this.disabled && !this.visuallyDisabled && !this.readonly) { ev.preventDefault(); this.show(); } @@ -1585,7 +1605,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr /** Mouse enter on a day: updates preview end when selecting a range. */ private onDayMouseEnter(day: Date) { - if (!this.range) return; + if (!this.range || this.readonly) return; const rs = this.rangeStart ? DateUtils.parseLocalISO(this.rangeStart) : null; const re = this.rangeEnd ? DateUtils.parseLocalISO(this.rangeEnd) : null; if (rs && !re) { @@ -1622,9 +1642,14 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr return html`
@@ -1814,8 +1839,10 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr aria-colindex=${colIndex + 1} aria-labelledby=${'col-' + (colIndex + 1)} .tabIndex=${tabIndex} - ?disabled=${disabled || this.disabled} - aria-disabled=${disabled || this.visuallyDisabled || this.disabled ? 'true' : 'false'} + ?disabled=${disabled || this.disabled || this.readonly} + aria-disabled=${disabled || this.visuallyDisabled || this.disabled || this.readonly + ? 'true' + : 'false'} aria-selected=${isSelectedSingle || inSelectedRange || isRangeStart || isRangeEnd ? 'true' : 'false'} @@ -1936,7 +1963,11 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr 'absolute top-0 w-full h-full pointer-events-none border rounded-default z-10 transition-[border] duration-medium ease-in-out', borderColor, this.open && this.alignment === 'left' ? 'rounded-bl-none' : '', - this.open && this.alignment === 'right' ? 'rounded-br-none' : '' + this.open && this.alignment === 'right' ? 'rounded-br-none' : '', + this.open && + (this.currentPlacement?.startsWith('bottom') + ? 'rounded-bl-none rounded-br-none' + : 'rounded-tl-none rounded-tr-none') )} > ${hasLabel && this.floatingLabel @@ -1973,8 +2004,13 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
- - ${this.renderCalendar()}
+ ${this.renderCalendar()}
{ - return html`
${generateTemplate({ args })}
`; + return html`
${generateTemplate({ args })}
`; } }; diff --git a/packages/docs/src/stories/components/datepicker.test.stories.ts b/packages/docs/src/stories/components/datepicker.test.stories.ts index b8e1e0d037..d837fb66cc 100644 --- a/packages/docs/src/stories/components/datepicker.test.stories.ts +++ b/packages/docs/src/stories/components/datepicker.test.stories.ts @@ -161,6 +161,15 @@ export const MinAndMax = { } }; +export const Placement = { + name: 'Placement', + render: () => { + return html`
+ +
`; + } +}; + export const Mouseless = { name: 'Mouseless', render: (args: any) => { @@ -195,6 +204,7 @@ export const Combination = generateScreenshotStory([ DisabledWeekends, DisabledDates, MinAndMax, + Placement, Mouseless, LocaleAware ]);