Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/silver-buttons-fly.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 43 additions & 26 deletions packages/components/src/components/datepicker/datepicker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,14 @@ describe('<sd-datepicker>', () => {
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<HTMLButtonElement>('button.day:not(.out-month):not(.disabled)'),
'No enabled day rendered',
{ timeout: 0 }
);
const dayButton = el.shadowRoot!.querySelector<HTMLButtonElement>('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;
});

Expand All @@ -138,10 +142,14 @@ describe('<sd-datepicker>', () => {
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<HTMLButtonElement>('button.day:not(.out-month):not(.disabled)'),
'No enabled day rendered',
{ timeout: 0 }
);
const dayButton = el.shadowRoot!.querySelector<HTMLButtonElement>('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;
});
Expand Down Expand Up @@ -269,13 +277,13 @@ describe('<sd-datepicker>', () => {
});

describe('form integration', () => {
it('readonly prevents typing but allows calendar open', async () => {
it('readonly prevents typing and calendar open', async () => {
const el = await fixture<SdDatepicker>(html`<sd-datepicker readonly></sd-datepicker>`);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('#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;
Expand Down Expand Up @@ -326,31 +334,40 @@ describe('<sd-datepicker>', () => {
});

describe('calendar alignment and placement', () => {
it('applies alignment classes to calendar', async () => {
const elLeft = await fixture<SdDatepicker>(html`<sd-datepicker alignment="left"></sd-datepicker>`);
elLeft.show();
await elLeft.updateComplete;
const calLeft = elLeft.shadowRoot!.querySelector('[part~="datepicker"]')!;
expect(calLeft.className).to.include('left-0');

const elRight = await fixture<SdDatepicker>(html`<sd-datepicker alignment="right"></sd-datepicker>`);
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<SdDatepicker>(
html`<sd-datepicker placement="bottom" alignment="left"></sd-datepicker>`
);
elBottom.show();
await elBottom.updateComplete;
const popupBottom = elBottom.shadowRoot!.querySelector('sd-popup')!;
expect(popupBottom.getAttribute('placement')).to.equal('bottom-start');

const elTop = await fixture<SdDatepicker>(html`<sd-datepicker placement="top" alignment="left"></sd-datepicker>`);
elTop.show();
await elTop.updateComplete;
const popupTop = elTop.shadowRoot!.querySelector('sd-popup')!;
expect(popupTop.getAttribute('placement')).to.equal('top-start');

const elBottomRight = await fixture<SdDatepicker>(
html`<sd-datepicker placement="bottom" alignment="right"></sd-datepicker>`
);
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 () => {
const el = await fixture<SdDatepicker>(html`<sd-datepicker placement="bottom"></sd-datepicker>`);
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');
});
});

Expand Down
75 changes: 55 additions & 20 deletions packages/components/src/components/datepicker/datepicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1622,9 +1642,14 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
return html`
<div
part="datepicker"
class="w-[284px] z-50 absolute top-full bg-white border-2 border-t-0 border-primary py-3 px-4 ${this.open
? 'block rounded-bl-default rounded-br-default'
: 'hidden'} ${this.alignment === 'left' ? 'left-0' : 'right-0'}"
class=${cx(
'w-[284px] z-50 bg-white py-3 px-4',
this.open ? 'block' : 'hidden',
this.currentPlacement?.startsWith('bottom')
? 'border-r-2 border-b-2 border-l-2 rounded-br-default rounded-bl-default'
: 'border-r-2 border-t-2 border-l-2 rounded-tr-default rounded-tl-default',
'border-primary'
)}
>
<div class="flex flex-row items-center w-full justify-between mb-3" part="header">
<div class="flex items-center">
Expand Down Expand Up @@ -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'}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1973,8 +2004,13 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
</div>
<sd-popup
@sd-current-placement=${this.handleCurrentPlacement}
class=${cx('inline-flex relative w-full')}
sync="width"
class=${cx(
'inline-flex relative w-full',
this.currentPlacement?.startsWith('bottom') ? 'origin-top' : 'origin-bottom'
)}
placement=${this.alignment === 'left' ? `${this.placement}-start` : `${this.placement}-end`}
flip
shift
auto-size="vertical"
auto-size-padding="10"
exportparts="popup:popup__content,"
Expand Down Expand Up @@ -2047,9 +2083,8 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
name="calendar"
@click=${this.show}
></sd-icon>

${this.renderCalendar()}
</div>
${this.renderCalendar()}
</sd-popup>
</div>
<slot
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/stories/components/datepicker.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default {

export const Default = {
render: (args: any) => {
return html`<div class="h-[500px] w-[370px] h-[500px]">${generateTemplate({ args })}</div>`;
return html`<div class="h-[500px] w-[370px]">${generateTemplate({ args })}</div>`;
}
};

Expand Down
10 changes: 10 additions & 0 deletions packages/docs/src/stories/components/datepicker.test.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ export const MinAndMax = {
}
};

export const Placement = {
name: 'Placement',
render: () => {
return html`<div class="w-[400px] mt-[400px]">
<sd-datepicker label="Label" size="lg" placement="top" value="2025-12-10"></sd-datepicker>
</div>`;
}
};

export const Mouseless = {
name: 'Mouseless',
render: (args: any) => {
Expand Down Expand Up @@ -195,6 +204,7 @@ export const Combination = generateScreenshotStory([
DisabledWeekends,
DisabledDates,
MinAndMax,
Placement,
Mouseless,
LocaleAware
]);
Loading