Skip to content

Commit

Permalink
feat: add sd-switch and sd-label (#65)
Browse files Browse the repository at this point in the history
* feat: initial support for sd-switch

* docs: update descriptions of elements

* fix: event argument types of signals

* feat: add support for native labels

* feat: add sd-label, and improve support for labelling

* fix: remove focus on click

* fix: spacing

* feat: add custom data

* refactor: improve highlight of elements to not use negative margin

* fix: improve spacing, and focus state of sd-switch

* feat: improve support of sd-label

* fix: switch toggling back on click

* fix: sd-switch within sd-label

* fix: linting

* fix: improve support for nested sd-label

* feat: add label to sd-switch, and refactor to use sd-label

* refactor: revert support for native labels in favor of sd-label

* style: linting

* feat: add "on" attr to sd-switch

* chore: minor refactor of class names

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Oct 29, 2024
1 parent 7343dc5 commit ec9fd82
Show file tree
Hide file tree
Showing 9 changed files with 506 additions and 86 deletions.
50 changes: 49 additions & 1 deletion .vscode/html.customData.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,57 @@
"$schema": "https://raw.githubusercontent.com/microsoft/vscode-html-languageservice/master/docs/customData.schema.json",
"version": 1.1,
"tags": [
{
"name": "sd-field",
"description": "Element that provides a label for placeholder containing an input.",
"attributes": [
{
"name": "label",
"description": "Label to show for the field."
}
]
},
{
"name": "sd-label",
"description": "Element that provides a label for input element.",
"attributes": [
{
"name": "for",
"description": "Identifier of the element the label is for."
}
]
},
{
"name": "sd-switch",
"description": "Element that offers persisting a `boolean` via a toggle switch.",
"attributes": [
{
"name": "disabled",
"description": "Determines whether the input is disabled; default `false`.",
"values": [{ "name": "disabled" }]
},
{
"name": "global",
"description": "When `true`, the setting will be persisted in the global settings, otherwise it will be persisted in the action's settings; default `false`.",
"values": [{ "name": "global" }]
},
{
"name": "label",
"description": "Label of the switch."
},
{
"name": "on",
"description": "Determines the on/off state of the switch."
},
{
"name": "setting",
"description": "Path to the setting where the value should be persisted, for example `name`."
}
]
},
{
"name": "sd-textfield",
"description": "Text field capable of persisting `string` values to Stream Deck settings.",
"description": "Element that offers persisting a `string` via a text input.",
"attributes": [
{
"name": "disabled",
Expand Down
35 changes: 14 additions & 21 deletions src/ui/components/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { css, html, type HTMLTemplateResult, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";

/**
* Field that identifies an input, or group of inputs.
* Element that provides a label for placeholder containing an input.
*/
@customElement("sd-field")
export class SDFieldElement extends LitElement {
Expand All @@ -11,13 +11,18 @@ export class SDFieldElement extends LitElement {
*/
public static styles = [
css`
.sd-field {
align-items: baseline;
.field {
column-gap: var(--space-xs);
display: grid;
grid-template-columns: 95px 262px;
margin-bottom: var(--space-s);
}
.label {
align-items: center;
display: flex;
height: var(--size-2xl);
}
`,
];

Expand All @@ -32,34 +37,22 @@ export class SDFieldElement extends LitElement {
*/
public render(): HTMLTemplateResult {
return html`
<div class="sd-field">
<div class="sd-field-label">
<label @click=${this.#focusFirstElement}>${this.label ? this.label + ":" : undefined}</label>
<div class="field">
<div class="label">
<sd-label for="input">${this.label ? `${this.label}:` : undefined}</sd-label>
</div>
<div class="sd-field-input">
<slot></slot>
<div>
<slot id="input"></slot>
</div>
</div>
`;
}

/**
* Focuses the first element, that can have focus, within the field.
*/
#focusFirstElement(): void {
for (const el of this.querySelectorAll("*")) {
if ("focus" in el && typeof el.focus === "function") {
el.focus();
return;
}
}
}
}

declare global {
interface HTMLElementTagNameMap {
/**
* Field that identifies an input, or group of inputs.
* Element that provides a label for placeholder containing an input.
*/
"sd-field": SDFieldElement;
}
Expand Down
2 changes: 2 additions & 0 deletions src/ui/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
import "./field";
import "./label";
import "./switch";
import "./textfield";
150 changes: 150 additions & 0 deletions src/ui/components/label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";

import { isSDInputElement } from "../mixins/input";

/**
* Element that provides a label for input element.
*/
@customElement("sd-label")
export class SDLabelElement extends LitElement {
/**
* Reference to the slot element within the label of the shadow DOM.
*/
#slotRef = createRef<HTMLSlotElement>();

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

this.role = "label";
this.addEventListener("click", (ev: MouseEvent) => {
// Stop propagation to prevent multiple triggers of nested labels.
ev.stopImmediatePropagation();
this.activate();
});
}

/**
* Identifier of the element the label is for.
*/
@property({ attribute: "for" })
public accessor htmlFor: string | undefined;

/**
* Activates the input associated with this label element.
*/
public activate(): void {
const target = this.#getTarget();
if (target) {
this.#activate(target);
}
}

/**
* @inheritdoc
*/
public override render(): TemplateResult {
return html`<label
for=${ifDefined(this.htmlFor)}
@mousedown=${(ev: MouseEvent): void => {
// Disable text selection on double-click.
if (ev.detail > 1) {
ev.preventDefault();
}
}}
><slot ${ref(this.#slotRef)}></slot>
</label>`;
}

/**
* Activates the specified element, or the first activable element if the specified one is a slot.
* @param element Element to activate.
*/
#activate(element: HTMLElement): void {
// When the element is a regular element, attempt to activate it.
if (!(element instanceof HTMLSlotElement)) {
this.#tryActivate(element);
return;
}

// Otherwise, as we have a slot, attempt to find the first element that can be activated.
for (const slotElement of element.assignedElements({ flatten: true })) {
if (slotElement instanceof HTMLElement && this.#tryActivate(slotElement)) {
return;
}
}
}

/**
* Gets the target element the label is for.
* @returns The target element when one is specified; otherwise the slot of the label.
*/
#getTarget(): HTMLElement | null | undefined {
// When an input isn't specified, return the slot within the label.
if (!this.htmlFor) {
return this.#slotRef.value;
}

// Prioritize elements within the shadow DOM.
const root = this.parentNode?.getRootNode();
if (root && root instanceof ShadowRoot) {
return root.getElementById(this.htmlFor);
}

// Otherwise, revert to the document.
return document.getElementById(this.htmlFor);
}

/**
* Attempts to activate the specified element, either clicking or focusing it.
* @param element Element to attempt to activate.
* @returns `true` when the element was activated; otherwise `false`.
*/
#tryActivate(element: HTMLElement): boolean {
// Labels can be activated.
if (element instanceof SDLabelElement) {
element.activate();
return true;
}

// Elements that should be clicked.
if (
element.role === "checkbox" ||
element.role === "radio" ||
(element instanceof HTMLInputElement && (element.type === "checkbox" || element.type === "radio"))
) {
element.click();
return true;
}

// Elements that should be focused.
if (
element.role === "button" ||
element.role === "textbox" ||
element instanceof HTMLButtonElement ||
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement ||
isSDInputElement(element)
) {
element.focus();
return true;
}

return false;
}
}

declare global {
interface HTMLElementTagNameMap {
/**
* Element that provides a label for input element.
*/
"sd-label": SDLabelElement;
}
}
Loading

0 comments on commit ec9fd82

Please sign in to comment.