Skip to content

Commit

Permalink
feat: add sd-textarea (#67)
Browse files Browse the repository at this point in the history
* feat: add sd-textarea, fix disabled color text of text field

* style: fix linting

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Nov 5, 2024
1 parent 7ddddca commit 2090d9e
Show file tree
Hide file tree
Showing 5 changed files with 331 additions and 39 deletions.
56 changes: 56 additions & 0 deletions src/ui/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { cls } from "../utils";

/**
* Provides assertions for the {@link cls} utility function.
*/
describe("cls", () => {
test.each([
{
name: "empty is undefined",
values: [],
expected: "",
},
{
name: "single string",
values: ["test"],
expected: "test",
},
{
name: "multiple strings",
values: ["foo", "bar"],
expected: "foo bar",
},
{
name: "truthy",
// eslint-disable-next-line no-constant-binary-expression
values: [1 && "yes"],
expected: "yes",
},
{
name: "falsy undefined",
// eslint-disable-next-line no-constant-binary-expression
values: [undefined && "no", "yes"],
expected: "yes",
},
{
name: "falsy null",
// eslint-disable-next-line no-constant-binary-expression
values: [null && "no", "yes"],
expected: "yes",
},
{
name: "falsy 0",
// eslint-disable-next-line no-constant-binary-expression
values: [0 && "no", "yes"],
expected: "yes",
},
{
name: "hyphens",
// eslint-disable-next-line no-constant-binary-expression
values: [true && "container--disabled"],
expected: "container--disabled",
},
])("$name", ({ values, expected }) => {
expect(cls(...values)).toBe(expected);
});
});
1 change: 1 addition & 0 deletions src/ui/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import "./label";
import "./option";
import "./radio-group";
import "./switch";
import "./text-area";
import "./text-field";
216 changes: 216 additions & 0 deletions src/ui/components/text-area.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { css, html, type HTMLTemplateResult, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";

import { Input } from "../mixins/input";
import { type HTMLInputEvent } from "../utils";

/**
* Element that offers persisting a `string` via a text area.
*/
@customElement("sd-textarea")
export class SDTextAreaElement extends Input<string>(LitElement) {
/**
* @inheritdoc
*/
public static styles = [
super.styles ?? [],
css`
.container {
display: grid;
width: 224px;
}
.container::after,
textarea,
.counter {
grid-area: 1 / 1 / 2 / 2; /* Place everything on top of one another */
}
/**
* Important: the container content placeholder and textarea *must* have the same styling
* so they wrap equally.
*/
.container::after,
textarea {
background-color: var(--color-surface);
border: none;
border-radius: var(--rounding-m);
color: var(--color-content-primary);
font-family: var(--typography-body-m-family);
font-size: var(--typography-body-m-size);
font-weight: var(--typography-body-m-weight);
min-height: var(--size-4xl);
outline: none;
padding: var(--space-xs);
overflow: hidden;
width: 224px;
}
.container:has(.counter) {
&::after,
& > textarea {
min-height: var(--size-2xl);
padding-bottom: var(--space-xl);
}
}
.container::after {
content: attr(data-content) " "; /* Extra space needed to prevent jumpy behavior */
visibility: hidden;
word-wrap: break-word;
white-space: pre-wrap;
}
textarea {
overflow: none;
resize: none;
&::placeholder {
color: var(--color-content-secondary);
}
&:disabled,
&:disabled::placeholder {
color: var(--color-content-disabled);
}
&:focus,
&:invalid {
box-shadow: var(--highlight-box-shadow);
outline-offset: var(--highlight-outline-offset);
}
&:focus,
&:focus:invalid {
outline: var(--highlight-outline--focus);
}
&:invalid {
outline: var(--highlight-outline--invalid);
}
}
.counter {
align-self: flex-end;
color: var(--color-content-secondary);
justify-self: flex-end;
padding: 0 var(--size-xs) var(--size-xs) 0;
user-select: none;
& span {
margin: 0 var(--size-3xs);
}
}
textarea:not(:disabled) + .counter {
cursor: text; /* Give the impression the label isn't there */
}
`,
];

/**
* Initializes a new instance of the {@link SDTextAreaElement} class.
*/
constructor() {
super();

this.debounceSave = true;
this.role = "textbox";
}

/**
* Maximum length the value can be.
*/
@property({
attribute: "maxlength",
type: Number,
})
public accessor maxLength: number | undefined;

/**
* Optional placeholder text to be shown within the element.
*/
@property()
public accessor placeholder: string | undefined;

/**
* Determines whether a value is required.
*/
@property({ type: Boolean })
public accessor required = false;

/**
* Determines whether the user has interacted with the text field; primarily used to mimic
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/:user-invalid `:user-invalid`} in
* conjunction with `required`.
*/
@state()
accessor #userHasInteracted = false;

/**
* References to the container around the text element; allows the text area to expand.
*/
#containerRef = createRef<HTMLDivElement>();

/**
* @inheritdoc
*/
public override render(): TemplateResult {
return html`
<div ${ref(this.#containerRef)} class="container">
<textarea
${ref(this.inputRef)}
id="textarea"
maxlength=${ifDefined(this.maxLength)}
placeholder=${ifDefined(this.placeholder)}
.value=${this.value ?? ""}
?disabled=${this.disabled}
?required=${this.#userHasInteracted && this.required}
@blur=${(): void => {
this.#userHasInteracted = true;
}}
@input=${(ev: HTMLInputEvent<HTMLTextAreaElement>): void => {
this.value = ev.target.value;
}}
></textarea>
${this.#getCounter()}
</div>
`;
}

/**
* @inheritdoc
*/
protected override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
super.willUpdate(_changedProperties);

if (_changedProperties.has("value") && this.#containerRef.value) {
this.#containerRef.value.dataset.content = this.value;
}
}

/**
* Gets the counter text, displayed in the lower right corner of the text area.
* @returns The counter element.
*/
#getCounter(): HTMLTemplateResult | undefined {
if (this.maxLength) {
return html`
<label class="counter" for="textarea"> ${this.value?.length ?? 0}<span>/</span>${this.maxLength} </label>
`;
}

return undefined;
}
}

declare global {
interface HTMLElementTagNameMap {
/**
* Element that offers persisting a `string` via a text area.
*/
"sd-textarea": SDTextAreaElement;
}
}
81 changes: 42 additions & 39 deletions src/ui/components/text-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,34 @@ export class SDTextFieldElement extends Input<string>(LitElement) {
font-size: var(--typography-body-m-size);
font-weight: var(--typography-body-m-weight);
height: var(--size-2xl);
min-height: var(--size-2xl);
outline: none;
padding: 0 var(--space-xs);
min-height: 32px;
width: 224px;
}
input::placeholder {
color: var(--color-content-secondary);
}
input:disabled {
color: var(--color-content-disabled);
}
input:focus,
input:invalid {
box-shadow: var(--highlight-box-shadow);
outline-offset: var(--highlight-outline-offset);
}
input:focus,
input:focus:invalid {
outline: var(--highlight-outline--focus);
}
input:invalid {
outline: var(--highlight-outline--invalid);
&::placeholder {
color: var(--color-content-secondary);
}
&:disabled,
&:disabled::placeholder {
color: var(--color-content-disabled);
}
&:focus,
&:invalid {
box-shadow: var(--highlight-box-shadow);
outline-offset: var(--highlight-outline-offset);
}
&:focus,
&:focus:invalid {
outline: var(--highlight-outline--focus);
}
&:invalid {
outline: var(--highlight-outline--invalid);
}
}
`,
];
Expand Down Expand Up @@ -112,22 +113,24 @@ export class SDTextFieldElement extends Input<string>(LitElement) {
* @inheritdoc
*/
public override render(): TemplateResult {
return html`<input
${ref(this.inputRef)}
maxlength=${ifDefined(this.maxLength)}
pattern=${ifDefined(this.pattern)}
placeholder=${ifDefined(this.placeholder)}
?disabled=${this.disabled}
?required=${this.#userHasInteracted && this.required}
.type=${this.type ?? "text"}
.value=${this.value ?? ""}
@blur=${(): void => {
this.#userHasInteracted = true;
}}
@input=${(ev: HTMLInputEvent<HTMLInputElement>): void => {
this.value = ev.target.value;
}}
/>`;
return html`
<input
${ref(this.inputRef)}
maxlength=${ifDefined(this.maxLength)}
pattern=${ifDefined(this.pattern)}
placeholder=${ifDefined(this.placeholder)}
?disabled=${this.disabled}
?required=${this.#userHasInteracted && this.required}
.type=${this.type ?? "text"}
.value=${this.value ?? ""}
@blur=${(): void => {
this.#userHasInteracted = true;
}}
@input=${(ev: HTMLInputEvent<HTMLInputElement>): void => {
this.value = ev.target.value;
}}
/>
`;
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/ui/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
/**
* Utility function for building CSS class names from an array of truthy values.
* @param values CSS class names; when truthy, the class name will be included in the result.
* @returns The flattened CSS class name; otherwise `undefined` when no values were truthy.
*/
export function cls(...values: unknown[]): string {
let str = "";
for (const value of values) {
if (value) {
str += str ? ` ${value}` : value;
}
}

return str;
}

/**
* Prevents the default behavior occurring when a double click occurs, preventing text-selection.
* @param ev Source event.
Expand Down

0 comments on commit 2090d9e

Please sign in to comment.