Skip to content

Commit

Permalink
feat(date-input): create sbb-date-input as a native text input
Browse files Browse the repository at this point in the history
  • Loading branch information
kyubisation committed Dec 13, 2024
1 parent d614fc3 commit 84ad0b2
Show file tree
Hide file tree
Showing 16 changed files with 770 additions and 6 deletions.
9 changes: 7 additions & 2 deletions src/elements/core/datetime/date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const YEARS_PER_ROW: number = 4;
export const YEARS_PER_PAGE: number = 24;
export const FORMAT_DATE =
/(^0?[1-9]?|[12]?[0-9]?|3?[01]?)[.,\\/\-\s](0?[1-9]?|1?[0-2]?)?[.,\\/\-\s](\d{1,4}$)?/;
export const ISO8601_FORMAT_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;

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

/**
* Format the given date as string.
Expand All @@ -146,7 +147,7 @@ export abstract class DateAdapter<T = any> {
*/
public format(
date: T | null | undefined,
options?: { weekdayStyle?: 'long' | 'short' | 'narrow' },
options?: { weekdayStyle?: 'long' | 'short' | 'narrow' | 'none' },
): string {
if (!this.isValid(date)) {
return '';
Expand All @@ -159,6 +160,10 @@ export abstract class DateAdapter<T = any> {
year: 'numeric',
});

if (options?.weekdayStyle === 'none') {
return dateFormatter.format(value);

Check warning on line 164 in src/elements/core/datetime/date-adapter.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L164 was not covered by tests
}

const weekdayStyle = options?.weekdayStyle ?? 'short';
let weekday = this.getDayOfWeekNames(weekdayStyle)[this.getDayOfWeek(date!)];
weekday = weekday.charAt(0).toUpperCase() + weekday.substring(1);
Expand Down
10 changes: 8 additions & 2 deletions src/elements/core/datetime/native-date-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SbbLanguageController } from '../controllers.js';
import type { SbbDateLike } from '../interfaces.js';

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

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

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

const isoMatch = value.match(ISO8601_FORMAT_DATE);
const date = isoMatch ? this.createDate(+isoMatch[1], +isoMatch[2], +isoMatch[3]) : null;
if (this.isValid(date)) {
return date;
}

const strippedValue = value.replace(/\D/g, ' ').trim();

const match: RegExpMatchArray | null | undefined = strippedValue?.match(FORMAT_DATE);
Expand Down
1 change: 1 addition & 0 deletions src/elements/core/mixins.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './mixins/constructor.js';
export * from './mixins/disabled-mixin.js';
export * from './mixins/form-associated-checkbox-mixin.js';
export * from './mixins/form-associated-input-mixin.js';
export * from './mixins/form-associated-mixin.js';
export * from './mixins/form-associated-radio-button-mixin.js';
export * from './mixins/hydration-mixin.js';
Expand Down
263 changes: 263 additions & 0 deletions src/elements/core/mixins/form-associated-input-mixin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { html, type LitElement } from 'lit';
import { eventOptions, property } from 'lit/decorators.js';

import type { Constructor } from './constructor.js';
import {
type FormRestoreReason,
type FormRestoreState,
SbbFormAssociatedMixin,
type SbbFormAssociatedMixinType,
} from './form-associated-mixin.js';
import { SbbRequiredMixin, type SbbRequiredMixinType } from './required-mixin.js';

export declare abstract class SbbFormAssociatedInputMixinType
extends SbbFormAssociatedMixinType
implements Partial<SbbRequiredMixinType>
{
public set disabled(value: boolean);
public get disabled(): boolean;

public set readOnly(value: boolean);
public get readOnly(): boolean;

public set required(value: boolean);
public get required(): boolean;

public formResetCallback(): void;
public formStateRestoreCallback(state: FormRestoreState | null, reason: FormRestoreReason): void;

protected withUserInteraction?(): void;
protected updateFormValue(): void;
}

/**
* The FormAssociatedCheckboxMixin enables native form support for checkbox controls.
*
* Inherited classes MUST implement the ariaChecked state (ElementInternals) themselves.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const SbbFormAssociatedInputMixin = <T extends Constructor<LitElement>>(
superClass: T,
): Constructor<SbbFormAssociatedInputMixinType> & T => {
abstract class SbbFormAssociatedInputElement
extends SbbRequiredMixin(SbbFormAssociatedMixin(superClass))
implements Partial<SbbFormAssociatedInputMixinType>
{
/**
* The native text input changes the value property when the value attribute is
* changed under the condition that no input event has occured since creation
* or the last form reset.
*/
private _interacted = false;
/**
* An element with contenteditable will not emit a change event. To achieve parity
* with a native text input, we need to track whether a change event should be
* emitted.
*/
private _shouldEmitChange = false;
/**
* A native text input attempts to submit the form when pressing Enter.
* This can be prevented by calling preventDefault on the keydown event.
* We track whether to request submit, which should occur before the keyup
* event.
*/
private _shouldTriggerSubmit = false;

/**
* Form type of element.
* @default 'text'
*/
public override get type(): string {
return 'text';
}

/**
* The text value of the input element.
*/
public override set value(value: string) {
this.textContent = this._cleanText(value);
}
public override get value(): string {
return this.textContent ?? '';
}

/**
* Whether the component is readonly.
* @attr readonly
* @default false
*/
@property({ attribute: false })
public set readOnly(value: boolean) {
this.toggleAttribute('readonly', !!value);
}
public get readOnly(): boolean {
return this.hasAttribute('readonly');
}

/**
* Whether the component is disabled.
* @attr disabled
* @default false
*/
@property({ attribute: false })
public set disabled(value: boolean) {
this.toggleAttribute('disabled', !!value);
if (this.isConnected) {
this.setAttribute('contenteditable', `${!value}`);
}
}
public get disabled(): boolean {
return this.hasAttribute('disabled');
}

protected constructor() {
super();
/** @internal */
this.internals.role = 'textbox';
// We primarily use capture event listeners, as we want
// our listeners to occur before consumer event listeners.
this.addEventListener?.(
'input',
() => {
this._interacted = true;
this._shouldEmitChange = true;
this.updateFormValue();
},
{ capture: true },
);
this.addEventListener?.(
'keydown',
(event) => {
if ((event.key === 'Enter' || event.key === '\n') && event.isTrusted) {
event.preventDefault();
event.stopImmediatePropagation();
// We prevent recursive events by checking the original event for isTrusted
// which is false for manually dispatched events.
this._shouldTriggerSubmit = this.dispatchEvent(new KeyboardEvent('keydown', event));
} else if (this.readOnly) {
event.preventDefault();
}
},
{ capture: true },
);
this.addEventListener?.(
'keyup',
(event) => {
if (event.key === 'Enter' || event.key === '\n') {
this._emitChangeIfNecessary();
if (this._shouldTriggerSubmit) {
this._shouldTriggerSubmit = false;
this.form?.requestSubmit();
}
}
},
{ capture: true },
);
// contenteditable allows pasting rich content into its host.
// We prevent this by listening to the paste event and
// extracting the plain text from the pasted content
// and inserting it into the selected range (cursor position
// or selection).
this.addEventListener?.('paste', (e) => {
e.preventDefault();
const text = e.clipboardData?.getData('text/plain');
const selectedRange = window.getSelection()?.getRangeAt(0);
if (!selectedRange || !text) {
return;
}

selectedRange.deleteContents();
selectedRange.insertNode(document.createTextNode(text));
selectedRange.setStart(selectedRange.endContainer, selectedRange.endOffset);
this.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }));
});
// On blur the native text input scrolls the text to the start of the text.
// We mimick that by resetting the scroll position.
this.addEventListener?.(
'blur',
() => {
this._emitChangeIfNecessary();
this.scrollLeft = 0;
},
{ capture: true },
);
}

public override connectedCallback(): void {
super.connectedCallback();
this.setAttribute('contenteditable', `${!this.disabled}`);
// We want to replace any content by just the text content.
this.innerHTML = this.value;
}

public override attributeChangedCallback(
name: string,
old: string | null,
value: string | null,
): void {
if (name !== 'value' || !this._interacted) {
super.attributeChangedCallback(name, old, value);
}
}

/**
* Is called whenever the form is being reset.
*
* @internal
*/
public override formResetCallback(): void {
this._interacted = false;
this.value = this.getAttribute('value') ?? '';
}

/**
* Called when the browser is trying to restore element’s state to state in which case
* reason is “restore”, or when the browser is trying to fulfill autofill on behalf of
* user in which case reason is “autocomplete”.
* In the case of “restore”, state is a string, File, or FormData object
* previously set as the second argument to setFormValue.
*
* @internal
*/
public override formStateRestoreCallback(
state: FormRestoreState | null,
_reason: FormRestoreReason,
): void {
if (state && typeof state === 'string') {
this.value = state;
}
}

protected override updateFormValue(): void {
this.internals.setFormValue(this.value, this.value);
}

private _cleanText(value: string): string {
// The native text input removes all newline characters if passed to the value property
return `${value}`.replace(/[\n\r]+/g, '');
}

@eventOptions({ passive: true })
private _cleanChildren(): void {
if (this.childElementCount) {
for (const element of this.children) {
element.remove();
}
}
}

private _emitChangeIfNecessary(): void {
if (this._shouldEmitChange) {
this._shouldEmitChange = false;
this.dispatchEvent(new Event('change', { bubbles: true }));
}
}

protected override render(): unknown {
return html`<slot @slotchange=${this._cleanChildren}></slot>`;
}
}

return SbbFormAssociatedInputElement as unknown as Constructor<SbbFormAssociatedInputMixinType> &
T;
};
1 change: 1 addition & 0 deletions src/elements/date-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './date-input/date-input.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/* @web/test-runner snapshot v1 */
export const snapshots = {};

snapshots["sbb-date-input renders DOM"] =
`<sbb-date-input
contenteditable="true"
value="2024-12-11"
>
We, 11.12.2024
</sbb-date-input>
`;
/* end snapshot sbb-date-input renders DOM */

snapshots["sbb-date-input renders Shadow DOM"] =
`<slot>
</slot>
`;
/* end snapshot sbb-date-input renders Shadow DOM */

snapshots["sbb-date-input renders A11y tree Chrome"] =
`<p>
{
"role": "WebArea",
"name": "",
"children": [
{
"role": "textbox",
"name": "",
"multiline": true,
"children": [
{
"role": "text",
"name": "We, 11.12.2024"
}
],
"value": "We, 11.12.2024"
}
]
}
</p>
`;
/* end snapshot sbb-date-input renders A11y tree Chrome */

snapshots["sbb-date-input renders A11y tree Firefox"] =
`<p>
{
"role": "document",
"name": "",
"children": [
{
"role": "textbox",
"name": "",
"value": "We, 11.12.2024"
}
]
}
</p>
`;
/* end snapshot sbb-date-input renders A11y tree Firefox */

Loading

0 comments on commit 84ad0b2

Please sign in to comment.