Skip to content

Commit

Permalink
feat: add <sd-checkbox /> and <sd-checkboxgroup /> (#71)
Browse files Browse the repository at this point in the history
* feat: add sd-checkbox

* refactor: remove label of switch in favour of textContext

* feat: add support for sd-label

* refactor: align checkbox and switch rendering

* feat: improve margin management

* fix: top/bottom margin

* feat: add sd-checkboxgroup

* docs: minor re-wording

* refactor: update option-type elements to all utilize textContent as their label

* refactor: update option-type elements to all utilize textContent as their label

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Nov 18, 2024
1 parent cf64740 commit 14008df
Show file tree
Hide file tree
Showing 8 changed files with 391 additions and 72 deletions.
79 changes: 79 additions & 0 deletions src/ui/components/checkbox-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";

import { Input } from "../mixins/input";
import { List } from "../mixins/list";
import { SDCheckboxElement } from "./checkbox";
import { type SDOptionElement } from "./option";

/**
* Element that offers persisting an set of values, from a group of checkbox options.
*/
@customElement("sd-checkboxgroup")
export class SDCheckboxGroupElement extends List(Input<(boolean | number | string)[]>(LitElement)) {
/**
* @inheritdoc
*/
public static styles = [
super.styles ?? [],
css`
sd-checkbox {
display: flex;
}
`,
];

/**
* @inheritdoc
*/
public override render(): TemplateResult {
return html`
${repeat(
this.items,
(opt) => opt,
(opt) => {
return html`<sd-checkbox
.checked=${(this.value ?? []).findIndex((value) => value === opt.value) > -1}
.disabled=${opt.disabled}
.label=${opt.label}
@change=${(ev: Event): void => {
if (ev.target instanceof SDCheckboxElement) {
this.#handleChange(ev.target.checked, opt.value);
}
}}
/>`;
},
)}
`;
}

/**
* Handles a checkbox state changing.
* @param checked Whether the checkbox is checked.
* @param value Value the checkbox represents.
*/
#handleChange(checked: boolean, value: SDOptionElement["value"]): void {
if (value === undefined) {
return;
}

const values = new Set(this.value);
if (checked) {
values.add(value);
} else {
values.delete(value);
}

this.value = Array.from(values);
}
}

declare global {
interface HTMLElementTagNameMap {
/**
* Element that offers persisting an set of values, from a group of checkbox options.
*/
"sd-checkboxgroup": SDCheckboxGroupElement;
}
}
181 changes: 181 additions & 0 deletions src/ui/components/checkbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { ref } from "lit/directives/ref.js";

import { Input } from "../mixins/input";
import { Labeled } from "../mixins/labeled";
import { type HTMLInputEvent, preventDoubleClickSelection } from "../utils";

/**
* Element that offers persisting a `boolean` via a checkbox.
*/
@customElement("sd-checkbox")
export class SDCheckboxElement extends Labeled(Input(LitElement)) {
/**
* @inheritdoc
*/
public static styles = [
super.styles ?? [],
css`
/**
* Container
*/
:host {
display: inline-flex;
}
label {
align-items: center;
display: inline-flex;
margin: var(--space-xs) 0;
outline: none;
&:focus-visible .checkbox {
box-shadow: var(--highlight-box-shadow);
outline: var(--highlight-outline--focus);
outline-offset: var(--highlight-outline-offset);
}
&:has(input:disabled) {
color: var(--color-content-disabled);
}
}
/**
* Checkbox and text
*/
.checkbox {
border: solid 1px var(--color-border-strong);
border-radius: var(--rounding-m);
box-sizing: border-box;
height: var(--size-m);
width: var(--size-m);
user-select: none;
}
.checkbox > svg {
visibility: hidden;
}
.text {
margin-left: var(--space-xs);
}
/**
* States
*/
input {
display: none;
/* Checked */
&:checked + .checkbox {
border-width: 0;
background-color: var(--color-surface-accent);
color: var(--color-content-ondark);
& > svg {
visibility: visible;
}
}
/* Disabled */
&:disabled {
& + .checkbox {
border-color: var(--color-border-subtle-disabled);
}
&:checked + .checkbox {
background-color: var(--color-surface-disabled);
color: var(--color-content-disabled);
}
}
}
`,
];

/**
* Initializes a new instance of the {@link SDCheckboxElement} class.
*/
constructor() {
super();
this.role = "checkbox";
}

/**
* Gets the checked state.
* @returns `true` when the checkbox is checked; otherwise `false`.
*/
public get checked(): boolean {
return !!this.value;
}

/**
* Sets the checked state.
* @param value Value indicating whether the checkbox is checked.
*/
public set checked(value: boolean) {
this.value = value;
}

/**
* @inheritdoc
*/
public override click(): void {
if (!this.disabled) {
this.checked = !this.checked;
}
}

/**
* @inheritdoc
*/
public override render(): TemplateResult {
return html`
<label
tabindex=${ifDefined(this.disabled ? undefined : 0)}
@mousedown=${preventDoubleClickSelection}
@keydown=${(ev: KeyboardEvent): void => {
// Toggle switch on space bar key.
if (ev.code === "Space") {
this.checked = !this.checked;
ev.preventDefault();
}
}}
>
<input
${ref(this.inputRef)}
type="checkbox"
.checked=${this.checked}
.disabled=${this.disabled}
@change=${(ev: HTMLInputEvent<HTMLInputElement>): void => {
this.checked = ev.target.checked;
this.dispatchEvent(new Event("change")); // TODO: relocate this to Input for closed shadow roots
}}
/>
<div class="checkbox" role="checkbox">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" fill="currentColor" viewBox="0 0 24 24">
<path
d="M19.78 7.22a.75.75 0 0 1 0 1.06l-9.5 9.5a.75.75 0 0 1-1.06 0l-5-5a.75.75 0 1 1 1.06-1.06l4.47 4.47 8.97-8.97a.75.75 0 0 1 1.06 0Z"
/>
</svg>
</div>
${this.label && html`<span class="text">${this.label}</span>`}
</label>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
/**
* Element that offers persisting a `boolean` via a checkbox.
*/
"sd-checkbox": SDCheckboxElement;
}
}
2 changes: 2 additions & 0 deletions src/ui/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from "./button";
export * from "./checkbox";
export * from "./checkbox-group";
export * from "./divider";
export * from "./field";
export * from "./label";
Expand Down
9 changes: 2 additions & 7 deletions src/ui/components/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";

import { parseBoolean, parseNumber } from "../../common/utils";
import { Labeled } from "../mixins/labeled";

/**
* Non-visual element that provides information for an option.
*/
@customElement("sd-option")
export class SDOptionElement extends LitElement {
export class SDOptionElement extends Labeled(LitElement) {
/**
* Private backing field for {@link SDOptionElement.value}.
*/
Expand All @@ -22,12 +23,6 @@ export class SDOptionElement extends LitElement {
})
public accessor disabled: boolean = false;

/**
* Label that represents the option.
*/
@property()
public accessor label: string | undefined;

/**
* Type of the value; allows for the value to be converted to a boolean or number.
*/
Expand Down
7 changes: 3 additions & 4 deletions src/ui/components/radio-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { List } from "../mixins/list";
import { SDRadioElement } from "./radio";

/**
* Element that offers persisting a value via a list of radio options.
* Element that offers persisting a `boolean`, `number`, or `string` from a list of radio options.
*/
@customElement("sd-radiogroup")
export class SDRadioGroupElement extends List(Input<boolean | number | string>(LitElement)) {
Expand Down Expand Up @@ -42,8 +42,7 @@ export class SDRadioGroupElement extends List(Input<boolean | number | string>(L
@change=${(): void => {
this.value = opt.value;
}}
>${opt.innerText}</sd-radio
>`;
/>`;
},
)}
`;
Expand All @@ -53,7 +52,7 @@ export class SDRadioGroupElement extends List(Input<boolean | number | string>(L
declare global {
interface HTMLElementTagNameMap {
/**
* Element that offers persisting a value via a list of radio options.
* Element that offers persisting a `boolean`, `number`, or `string` from a list of radio options.
*/
"sd-radiogroup": SDRadioGroupElement;
}
Expand Down
11 changes: 3 additions & 8 deletions src/ui/components/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,6 @@ export class SDRadioElement extends SDOptionElement {
})
public accessor checked: boolean = false;

/**
* Fallback label, derived from the original inner text of this element when creating the render root.
*/
#fallbackLabel: string | undefined;

/**
* @inheritdoc
*/
Expand Down Expand Up @@ -173,8 +168,10 @@ export class SDRadioElement extends SDOptionElement {
.checked=${this.checked}
.disabled=${this.disabled}
/>
<span role="radio" aria-checked=${this.checked}></span>
${this.label ?? this.#fallbackLabel}
${this.label}
</label>
`;
}
Expand All @@ -184,9 +181,7 @@ export class SDRadioElement extends SDOptionElement {
*/
protected override createRenderRoot(): DocumentFragment | HTMLElement {
// Shadow root has to be open to allow for joining named radio buttons.
this.#fallbackLabel = this.innerText;
this.innerHTML = "";

return this;
}
}
Expand Down
Loading

0 comments on commit 14008df

Please sign in to comment.