Skip to content

Commit 8d86414

Browse files
committed
feat(date-input): create sbb-date-input as a native text input
1 parent d614fc3 commit 8d86414

16 files changed

+765
-5
lines changed

src/elements/core/datetime/date-adapter.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const YEARS_PER_ROW: number = 4;
44
export const YEARS_PER_PAGE: number = 24;
55
export const FORMAT_DATE =
66
/(^0?[1-9]?|[12]?[0-9]?|3?[01]?)[.,\\/\-\s](0?[1-9]?|1?[0-2]?)?[.,\\/\-\s](\d{1,4}$)?/;
7+
export const ISO8601_FORMAT_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
78

89
/**
910
* Abstract date functionality.
@@ -137,7 +138,7 @@ export abstract class DateAdapter<T = any> {
137138
* @param value The date in the format DD.MM.YYYY.
138139
* @param now The current date as Date.
139140
*/
140-
public abstract parse(value: string | null | undefined, now: T): T | null;
141+
public abstract parse(value: string | null | undefined, now?: T): T | null;
141142

142143
/**
143144
* Format the given date as string.
@@ -146,7 +147,7 @@ export abstract class DateAdapter<T = any> {
146147
*/
147148
public format(
148149
date: T | null | undefined,
149-
options?: { weekdayStyle?: 'long' | 'short' | 'narrow' },
150+
options?: { weekdayStyle?: 'long' | 'short' | 'narrow' | 'none' },
150151
): string {
151152
if (!this.isValid(date)) {
152153
return '';
@@ -159,6 +160,10 @@ export abstract class DateAdapter<T = any> {
159160
year: 'numeric',
160161
});
161162

163+
if (options?.weekdayStyle === 'none') {
164+
return dateFormatter.format(value);
165+
}
166+
162167
const weekdayStyle = options?.weekdayStyle ?? 'short';
163168
let weekday = this.getDayOfWeekNames(weekdayStyle)[this.getDayOfWeek(date!)];
164169
weekday = weekday.charAt(0).toUpperCase() + weekday.substring(1);

src/elements/core/datetime/native-date-adapter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { SbbLanguageController } from '../controllers.js';
22
import type { SbbDateLike } from '../interfaces.js';
33

4-
import { DateAdapter, FORMAT_DATE } from './date-adapter.js';
4+
import { DateAdapter, FORMAT_DATE, ISO8601_FORMAT_DATE } from './date-adapter.js';
55

66
/**
77
* Matches strings that have the form of a valid RFC 3339 string
@@ -173,11 +173,17 @@ export class NativeDateAdapter extends DateAdapter<Date> {
173173
}
174174

175175
/** Returns the right format for the `valueAsDate` property. */
176-
public parse(value: string | null | undefined, now: Date): Date | null {
176+
public parse(value: string | null | undefined, now: Date = this.today()): Date | null {
177177
if (!value) {
178178
return null;
179179
}
180180

181+
const isoMatch = value.match(ISO8601_FORMAT_DATE);
182+
const date = isoMatch ? this.createDate(+isoMatch[1], +isoMatch[2], +isoMatch[3]) : null;
183+
if (this.isValid(date)) {
184+
return date;
185+
}
186+
181187
const strippedValue = value.replace(/\D/g, ' ').trim();
182188

183189
const match: RegExpMatchArray | null | undefined = strippedValue?.match(FORMAT_DATE);

src/elements/core/mixins.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './mixins/constructor.js';
22
export * from './mixins/disabled-mixin.js';
33
export * from './mixins/form-associated-checkbox-mixin.js';
4+
export * from './mixins/form-associated-input-mixin.js';
45
export * from './mixins/form-associated-mixin.js';
56
export * from './mixins/form-associated-radio-button-mixin.js';
67
export * from './mixins/hydration-mixin.js';
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { html, type LitElement } from 'lit';
2+
import { eventOptions, property } from 'lit/decorators.js';
3+
4+
import type { Constructor } from './constructor.js';
5+
import {
6+
type FormRestoreReason,
7+
type FormRestoreState,
8+
SbbFormAssociatedMixin,
9+
type SbbFormAssociatedMixinType,
10+
} from './form-associated-mixin.js';
11+
import { SbbRequiredMixin, type SbbRequiredMixinType } from './required-mixin.js';
12+
13+
export declare abstract class SbbFormAssociatedInputMixinType
14+
extends SbbFormAssociatedMixinType
15+
implements Partial<SbbRequiredMixinType>
16+
{
17+
public set disabled(value: boolean);
18+
public get disabled(): boolean;
19+
20+
public set readOnly(value: boolean);
21+
public get readOnly(): boolean;
22+
23+
public set required(value: boolean);
24+
public get required(): boolean;
25+
26+
public formResetCallback(): void;
27+
public formStateRestoreCallback(state: FormRestoreState | null, reason: FormRestoreReason): void;
28+
29+
protected withUserInteraction?(): void;
30+
protected updateFormValue(): void;
31+
}
32+
33+
/**
34+
* The FormAssociatedCheckboxMixin enables native form support for checkbox controls.
35+
*
36+
* Inherited classes MUST implement the ariaChecked state (ElementInternals) themselves.
37+
*/
38+
// eslint-disable-next-line @typescript-eslint/naming-convention
39+
export const SbbFormAssociatedInputMixin = <T extends Constructor<LitElement>>(
40+
superClass: T,
41+
): Constructor<SbbFormAssociatedInputMixinType> & T => {
42+
abstract class SbbFormAssociatedInputElement
43+
extends SbbRequiredMixin(SbbFormAssociatedMixin(superClass))
44+
implements Partial<SbbFormAssociatedInputMixinType>
45+
{
46+
/**
47+
* The native text input changes the value property when the value attribute is
48+
* changed under the condition that no input event has occured since creation
49+
* or the last form reset.
50+
*/
51+
private _interacted = false;
52+
/**
53+
* An element with contenteditable will not emit a change event. To achieve parity
54+
* with a native text input, we need to track whether a change event should be
55+
* emitted.
56+
*/
57+
private _shouldEmitChange = false;
58+
/**
59+
* A native text input attempts to submit the form when pressing Enter.
60+
* This can be prevented by calling preventDefault on the keydown event.
61+
* We track whether to request submit, which should occur before the keyup
62+
* event.
63+
*/
64+
private _shouldTriggerSubmit = false;
65+
66+
/**
67+
* Form type of element.
68+
* @default 'text'
69+
*/
70+
public override get type(): string {
71+
return 'text';
72+
}
73+
74+
/**
75+
* The text value of the input element.
76+
*/
77+
public override set value(value: string) {
78+
this.textContent = this._cleanText(value);
79+
}
80+
public override get value(): string {
81+
return this.textContent ?? '';
82+
}
83+
84+
/**
85+
* Whether the component is readonly.
86+
* @attr readonly
87+
* @default false
88+
*/
89+
@property({ attribute: false })
90+
public set readOnly(value: boolean) {
91+
this.toggleAttribute('readonly', !!value);
92+
}
93+
public get readOnly(): boolean {
94+
return this.hasAttribute('readonly');
95+
}
96+
97+
/**
98+
* Whether the component is disabled.
99+
* @attr disabled
100+
* @default false
101+
*/
102+
@property({ attribute: false })
103+
public set disabled(value: boolean) {
104+
this.toggleAttribute('disabled', !!value);
105+
if (this.isConnected) {
106+
this.setAttribute('contenteditable', `${!value}`);
107+
}
108+
}
109+
public get disabled(): boolean {
110+
return this.hasAttribute('disabled');
111+
}
112+
113+
protected constructor() {
114+
super();
115+
/** @internal */
116+
this.internals.role = 'textbox';
117+
// We primarily use capture event listeners, as we want
118+
// our listeners to occur before consumer event listeners.
119+
this.addEventListener?.(
120+
'input',
121+
() => {
122+
this._interacted = true;
123+
this._shouldEmitChange = true;
124+
this.updateFormValue();
125+
},
126+
{ capture: true },
127+
);
128+
this.addEventListener?.(
129+
'keydown',
130+
(event) => {
131+
if ((event.key === 'Enter' || event.key === '\n') && event.isTrusted) {
132+
event.preventDefault();
133+
event.stopImmediatePropagation();
134+
// We prevent recursive events by checking the original event for isTrusted
135+
// which is false for manually dispatched events.
136+
this._shouldTriggerSubmit = this.dispatchEvent(new KeyboardEvent('keydown', event));
137+
} else if (this.readOnly) {
138+
event.preventDefault();
139+
}
140+
},
141+
{ capture: true },
142+
);
143+
this.addEventListener?.(
144+
'keyup',
145+
(event) => {
146+
if (event.key === 'Enter' || event.key === '\n') {
147+
this._emitChangeIfNecessary();
148+
if (this._shouldTriggerSubmit) {
149+
this._shouldTriggerSubmit = false;
150+
this.form?.requestSubmit();
151+
}
152+
}
153+
},
154+
{ capture: true },
155+
);
156+
// contenteditable allows pasting rich content into its host.
157+
// We prevent this by listening to the paste event and
158+
// extracting the plain text from the pasted content
159+
// and inserting it into the selected range (cursor position
160+
// or selection).
161+
this.addEventListener?.('paste', (e) => {
162+
e.preventDefault();
163+
const text = e.clipboardData?.getData('text/plain');
164+
const selectedRange = window.getSelection()?.getRangeAt(0);
165+
if (!selectedRange || !text) {
166+
return;
167+
}
168+
169+
selectedRange.deleteContents();
170+
selectedRange.insertNode(document.createTextNode(text));
171+
selectedRange.setStart(selectedRange.endContainer, selectedRange.endOffset);
172+
this.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }));
173+
});
174+
// On blur the native text input scrolls the text to the start of the text.
175+
// We mimick that by resetting the scroll position.
176+
this.addEventListener?.(
177+
'blur',
178+
() => {
179+
this._emitChangeIfNecessary();
180+
this.scrollLeft = 0;
181+
},
182+
{ capture: true },
183+
);
184+
}
185+
186+
public override connectedCallback(): void {
187+
super.connectedCallback();
188+
this.setAttribute('contenteditable', `${!this.disabled}`);
189+
// We want to replace any content by just the text content.
190+
this.innerHTML = this.value;
191+
}
192+
193+
public override attributeChangedCallback(
194+
name: string,
195+
old: string | null,
196+
value: string | null,
197+
): void {
198+
if (name !== 'value' || !this._interacted) {
199+
super.attributeChangedCallback(name, old, value);
200+
}
201+
}
202+
203+
/**
204+
* Is called whenever the form is being reset.
205+
*
206+
* @internal
207+
*/
208+
public override formResetCallback(): void {
209+
this._interacted = false;
210+
this.value = this.getAttribute('value') ?? '';
211+
}
212+
213+
/**
214+
* Called when the browser is trying to restore element’s state to state in which case
215+
* reason is “restore”, or when the browser is trying to fulfill autofill on behalf of
216+
* user in which case reason is “autocomplete”.
217+
* In the case of “restore”, state is a string, File, or FormData object
218+
* previously set as the second argument to setFormValue.
219+
*
220+
* @internal
221+
*/
222+
public override formStateRestoreCallback(
223+
state: FormRestoreState | null,
224+
_reason: FormRestoreReason,
225+
): void {
226+
if (state && typeof state === 'string') {
227+
this.value = state;
228+
}
229+
}
230+
231+
protected override updateFormValue(): void {
232+
this.internals.setFormValue(this.value, this.value);
233+
}
234+
235+
private _cleanText(value: string): string {
236+
// The native text input removes all newline characters if passed to the value property
237+
return `${value}`.replace(/[\n\r]+/g, '');
238+
}
239+
240+
@eventOptions({ passive: true })
241+
private _cleanChildren(): void {
242+
if (this.childElementCount) {
243+
for (const element of this.children) {
244+
element.remove();
245+
}
246+
}
247+
}
248+
249+
private _emitChangeIfNecessary(): void {
250+
if (this._shouldEmitChange) {
251+
this._shouldEmitChange = false;
252+
this.dispatchEvent(new Event('change', { bubbles: true }));
253+
}
254+
}
255+
256+
protected override render(): unknown {
257+
return html`<slot @slotchange=${this._cleanChildren}></slot>`;
258+
}
259+
}
260+
261+
return SbbFormAssociatedInputElement as unknown as Constructor<SbbFormAssociatedInputMixinType> &
262+
T;
263+
};

src/elements/date-input.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './date-input/date-input.js';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* @web/test-runner snapshot v1 */
2+
export const snapshots = {};
3+
4+
snapshots["sbb-date-input renders DOM"] =
5+
`<sbb-date-input
6+
contenteditable="true"
7+
value="2024-12-11"
8+
>
9+
We, 11.12.2024
10+
</sbb-date-input>
11+
`;
12+
/* end snapshot sbb-date-input renders DOM */
13+
14+
snapshots["sbb-date-input renders Shadow DOM"] =
15+
`<slot>
16+
</slot>
17+
`;
18+
/* end snapshot sbb-date-input renders Shadow DOM */
19+
20+
snapshots["sbb-date-input renders A11y tree Chrome"] =
21+
`<p>
22+
{
23+
"role": "WebArea",
24+
"name": "",
25+
"children": [
26+
{
27+
"role": "textbox",
28+
"name": "",
29+
"multiline": true,
30+
"children": [
31+
{
32+
"role": "text",
33+
"name": "We, 11.12.2024"
34+
}
35+
],
36+
"value": "We, 11.12.2024"
37+
}
38+
]
39+
}
40+
</p>
41+
`;
42+
/* end snapshot sbb-date-input renders A11y tree Chrome */
43+
44+
snapshots["sbb-date-input renders A11y tree Firefox"] =
45+
`<p>
46+
{
47+
"role": "document",
48+
"name": "",
49+
"children": [
50+
{
51+
"role": "textbox",
52+
"name": "",
53+
"value": "We, 11.12.2024"
54+
}
55+
]
56+
}
57+
</p>
58+
`;
59+
/* end snapshot sbb-date-input renders A11y tree Firefox */
60+

0 commit comments

Comments
 (0)