Skip to content

Commit d80b650

Browse files
authored
fix: datepicker placement (#2765)
<!-- ## Title: Please consider adding the [skip chromatic] flag to the PR title in case you dont need chromatic testing your changes. --> ## Description: Fixes an issue with the `placement` attribute which was not working. ## Definition of Reviewable: <!-- *PR notes: Irrelevant elements should be removed.* --> - [ ] Documentation is created/updated - [ ] Migration Guide is created/updated <!-- *PR notes: If this PR includes a BREAKING CHANGE, a migration guide is needed to explain how users can migrate between versions. * --> - [ ] E2E tests (features, a11y, bug fixes) are created/updated <!-- *If this PR includes a bug fix, an E2E test is necessary to verify the change. If the fix is purely visual, ensuring it is captured within our chromatic screenshot tests is sufficient.* --> - [ ] Stories (features, a11y) are created/updated - [ ] relevant tickets are linked
1 parent 5c1a4f0 commit d80b650

5 files changed

Lines changed: 119 additions & 47 deletions

File tree

.changeset/silver-buttons-fly.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@solid-design-system/components': patch
3+
'@solid-design-system/docs': patch
4+
---
5+
6+
Fixed `sd-datepicker`:
7+
8+
- `placement` attribute properly renders the calendar view on top or bottom of the input element.
9+
- Included screenshot test `Placement`.
10+
- Improved `readonly` attribute handling.

packages/components/src/components/datepicker/datepicker.test.ts

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,14 @@ describe('<sd-datepicker>', () => {
120120
el.show();
121121
await el.updateComplete;
122122

123-
const dayButton = el.shadowRoot!.querySelector('button.day:not(.out-month):not(.disabled)')!;
124-
await clickOnElement(dayButton);
125-
await el.updateComplete;
126-
123+
await waitUntil(
124+
() => !!el.shadowRoot!.querySelector<HTMLButtonElement>('button.day:not(.out-month):not(.disabled)'),
125+
'No enabled day rendered',
126+
{ timeout: 0 }
127+
);
128+
const dayButton = el.shadowRoot!.querySelector<HTMLButtonElement>('button.day:not(.out-month):not(.disabled)')!;
129+
dayButton.click();
130+
await waitUntil(() => el.value !== null, 'Value not set after click');
127131
expect(el.value).to.not.be.null;
128132
});
129133

@@ -138,10 +142,14 @@ describe('<sd-datepicker>', () => {
138142
el.show();
139143
await el.updateComplete;
140144

141-
const dayButton = el.shadowRoot!.querySelector('button.day:not(.out-month):not(.disabled)')!;
142-
await clickOnElement(dayButton);
143-
await el.updateComplete;
144-
145+
await waitUntil(
146+
() => !!el.shadowRoot!.querySelector<HTMLButtonElement>('button.day:not(.out-month):not(.disabled)'),
147+
'No enabled day rendered',
148+
{ timeout: 0 }
149+
);
150+
const dayButton = el.shadowRoot!.querySelector<HTMLButtonElement>('button.day:not(.out-month):not(.disabled)')!;
151+
dayButton.click();
152+
await waitUntil(() => el.value !== null, 'Value not set after click');
145153
expect(changeHandler).to.have.been.calledOnce;
146154
expect(selectHandler).to.have.been.calledOnce;
147155
});
@@ -269,13 +277,13 @@ describe('<sd-datepicker>', () => {
269277
});
270278

271279
describe('form integration', () => {
272-
it('readonly prevents typing but allows calendar open', async () => {
280+
it('readonly prevents typing and calendar open', async () => {
273281
const el = await fixture<SdDatepicker>(html`<sd-datepicker readonly></sd-datepicker>`);
274282
const input = el.shadowRoot!.querySelector<HTMLInputElement>('#input')!;
275283
input.focus();
276284
await el.updateComplete;
277285

278-
expect(input.ariaExpanded).to.equal('true');
286+
expect(input.ariaExpanded).to.equal('false');
279287
const before = input.value;
280288
await sendKeys({ type: '15012024' });
281289
await el.updateComplete;
@@ -326,31 +334,40 @@ describe('<sd-datepicker>', () => {
326334
});
327335

328336
describe('calendar alignment and placement', () => {
329-
it('applies alignment classes to calendar', async () => {
330-
const elLeft = await fixture<SdDatepicker>(html`<sd-datepicker alignment="left"></sd-datepicker>`);
331-
elLeft.show();
332-
await elLeft.updateComplete;
333-
const calLeft = elLeft.shadowRoot!.querySelector('[part~="datepicker"]')!;
334-
expect(calLeft.className).to.include('left-0');
335-
336-
const elRight = await fixture<SdDatepicker>(html`<sd-datepicker alignment="right"></sd-datepicker>`);
337-
elRight.show();
338-
await elRight.updateComplete;
339-
const calRight = elRight.shadowRoot!.querySelector('[part~="datepicker"]')!;
340-
expect(calRight.className).to.include('right-0');
337+
it('forwards placement to sd-popup (with alignment)', async () => {
338+
const elBottom = await fixture<SdDatepicker>(
339+
html`<sd-datepicker placement="bottom" alignment="left"></sd-datepicker>`
340+
);
341+
elBottom.show();
342+
await elBottom.updateComplete;
343+
const popupBottom = elBottom.shadowRoot!.querySelector('sd-popup')!;
344+
expect(popupBottom.getAttribute('placement')).to.equal('bottom-start');
345+
346+
const elTop = await fixture<SdDatepicker>(html`<sd-datepicker placement="top" alignment="left"></sd-datepicker>`);
347+
elTop.show();
348+
await elTop.updateComplete;
349+
const popupTop = elTop.shadowRoot!.querySelector('sd-popup')!;
350+
expect(popupTop.getAttribute('placement')).to.equal('top-start');
351+
352+
const elBottomRight = await fixture<SdDatepicker>(
353+
html`<sd-datepicker placement="bottom" alignment="right"></sd-datepicker>`
354+
);
355+
elBottomRight.show();
356+
await elBottomRight.updateComplete;
357+
const popupBottomRight = elBottomRight.shadowRoot!.querySelector('sd-popup')!;
358+
expect(popupBottomRight.getAttribute('placement')).to.equal('bottom-end');
341359
});
342360

343361
it('updates currentPlacement from sd-popup', async () => {
344362
const el = await fixture<SdDatepicker>(html`<sd-datepicker placement="bottom"></sd-datepicker>`);
345363
el.show();
346364
await el.updateComplete;
347365

348-
el.dispatchEvent(
349-
new CustomEvent('sd-current-placement', { bubbles: true, composed: true, detail: { placement: 'top' } })
350-
);
366+
const popup = el.shadowRoot!.querySelector('sd-popup')!;
367+
popup.dispatchEvent(new CustomEvent('sd-current-placement', { bubbles: true, composed: true, detail: 'top' }));
351368
await el.updateComplete;
352369

353-
expect(el['currentPlacement']).to.equal('bottom');
370+
expect(el['currentPlacement']).to.equal('top');
354371
});
355372
});
356373

packages/components/src/components/datepicker/datepicker.ts

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { watch } from '../../internal/watch';
1212
import cx from 'classix';
1313
import SolidElement from '../../internal/solid-element';
1414
import type { SolidFormControl } from '../../internal/solid-element';
15+
import type SdPopup from '../popup/popup';
1516

1617
/**
1718
* @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
274275
/** The text value shown in the input, synchronized with selection. */
275276
@state() private inputValue = '';
276277

278+
@query('sd-popup') popup: SdPopup;
279+
277280
@query('#invalid-message') invalidMessage: HTMLDivElement;
278281

279282
@query('#input') input: HTMLInputElement;
@@ -326,6 +329,13 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
326329
this.formControlController.updateValidity();
327330
}
328331

332+
@watch('open', { waitUntilFirstUpdate: true })
333+
handleOpenChange() {
334+
if (this.popup) {
335+
this.popup.active = this.open && !this.disabled && !this.visuallyDisabled;
336+
}
337+
}
338+
329339
disconnectedCallback() {
330340
super.disconnectedCallback();
331341
this.removeEventListener('focusin', this.onFocusIn);
@@ -823,7 +833,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
823833
};
824834

825835
show() {
826-
if (this.open || this.disabled || this.visuallyDisabled) {
836+
if (this.open || this.disabled || this.visuallyDisabled || this.readonly) {
827837
this.open = false;
828838
return;
829839
}
@@ -851,7 +861,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
851861
};
852862

853863
private handleMouseDown(event: MouseEvent) {
854-
if (this.visuallyDisabled || this.disabled) {
864+
if (this.visuallyDisabled || this.disabled || this.readonly) {
855865
event.preventDefault();
856866
event.stopPropagation();
857867
return;
@@ -869,14 +879,14 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
869879
private handleFocus() {
870880
this.hasFocus = true;
871881

872-
if (!this.open && !this.disabled && !this.visuallyDisabled) {
882+
if (!this.open && !this.disabled && !this.visuallyDisabled && !this.readonly) {
873883
this.show();
874884
}
875885
this.emit('sd-focus');
876886
}
877887

878888
private handleInput = (ev: Event) => {
879-
if (this.disabled || this.visuallyDisabled) {
889+
if (this.disabled || this.visuallyDisabled || this.readonly) {
880890
ev.preventDefault?.();
881891
ev.stopPropagation?.();
882892
return;
@@ -1127,8 +1137,12 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
11271137
this.handleBlur();
11281138
};
11291139

1130-
private handleCurrentPlacement = (ev: CustomEvent<{ placement: 'top' | 'bottom' }>) => {
1131-
this.currentPlacement = ev.detail.placement;
1140+
private handleCurrentPlacement = (e: CustomEvent<'top' | 'bottom'>) => {
1141+
const incomingPlacement = e.detail;
1142+
1143+
if (incomingPlacement) {
1144+
this.currentPlacement = incomingPlacement;
1145+
}
11321146
};
11331147

11341148
private setMonth(offset: number) {
@@ -1216,6 +1230,9 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
12161230
direction: -1 | 1,
12171231
isLastHeaderControl: boolean
12181232
) => {
1233+
if (this.disabled || this.visuallyDisabled || this.readonly) {
1234+
return;
1235+
}
12191236
// Only the last header control sends focus into the grid on Tab
12201237
if (ev.key === 'Tab' && !ev.shiftKey) {
12211238
if (isLastHeaderControl) {
@@ -1236,6 +1253,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
12361253
};
12371254

12381255
private selectSingleDate(d: Date) {
1256+
if (this.readonly) return;
12391257
if (this.isDisabled(d)) return;
12401258

12411259
const localMidnight = DateUtils.startOfDayLocal(d);
@@ -1263,6 +1281,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
12631281
}
12641282

12651283
private selectRangeDate(d: Date) {
1284+
if (this.readonly) return;
12661285
const day = DateUtils.startOfDayLocal(d);
12671286
const rs = this.rangeStart ? DateUtils.parseLocalISO(this.rangeStart) : null;
12681287
const re = this.rangeEnd ? DateUtils.parseLocalISO(this.rangeEnd) : null;
@@ -1402,12 +1421,13 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
14021421
}
14031422

14041423
private selectDate(d: Date) {
1424+
if (this.readonly) return;
14051425
if (this.range) this.selectRangeDate(d);
14061426
else this.selectSingleDate(d);
14071427
}
14081428

14091429
private onKeyDown = (ev: KeyboardEvent) => {
1410-
if (this.disabled || this.visuallyDisabled) {
1430+
if (this.disabled || this.visuallyDisabled || this.readonly) {
14111431
ev.preventDefault();
14121432
ev.stopPropagation();
14131433
return;
@@ -1487,7 +1507,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
14871507
};
14881508

14891509
private handleInputKeyDown = (ev: KeyboardEvent) => {
1490-
if ((this.disabled || this.visuallyDisabled) && ev.key !== 'Tab') {
1510+
if ((this.disabled || this.visuallyDisabled || this.readonly) && ev.key !== 'Tab') {
14911511
ev.preventDefault();
14921512
ev.stopPropagation();
14931513
return;
@@ -1513,7 +1533,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
15131533
return;
15141534
}
15151535

1516-
if (ev.key === 'Enter' && !this.open && !this.disabled && !this.visuallyDisabled) {
1536+
if (ev.key === 'Enter' && !this.open && !this.disabled && !this.visuallyDisabled && !this.readonly) {
15171537
ev.preventDefault();
15181538
this.show();
15191539
}
@@ -1585,7 +1605,7 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
15851605

15861606
/** Mouse enter on a day: updates preview end when selecting a range. */
15871607
private onDayMouseEnter(day: Date) {
1588-
if (!this.range) return;
1608+
if (!this.range || this.readonly) return;
15891609
const rs = this.rangeStart ? DateUtils.parseLocalISO(this.rangeStart) : null;
15901610
const re = this.rangeEnd ? DateUtils.parseLocalISO(this.rangeEnd) : null;
15911611
if (rs && !re) {
@@ -1622,9 +1642,14 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
16221642
return html`
16231643
<div
16241644
part="datepicker"
1625-
class="w-[284px] z-50 absolute top-full bg-white border-2 border-t-0 border-primary py-3 px-4 ${this.open
1626-
? 'block rounded-bl-default rounded-br-default'
1627-
: 'hidden'} ${this.alignment === 'left' ? 'left-0' : 'right-0'}"
1645+
class=${cx(
1646+
'w-[284px] z-50 bg-white py-3 px-4',
1647+
this.open ? 'block' : 'hidden',
1648+
this.currentPlacement?.startsWith('bottom')
1649+
? 'border-r-2 border-b-2 border-l-2 rounded-br-default rounded-bl-default'
1650+
: 'border-r-2 border-t-2 border-l-2 rounded-tr-default rounded-tl-default',
1651+
'border-primary'
1652+
)}
16281653
>
16291654
<div class="flex flex-row items-center w-full justify-between mb-3" part="header">
16301655
<div class="flex items-center">
@@ -1814,8 +1839,10 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
18141839
aria-colindex=${colIndex + 1}
18151840
aria-labelledby=${'col-' + (colIndex + 1)}
18161841
.tabIndex=${tabIndex}
1817-
?disabled=${disabled || this.disabled}
1818-
aria-disabled=${disabled || this.visuallyDisabled || this.disabled ? 'true' : 'false'}
1842+
?disabled=${disabled || this.disabled || this.readonly}
1843+
aria-disabled=${disabled || this.visuallyDisabled || this.disabled || this.readonly
1844+
? 'true'
1845+
: 'false'}
18191846
aria-selected=${isSelectedSingle || inSelectedRange || isRangeStart || isRangeEnd
18201847
? 'true'
18211848
: 'false'}
@@ -1936,7 +1963,11 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
19361963
'absolute top-0 w-full h-full pointer-events-none border rounded-default z-10 transition-[border] duration-medium ease-in-out',
19371964
borderColor,
19381965
this.open && this.alignment === 'left' ? 'rounded-bl-none' : '',
1939-
this.open && this.alignment === 'right' ? 'rounded-br-none' : ''
1966+
this.open && this.alignment === 'right' ? 'rounded-br-none' : '',
1967+
this.open &&
1968+
(this.currentPlacement?.startsWith('bottom')
1969+
? 'rounded-bl-none rounded-br-none'
1970+
: 'rounded-tl-none rounded-tr-none')
19401971
)}
19411972
>
19421973
${hasLabel && this.floatingLabel
@@ -1973,8 +2004,13 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
19732004
</div>
19742005
<sd-popup
19752006
@sd-current-placement=${this.handleCurrentPlacement}
1976-
class=${cx('inline-flex relative w-full')}
1977-
sync="width"
2007+
class=${cx(
2008+
'inline-flex relative w-full',
2009+
this.currentPlacement?.startsWith('bottom') ? 'origin-top' : 'origin-bottom'
2010+
)}
2011+
placement=${this.alignment === 'left' ? `${this.placement}-start` : `${this.placement}-end`}
2012+
flip
2013+
shift
19782014
auto-size="vertical"
19792015
auto-size-padding="10"
19802016
exportparts="popup:popup__content,"
@@ -2047,9 +2083,8 @@ export default class SdDatepicker extends SolidElement implements SolidFormContr
20472083
name="calendar"
20482084
@click=${this.show}
20492085
></sd-icon>
2050-
2051-
${this.renderCalendar()}
20522086
</div>
2087+
${this.renderCalendar()}
20532088
</sd-popup>
20542089
</div>
20552090
<slot

packages/docs/src/stories/components/datepicker.stories.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default {
2323

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

packages/docs/src/stories/components/datepicker.test.stories.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@ export const MinAndMax = {
161161
}
162162
};
163163

164+
export const Placement = {
165+
name: 'Placement',
166+
render: () => {
167+
return html`<div class="w-[400px] mt-[400px]">
168+
<sd-datepicker label="Label" size="lg" placement="top" value="2025-12-10"></sd-datepicker>
169+
</div>`;
170+
}
171+
};
172+
164173
export const Mouseless = {
165174
name: 'Mouseless',
166175
render: (args: any) => {
@@ -195,6 +204,7 @@ export const Combination = generateScreenshotStory([
195204
DisabledWeekends,
196205
DisabledDates,
197206
MinAndMax,
207+
Placement,
198208
Mouseless,
199209
LocaleAware
200210
]);

0 commit comments

Comments
 (0)