(
+ (value) => (value ? converter("oklch")(value) : fallback) ?? fallback,
+ );
diff --git a/src/_common/clear.ts b/src/_common/clear.ts
deleted file mode 100644
index 233fb9b..0000000
--- a/src/_common/clear.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import {
- asMethod,
- type Component,
- type ComponentUI,
- type Effect,
- on,
- show,
- type UI,
-} from "@zeix/le-truc";
-
-/**
- * Creates a clear method for input components
- *
- * @param {HTMLInputElement | HTMLTextAreaElement} selector - The native input or textarea element
- */
-export const clearMethod = asMethod(
- <
- P extends {
- clear: () => void;
- value: string | number;
- readonly length: number;
- },
- U extends {
- host: Component;
- textbox: HTMLInputElement | HTMLTextAreaElement;
- },
- >({
- host,
- textbox,
- }: ComponentUI
) => {
- host.clear = () => {
- host.value = "";
- textbox.value = "";
- textbox.setCustomValidity("");
- textbox.checkValidity();
- textbox.dispatchEvent(new Event("input", { bubbles: true }));
- textbox.dispatchEvent(new Event("change", { bubbles: true }));
- textbox.focus();
- };
- },
-);
-
-/**
- * Standard effects for clearing input components on button elements
- *
- * @param {ComponentUI
} ui - The component UI with a host that has clear, length properties
- * @returns {Effect
[]} - Effects for clearing the input component
- */
-export const clearEffects = <
- P extends { clear: () => void; readonly length: number },
- U extends UI,
->(
- ui: ComponentUI
,
-): Effect
[] => [
- show(() => !!ui.host.length),
- on("click", () => {
- ui.host.clear();
- }),
-];
diff --git a/src/_common/escape.ts b/src/_common/escape.ts
deleted file mode 100644
index 61b9e91..0000000
--- a/src/_common/escape.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Escapes HTML entities to prevent XSS attacks
- */
-export function escapeHTML(text: string): string {
- return text
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
-}
diff --git a/src/_common/fetch.ts b/src/_common/fetchWithCache.ts
similarity index 100%
rename from src/_common/fetch.ts
rename to src/_common/fetchWithCache.ts
diff --git a/src/_common/focus.ts b/src/_common/focus.ts
deleted file mode 100644
index f4d040f..0000000
--- a/src/_common/focus.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { on } from "@zeix/le-truc";
-
-/* === Constants === */
-
-const ENTER_KEY = "Enter";
-const DECREMENT_KEYS = ["ArrowLeft", "ArrowUp"];
-const INCREMENT_KEYS = ["ArrowRight", "ArrowDown"];
-const FIRST_KEY = "Home";
-const LAST_KEY = "End";
-const HANDLED_KEYS = [
- ...DECREMENT_KEYS,
- ...INCREMENT_KEYS,
- FIRST_KEY,
- LAST_KEY,
-];
-
-/* === Exported Functions === */
-
-export const manageFocus = (
- getElements: () => E[],
- getSelectedIndex: (radios: E[]) => number,
-) => {
- let index = getSelectedIndex(getElements());
-
- return [
- on("click", ({ target }) => {
- if (!(target instanceof HTMLElement)) return;
- if (target.hasAttribute("value"))
- index = getElements().indexOf(target as E);
- }),
- on("keydown", (e) => {
- const { key } = e;
- if (!HANDLED_KEYS.includes(key)) return;
-
- const elements = getElements();
- e.preventDefault();
- e.stopPropagation();
- if (key === FIRST_KEY) index = 0;
- else if (key === LAST_KEY) index = elements.length - 1;
- else
- index =
- (index + (INCREMENT_KEYS.includes(key) ? 1 : -1) + elements.length) %
- elements.length;
- const focused = elements[index];
- if (focused) focused.focus();
- }),
- on("keyup", ({ key }) => {
- if (key !== ENTER_KEY) return;
-
- const element = getElements()[index];
- if (element) element.click();
- }),
- ];
-};
diff --git a/src/_common/getLocale.ts b/src/_common/getLocale.ts
new file mode 100644
index 0000000..e13ad93
--- /dev/null
+++ b/src/_common/getLocale.ts
@@ -0,0 +1,6 @@
+const FALLBACK_LOCALE = 'en'
+
+export function getLocale(el: HTMLElement): string {
+ const locale = el.closest('[lang]')?.getAttribute('lang')
+ return locale || FALLBACK_LOCALE
+}
diff --git a/src/_common/getStepColor.ts b/src/_common/getStepColor.ts
new file mode 100644
index 0000000..f9e4203
--- /dev/null
+++ b/src/_common/getStepColor.ts
@@ -0,0 +1,12 @@
+import type { Oklch } from 'culori'
+
+export const getStepColor = (base: Oklch, step: number): Oklch => {
+ const exp = 2 * Math.log((1 - base.l) / base.l)
+ const stepL =
+ base.l !== 0.5 ? (Math.exp(exp * step) - 1) / (Math.exp(exp) - 1) : step
+ const stepC =
+ base.c > 0
+ ? (base.c * (8 * Math.sin((Math.PI * (4 * step + 1)) / 6) ** 3 - 1)) / 7
+ : 0
+ return { mode: 'oklch', l: stepL, c: stepC, h: base.h ?? 0 }
+}
diff --git a/src/_common/highlight.ts b/src/_common/highlightMatch.ts
similarity index 93%
rename from src/_common/highlight.ts
rename to src/_common/highlightMatch.ts
index 5e420f3..239736f 100644
--- a/src/_common/highlight.ts
+++ b/src/_common/highlightMatch.ts
@@ -1,4 +1,4 @@
-import { escapeHTML } from "./escape";
+import { escapeHTML } from "@zeix/le-truc";
/**
* Safely creates HTML with highlighted matches
@@ -28,7 +28,7 @@ export function highlightMatch(
// Reset lastIndex for global RegExp
pattern.lastIndex = 0;
- // biome-ignore lint/suspicious/noAssignInExpressions: cleaner
+ // biome-ignore lint/suspicious/noAssignInExpressions: optimization
while ((match = pattern.exec(text)) !== null) {
matches.push({
start: match.index,
diff --git a/src/_common/html.ts b/src/_common/html.ts
new file mode 100644
index 0000000..6d5443a
--- /dev/null
+++ b/src/_common/html.ts
@@ -0,0 +1,2 @@
+export const html = (strings: TemplateStringsArray, ...values: any[]): string =>
+ String.raw({ raw: strings }, ...values)
diff --git a/src/_common/manageFocus.ts b/src/_common/manageFocus.ts
new file mode 100644
index 0000000..caead44
--- /dev/null
+++ b/src/_common/manageFocus.ts
@@ -0,0 +1,69 @@
+import type { EffectDescriptor } from "@zeix/le-truc";
+
+/* === Constants === */
+
+const ENTER_KEY = "Enter";
+const DECREMENT_KEYS = ["ArrowLeft", "ArrowUp"];
+const INCREMENT_KEYS = ["ArrowRight", "ArrowDown"];
+const FIRST_KEY = "Home";
+const LAST_KEY = "End";
+const HANDLED_KEYS = [
+ ...DECREMENT_KEYS,
+ ...INCREMENT_KEYS,
+ FIRST_KEY,
+ LAST_KEY,
+];
+
+/* === Exported Functions === */
+
+export const manageFocus = (
+ container: Element,
+ getElements: () => E[],
+ getSelectedIndex: (radios: E[]) => number,
+): EffectDescriptor[] => {
+ let index = getSelectedIndex(getElements());
+
+ const onClick = (e: Event) => {
+ const target = e.target as HTMLElement;
+ if (target?.hasAttribute("value"))
+ index = getElements().indexOf(target as E);
+ };
+
+ const onKeydown = (e: Event) => {
+ const { key } = e as KeyboardEvent;
+ if (!HANDLED_KEYS.includes(key)) return;
+
+ const elements = getElements();
+ e.preventDefault();
+ e.stopPropagation();
+ if (key === FIRST_KEY) index = 0;
+ else if (key === LAST_KEY) index = elements.length - 1;
+ else
+ index =
+ (index + (INCREMENT_KEYS.includes(key) ? 1 : -1) + elements.length) %
+ elements.length;
+ const focused = elements[index];
+ if (focused) focused.focus();
+ };
+
+ const onKeyup = (e: Event) => {
+ const { key } = e as KeyboardEvent;
+ if (key !== ENTER_KEY) return;
+
+ const element = getElements()[index];
+ if (element) element.click();
+ };
+
+ return [
+ () => {
+ container.addEventListener("click", onClick);
+ container.addEventListener("keydown", onKeydown);
+ container.addEventListener("keyup", onKeyup);
+ return () => {
+ container.removeEventListener("click", onClick);
+ container.removeEventListener("keydown", onKeydown);
+ container.removeEventListener("keyup", onKeyup);
+ };
+ },
+ ];
+};
diff --git a/src/basic/button/basic-button.css b/src/basic/button/basic-button.css
index 7a88f78..937257f 100644
--- a/src/basic/button/basic-button.css
+++ b/src/basic/button/basic-button.css
@@ -1,176 +1,176 @@
basic-button {
- position: relative;
- display: inline-block;
- flex: 0;
-
- & button {
- height: var(--input-height);
- min-width: var(--input-height);
- border-radius: var(--space-xs);
- background-color: var(--color-secondary);
- border: 1px solid var(--color-border);
- color: var(--color-text);
- padding: 0 var(--space-s);
- font-size: var(--font-size-s);
- line-height: var(--line-height-s);
- white-space: nowrap;
- opacity: var(--opacity-dimmed);
- transition: all var(--transition-shorter) var(--easing-inout);
-
- &:disabled {
- opacity: var(--opacity-translucent);
- }
-
- &:not(:disabled) {
- cursor: pointer;
- opacity: var(--opacity-solid);
-
- &:hover {
- background-color: var(--color-secondary-hover);
- }
-
- &:active {
- background-color: var(--color-secondary-active);
- }
- }
-
- &.primary {
- color: var(--color-primary-text);
- background-color: var(--color-primary);
- border-color: var(--color-primary-active);
-
- &:not(:disabled) {
- opacity: var(--opacity-solid);
-
- &:hover {
- background-color: var(--color-primary-hover);
- }
-
- &:active {
- background-color: var(--color-primary-active);
- }
- }
- }
-
- &.destructive {
- color: var(--color-error-text);
- background-color: var(--color-error);
- border-color: var(--color-error-active);
-
- &:not(:disabled) {
- opacity: var(--opacity-solid);
-
- &:hover {
- background-color: var(--color-error-hover);
- }
-
- &:active {
- background-color: var(--color-error-active);
- }
- }
- }
-
- &.constructive {
- color: var(--color-success-text);
- background-color: var(--color-success);
- border-color: var(--color-success-active);
-
- &:not(:disabled) {
- opacity: var(--opacity-solid);
-
- &:hover {
- background-color: var(--color-success-hover);
- }
-
- &:active {
- background-color: var(--color-success-active);
- }
- }
- }
-
- &.tertiary {
- background: transparent;
- border: 0 solid transparent;
- padding: var(--space-xs);
- height: auto;
- color: var(--color-primary);
-
- &:not(:disabled) {
- opacity: var(--opacity-solid);
-
- &:hover {
- background-color: var(--color-overlay-hover);
- color: var(--color-primary-hover);
- }
-
- &:active {
- background-color: var(--color-overlay-active);
- color: var(--color-primary-active);
- }
- }
-
- &.constructive {
- color: var(--color-success);
-
- &:not(:disabled) {
- opacity: var(--opacity-solid);
-
- &:hover {
- color: var(--color-success-hover);
- }
-
- &:active {
- color: var(--color-success-active);
- }
- }
- }
-
- &.destructive {
- color: var(--color-error);
-
- &:not(:disabled) {
- opacity: var(--opacity-solid);
-
- &:hover {
- color: var(--color-error-hover);
- }
-
- &:active {
- color: var(--color-error-active);
- }
- }
- }
- }
-
- &.small {
- --input-height: var(--space-l);
- font-size: var(--font-size-xs);
- padding-inline: var(--space-xs);
- }
-
- &.large {
- --input-height: var(--space-xl);
- font-size: var(--font-size-m);
- padding-inline: var(--space-m);
- }
- }
-
- .badge {
- position: absolute;
- box-sizing: border-box;
- top: calc(-1 * var(--space-s));
- right: calc(-1 * var(--space-s));
- font-size: var(--font-size-xs);
- line-height: var(--line-height-xs);
- background-color: var(--color-primary);
- color: var(--color-text-inverted);
- padding: var(--space-xxs) var(--space-xs);
- height: calc(2 * var(--space-s));
- min-width: calc(2 * var(--space-s));
- border-radius: var(--space-s);
-
- &:empty {
- display: none;
- }
- }
+ position: relative;
+ display: inline-block;
+ flex: 0;
+
+ & button {
+ height: var(--input-height);
+ min-inline-size: var(--input-height);
+ border-radius: var(--space-xs);
+ background-color: var(--color-secondary);
+ border: 1px solid var(--color-border);
+ color: var(--color-text);
+ padding: 0 var(--space-s);
+ font-size: var(--font-size-s);
+ line-height: var(--line-height-s);
+ white-space: nowrap;
+ opacity: var(--opacity-dimmed);
+ transition: all var(--transition-shorter) var(--easing-inout);
+
+ &:disabled {
+ opacity: var(--opacity-translucent);
+ }
+
+ &:not(:disabled) {
+ cursor: pointer;
+ opacity: var(--opacity-solid);
+
+ &:hover {
+ background-color: var(--color-secondary-hover);
+ }
+
+ &:active {
+ background-color: var(--color-secondary-active);
+ }
+ }
+
+ &.primary {
+ color: var(--color-primary-text);
+ background-color: var(--color-primary);
+ border-color: var(--color-primary-active);
+
+ &:not(:disabled) {
+ opacity: var(--opacity-solid);
+
+ &:hover {
+ background-color: var(--color-primary-hover);
+ }
+
+ &:active {
+ background-color: var(--color-primary-active);
+ }
+ }
+ }
+
+ &.destructive {
+ color: var(--color-error-text);
+ background-color: var(--color-error);
+ border-color: var(--color-error-active);
+
+ &:not(:disabled) {
+ opacity: var(--opacity-solid);
+
+ &:hover {
+ background-color: var(--color-error-hover);
+ }
+
+ &:active {
+ background-color: var(--color-error-active);
+ }
+ }
+ }
+
+ &.constructive {
+ color: var(--color-success-text);
+ background-color: var(--color-success);
+ border-color: var(--color-success-active);
+
+ &:not(:disabled) {
+ opacity: var(--opacity-solid);
+
+ &:hover {
+ background-color: var(--color-success-hover);
+ }
+
+ &:active {
+ background-color: var(--color-success-active);
+ }
+ }
+ }
+
+ &.tertiary {
+ background: transparent;
+ border: none;
+ padding: var(--space-xs);
+ height: auto;
+ color: var(--color-primary);
+
+ &:not(:disabled) {
+ opacity: var(--opacity-solid);
+
+ &:hover {
+ background-color: var(--color-overlay-hover);
+ color: var(--color-primary-hover);
+ }
+
+ &:active {
+ background-color: var(--color-overlay-active);
+ color: var(--color-primary-active);
+ }
+ }
+
+ &.constructive {
+ color: var(--color-success);
+
+ &:not(:disabled) {
+ opacity: var(--opacity-solid);
+
+ &:hover {
+ color: var(--color-success-hover);
+ }
+
+ &:active {
+ color: var(--color-success-active);
+ }
+ }
+ }
+
+ &.destructive {
+ color: var(--color-error);
+
+ &:not(:disabled) {
+ opacity: var(--opacity-solid);
+
+ &:hover {
+ color: var(--color-error-hover);
+ }
+
+ &:active {
+ color: var(--color-error-active);
+ }
+ }
+ }
+ }
+
+ &.small {
+ --input-height: var(--space-l);
+ font-size: var(--font-size-xs);
+ padding-inline: var(--space-xs);
+ }
+
+ &.large {
+ --input-height: var(--space-xl);
+ font-size: var(--font-size-m);
+ padding-inline: var(--space-m);
+ }
+ }
+
+ .badge {
+ position: absolute;
+ box-sizing: border-box;
+ top: calc(-1 * var(--space-s));
+ right: calc(-1 * var(--space-s));
+ font-size: var(--font-size-xs);
+ line-height: var(--line-height-xs);
+ background-color: var(--color-primary);
+ color: var(--color-text-inverted);
+ padding: var(--space-xxs) var(--space-xs);
+ height: calc(2 * var(--space-s));
+ min-width: calc(2 * var(--space-s));
+ border-radius: var(--space-s);
+
+ &:empty {
+ display: none;
+ }
+ }
}
diff --git a/src/basic/button/basic-button.stories.ts b/src/basic/button/basic-button.stories.ts
index 924ed9e..8fd87e4 100644
--- a/src/basic/button/basic-button.stories.ts
+++ b/src/basic/button/basic-button.stories.ts
@@ -3,7 +3,6 @@ import { html, nothing } from "lit";
import { expect } from "storybook/test";
import "./basic-button.ts";
import "./basic-button.css";
-import type { Component } from "@zeix/le-truc";
import type { BasicButtonProps } from "./basic-button.ts";
type BasicButtonArgs = {
@@ -80,72 +79,6 @@ export const Default: Story = {
},
};
-// ⚠️ Custom render: tests attribute-driven updates on a button without initial label/badge in DOM
-export const DynamicUpdates: Story = {
- render: () => html`
-
-
- 🛒 Shopping Cart
- 5
-
-
- `,
- play: async ({ canvasElement }) => {
- await customElements.whenDefined("basic-button");
- const el = canvasElement.querySelector(
- "basic-button",
- ) as Component;
- const button = el.querySelector("button");
- const label = el.querySelector(".label");
- const badge = el.querySelector(".badge");
-
- await expect(button).not.toBeDisabled();
- await expect(label).toHaveTextContent("🛒 Shopping Cart");
- await expect(badge).toHaveTextContent("5");
-
- el.setAttribute("disabled", "true");
- await expect(button).toBeDisabled();
-
- el.removeAttribute("disabled");
- await expect(button).not.toBeDisabled();
-
- el.setAttribute("label", "Wishlist");
- await expect(label).toHaveTextContent("Wishlist");
-
- el.setAttribute("badge", "10");
- await expect(badge).toHaveTextContent("10");
-
- el.setAttribute("disabled", "true");
- el.setAttribute("label", "Back to Store");
- el.setAttribute("badge", "0");
- await expect(button).toBeDisabled();
- await expect(label).toHaveTextContent("Back to Store");
- await expect(badge).toHaveTextContent("0");
- },
-};
-
-// ⚠️ Custom render: tests that host attributes override mismatched initial DOM content
-export const InitialAttributes: Story = {
- render: () => html`
-
-
- Default Label
- 0
-
-
- `,
- play: async ({ canvasElement }) => {
- await customElements.whenDefined("basic-button");
- const el = canvasElement.querySelector(
- "basic-button",
- ) as Component;
-
- await expect(el.querySelector("button")).toBeDisabled();
- await expect(el.querySelector(".label")).toHaveTextContent("Delete Item");
- await expect(el.querySelector(".badge")).toHaveTextContent("99");
- },
-};
-
// ⚠️ Custom render: tests property assignment on a button with a class not derived from variant/size args
export const PropertyChanges: Story = {
render: () => html`
@@ -159,9 +92,8 @@ export const PropertyChanges: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-button");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const el = canvasElement.querySelector(
- "basic-button",
- ) as Component;
+ const el = canvasElement.querySelector("basic-button") as HTMLElement &
+ BasicButtonProps;
const button = el.querySelector("button");
const label = el.querySelector(".label");
const badge = el.querySelector(".badge");
@@ -187,15 +119,14 @@ export const MissingOptionalElements: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-button");
- const el = canvasElement.querySelector(
- "basic-button",
- ) as Component;
+ const el = canvasElement.querySelector("basic-button") as HTMLElement &
+ BasicButtonProps;
const button = el.querySelector("button");
await expect(button).not.toBeDisabled();
await expect(button).toHaveTextContent("Just Button Text");
- el.setAttribute("disabled", "true");
+ el.disabled = true;
await expect(button).toBeDisabled();
},
};
@@ -209,54 +140,14 @@ export const TextFallback: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-button");
- const el = canvasElement.querySelector(
- "basic-button",
- ) as Component;
+ const el = canvasElement.querySelector("basic-button") as HTMLElement &
+ BasicButtonProps;
const button = el.querySelector("button");
await expect(el.label).toBe("Button Text Only");
- el.setAttribute("label", "New Label");
+ el.label = "New Label";
// No .label span, so the button's own text content is unchanged
await expect(button).toHaveTextContent("Button Text Only");
},
};
-
-// ⚠️ Custom render: tests asBoolean attribute parsing edge cases (empty string, "false", "0", "disabled")
-export const BooleanAttributes: Story = {
- render: () => html`
-
-
- Boolean Test
- Test
-
-
- `,
- play: async ({ canvasElement }) => {
- await customElements.whenDefined("basic-button");
- const el = canvasElement.querySelector(
- "basic-button",
- ) as Component;
- const button = el.querySelector("button");
-
- el.setAttribute("disabled", "");
- await expect(button).toBeDisabled();
-
- // asBoolean special case: "false" is the only string that returns false.
- // toBeDisabled() can't be used here: @testing-library/jest-dom walks ancestor
- // custom elements and treats any presence of the "disabled" attribute (regardless
- // of value) as disabling. Check the native button's own property instead.
- el.setAttribute("disabled", "false");
- await expect(button).not.toHaveAttribute("disabled");
-
- el.setAttribute("disabled", "disabled");
- await expect(button).toBeDisabled();
-
- // "0" is truthy in asBoolean, so disabled stays enabled
- el.setAttribute("disabled", "0");
- await expect(button).toBeDisabled();
-
- el.removeAttribute("disabled");
- await expect(button).not.toBeDisabled();
- },
-};
diff --git a/src/basic/button/basic-button.ts b/src/basic/button/basic-button.ts
index 22dcdf0..d9b5b94 100644
--- a/src/basic/button/basic-button.ts
+++ b/src/basic/button/basic-button.ts
@@ -1,11 +1,4 @@
-import {
- asBoolean,
- asString,
- type Component,
- defineComponent,
- setProperty,
- setText,
-} from "@zeix/le-truc";
+import { bindProperty, bindText, defineComponent } from "@zeix/le-truc";
export type BasicButtonProps = {
disabled: boolean;
@@ -13,33 +6,29 @@ export type BasicButtonProps = {
badge: string;
};
-type BasicButtonUI = {
- button: HTMLButtonElement;
- label?: HTMLSpanElement | undefined;
- badge?: HTMLSpanElement | undefined;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "basic-button": Component;
+ "basic-button": HTMLElement & BasicButtonProps;
}
}
-export default defineComponent(
+export default defineComponent(
"basic-button",
- {
- disabled: asBoolean(),
- label: asString((ui) => ui.label?.textContent ?? ui.button.textContent),
- badge: asString((ui) => ui.badge?.textContent ?? ""),
+ ({ expose, first, watch }) => {
+ const button = first("button", "Add a native button as descendant.");
+ const label = first("span.label");
+ const badge = first("span.badge");
+
+ expose({
+ disabled: button.disabled,
+ label: label?.textContent ?? button.textContent ?? "",
+ badge: badge?.textContent ?? "",
+ });
+
+ return [
+ watch("disabled", bindProperty(button, "disabled")),
+ label && watch("label", bindText(label, true)),
+ badge && watch("badge", bindText(badge, true)),
+ ];
},
- ({ first }) => ({
- button: first("button", "Add a native button as descendant."),
- label: first("span.label"),
- badge: first("span.badge"),
- }),
- () => ({
- button: setProperty("disabled"),
- label: setText("label"),
- badge: setText("badge"),
- }),
);
diff --git a/src/basic/button/copyToClipboard.ts b/src/basic/button/copyToClipboard.ts
index 66f096e..822dff7 100644
--- a/src/basic/button/copyToClipboard.ts
+++ b/src/basic/button/copyToClipboard.ts
@@ -1,9 +1,4 @@
-import {
- type Component,
- type ComponentProps,
- type Effect,
- on,
-} from "@zeix/le-truc";
+import type { EffectDescriptor } from "@zeix/le-truc";
import type { BasicButtonProps } from "./basic-button";
@@ -15,10 +10,11 @@ const COPY_ERROR = "error";
export const copyToClipboard =
(
container: HTMLElement,
+ button: HTMLElement & BasicButtonProps,
messages: { [COPY_ERROR]?: string; [COPY_SUCCESS]?: string },
- ): Effect> =>
- (_, button) =>
- on("click", async () => {
+ ): EffectDescriptor =>
+ () => {
+ const onClick = async () => {
const label = button.label;
let status: CopyStatus = COPY_SUCCESS;
try {
@@ -45,4 +41,7 @@ export const copyToClipboard =
},
status === COPY_SUCCESS ? 1000 : 3000,
);
- })(_, button);
+ };
+ button.addEventListener("click", onClick);
+ return () => button.removeEventListener("click", onClick);
+ };
diff --git a/src/basic/counter/basic-counter.stories.ts b/src/basic/counter/basic-counter.stories.ts
index 63e3f64..9fb78a2 100644
--- a/src/basic/counter/basic-counter.stories.ts
+++ b/src/basic/counter/basic-counter.stories.ts
@@ -3,7 +3,6 @@ import { html } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./basic-counter.ts";
import "./basic-counter.css";
-import type { Component } from "@zeix/le-truc";
import type { BasicCounterProps } from "./basic-counter.ts";
type BasicCounterArgs = {
@@ -43,9 +42,8 @@ export const DynamicUpdates: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-counter");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "basic-counter",
- ) as Component;
+ const el = canvasElement.querySelector("basic-counter") as HTMLElement &
+ BasicCounterProps;
const button = canvas.getByRole("button");
const span = el.querySelector("span");
@@ -65,9 +63,8 @@ export const InitialDOMValue: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-counter");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "basic-counter",
- ) as Component;
+ const el = canvasElement.querySelector("basic-counter") as HTMLElement &
+ BasicCounterProps;
const span = el.querySelector("span");
await expect(span).toHaveTextContent("100");
@@ -83,9 +80,8 @@ export const NegativeInitialValue: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-counter");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "basic-counter",
- ) as Component;
+ const el = canvasElement.querySelector("basic-counter") as HTMLElement &
+ BasicCounterProps;
const span = el.querySelector("span");
await expect(span).toHaveTextContent("-5");
@@ -102,9 +98,8 @@ export const PropertyChanges: Story = {
args: { count: 0 },
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-counter");
- const el = canvasElement.querySelector(
- "basic-counter",
- ) as Component;
+ const el = canvasElement.querySelector("basic-counter") as HTMLElement &
+ BasicCounterProps;
const span = el.querySelector("span");
el.count = 10;
diff --git a/src/basic/counter/basic-counter.ts b/src/basic/counter/basic-counter.ts
index 30a2be1..4e8e714 100644
--- a/src/basic/counter/basic-counter.ts
+++ b/src/basic/counter/basic-counter.ts
@@ -1,43 +1,30 @@
-import {
- asInteger,
- type Component,
- defineComponent,
- on,
- read,
- setText,
-} from "@zeix/le-truc";
+import { bindText, defineComponent } from "@zeix/le-truc";
export type BasicCounterProps = {
count: number;
};
-type BasicCounterUI = {
- increment: HTMLButtonElement;
- count: HTMLSpanElement;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "basic-counter": Component;
+ "basic-counter": HTMLElement & BasicCounterProps;
}
}
-export default defineComponent(
+export default defineComponent(
"basic-counter",
- {
- count: read((ui) => ui.count.textContent, asInteger()),
- },
- ({ first }) => ({
- increment: first(
+ ({ expose, first, host, on, watch }) => {
+ const increment = first(
"button",
"Add a native button element to increment the count.",
- ),
- count: first("span", "Add a span to display the count."),
- }),
- ({ host }) => ({
- increment: on("click", () => {
- host.count++;
- }),
- count: setText("count"),
- }),
+ );
+ const count = first("span", "Add a span to display the count.");
+
+ expose({ count: Number.parseInt(count.textContent || "0", 10) });
+
+ return [
+ on(increment, "click", () => ({ count: host.count + 1 })),
+
+ watch("count", bindText(count, true)),
+ ];
+ },
);
diff --git a/src/basic/gauge/basic-gauge.css b/src/basic/gauge/basic-gauge.css
new file mode 100644
index 0000000..1455eb3
--- /dev/null
+++ b/src/basic/gauge/basic-gauge.css
@@ -0,0 +1,49 @@
+basic-gauge {
+ --basic-gauge-color: var(--color-primary);
+ --basic-gauge-thickness: var(--space-xs);
+ --basic-gauge-degree: 120deg;
+ --basic-gauge-size: 8rem;
+
+ width: var(--basic-gauge-size);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ aspect-ratio: 1;
+ background-image:
+ radial-gradient(
+ closest-side,
+ var(--color-background) 0%,
+ var(--color-background) calc(100% - var(--basic-gauge-thickness)),
+ transparent calc(100% - var(--basic-gauge-thickness))
+ ),
+ conic-gradient(
+ from -120deg,
+ var(--basic-gauge-color) 0deg var(--basic-gauge-degree),
+ transparent var(--basic-gauge-degree) 240deg
+ ),
+ conic-gradient(
+ from -120deg,
+ var(--color-secondary) 0deg 240deg,
+ transparent 240deg
+ );
+ border-radius: 50%;
+
+ & p {
+ display: inline-block;
+ color: var(--color-text-soft);
+ font-size: var(--font-size-s);
+ margin: 0;
+ }
+
+ & basic-number {
+ display: inline-block;
+ font-size: var(--font-size-xl);
+ }
+
+ .label {
+ display: inline-block;
+ font-size: var(--font-size-s);
+ color: var(--color-text-soft);
+ }
+}
diff --git a/src/basic/gauge/basic-gauge.mdx b/src/basic/gauge/basic-gauge.mdx
new file mode 100644
index 0000000..1825465
--- /dev/null
+++ b/src/basic/gauge/basic-gauge.mdx
@@ -0,0 +1,102 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as BasicGaugeStories from './basic-gauge.stories';
+
+
+
+### Basic Gauge
+
+A visual level indicator that reads its initial value from a `` element, then drives a conic-gradient dial by setting `--basic-gauge-degree` and `--basic-gauge-color` on the host. Demonstrates `asJSON()` to parse a thresholds array from an attribute at connect time, `watch()` with custom handlers to keep the meter in sync and derive the active threshold label and color, and `pass()` to forward `value` to a child `` component.
+
+#### Tag Name
+
+`basic-gauge`
+
+#### Preview
+
+
+
+#### Controls
+
+
+
+#### Reactive Properties
+
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+ value
+ number (float)
+ 0
+ Current gauge value; initialized from the descendant <meter> element's value property at connect time; can be updated programmatically via host.value
+
+
+
+
+#### Attributes
+
+
+
+
+ Name
+ Description
+
+
+
+
+ thresholds
+ JSON array of threshold objects parsed once at connect time; not reactive after connect
+
+
+
+
+`BasicGaugeThreshold[]` is an array sorted from highest to lowest `min`. Each entry has `min` (number), `label` (string), and `color` (CSS color string). The first entry whose `min` is ≤ `host.value` determines the active label and color.
+
+Example:
+```json
+[
+ {"min": 0.8, "label": "Good job!", "color": "var(--color-success)"},
+ {"min": 0.5, "label": "Decent", "color": "var(--color-warning)"},
+ {"min": 0, "label": "Try again!", "color": "var(--color-error)"}
+]
+```
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('meter')
+ HTMLMeterElement
+ required
+ Native meter element; its value property seeds host.value at connect time and is kept in sync by a watch() handler
+
+
+ first('basic-number')
+ HTMLElement & BasicNumberProps
+ required
+ <basic-number> child component that displays the formatted value; receives host.value via pass() — configure display via the options attribute on <basic-number>
+
+
+ first('.label')
+ HTMLElement
+ required
+ Displays the active threshold label text
+
+
+
diff --git a/src/basic/gauge/basic-gauge.stories.ts b/src/basic/gauge/basic-gauge.stories.ts
new file mode 100644
index 0000000..3bb0cef
--- /dev/null
+++ b/src/basic/gauge/basic-gauge.stories.ts
@@ -0,0 +1,136 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html, nothing } from "lit";
+import { expect } from "storybook/test";
+import "./basic-gauge.ts";
+import "./basic-gauge.css";
+import "../number/basic-number.ts";
+import type { BasicGaugeProps } from "./basic-gauge.ts";
+
+const DEFAULT_THRESHOLDS =
+ '[{"min":0.8,"label":"Good job!","color":"var(--color-success)"},{"min":0.5,"label":"Decent","color":"var(--color-warning)"},{"min":0,"label":"Try again!","color":"var(--color-error)"}]';
+
+const PROGRESS_THRESHOLDS =
+ '[{"min":100,"label":"Full","color":"var(--color-success)"},{"min":75,"label":"Almost there","color":"var(--color-info)"},{"min":50,"label":"Halfway","color":"var(--color-warning)"},{"min":0,"label":"Empty","color":"var(--color-error)"}]';
+
+type BasicGaugeArgs = {
+ value: number;
+ thresholds: string;
+};
+
+const render = ({ value, thresholds }: BasicGaugeArgs) => html`
+
+ Speed:
+
+
+
+
+`;
+
+const meta: Meta = {
+ title: "Basic/Gauge",
+ render,
+ argTypes: {
+ value: {
+ control: { type: "range", min: 0, max: 1, step: 0.01 },
+ description:
+ "Initial value for the gauge (set via the meter element's value attribute)",
+ table: {
+ defaultValue: { summary: "0" },
+ category: "Reactive Properties",
+ },
+ },
+ thresholds: {
+ control: "text",
+ description:
+ "JSON array of threshold objects with min, label, and color properties",
+ table: { category: "Attributes" },
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ value: 0.84,
+ thresholds: DEFAULT_THRESHOLDS,
+ },
+};
+
+export const Decent: Story = {
+ args: {
+ value: 0.65,
+ thresholds: DEFAULT_THRESHOLDS,
+ },
+};
+
+export const TryAgain: Story = {
+ args: {
+ value: 0.20566788,
+ thresholds: DEFAULT_THRESHOLDS,
+ },
+};
+
+export const PropertyChanges: Story = {
+ args: {
+ value: 0.5,
+ thresholds: DEFAULT_THRESHOLDS,
+ },
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("basic-gauge");
+ const el = canvasElement.querySelector("basic-gauge") as HTMLElement &
+ BasicGaugeProps;
+ const label = el.querySelector(".label");
+
+ // Initial state based on meter value
+ await expect(label).toHaveTextContent("Decent");
+
+ // Update via host property (component watches host.value)
+ el.value = 0.9;
+ await expect(label).toHaveTextContent("Good job!");
+
+ el.value = 0.3;
+ await expect(label).toHaveTextContent("Try again!");
+ },
+};
+
+export const CustomThresholds: Story = {
+ args: {
+ value: 75,
+ thresholds: PROGRESS_THRESHOLDS,
+ },
+ render: ({ value, thresholds }) => html`
+
+ Progress:
+
+
+
+
+ `,
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("basic-gauge");
+ const el = canvasElement.querySelector("basic-gauge") as HTMLElement &
+ BasicGaugeProps;
+ const label = el.querySelector(".label");
+
+ await expect(label).toHaveTextContent("Almost there");
+ },
+};
diff --git a/src/basic/gauge/basic-gauge.ts b/src/basic/gauge/basic-gauge.ts
new file mode 100644
index 0000000..3273d77
--- /dev/null
+++ b/src/basic/gauge/basic-gauge.ts
@@ -0,0 +1,61 @@
+import { asJSON, defineComponent } from "@zeix/le-truc";
+
+export type BasicGaugeProps = {
+ value: number;
+};
+
+export type BasicGaugeThreshold = {
+ min: number;
+ label: string;
+ color: string;
+};
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "basic-gauge": HTMLElement & BasicGaugeProps;
+ }
+}
+
+export default defineComponent(
+ "basic-gauge",
+ ({ expose, first, host, pass, watch }) => {
+ const meter = first("meter", "Add a element to display the level");
+ const valueEl = first(
+ "basic-number",
+ "Add a element to display the value",
+ );
+ const labelEl = first(
+ ".label",
+ "Add an element to display the qualification label",
+ );
+
+ expose({ value: meter.value });
+
+ const thresholds = asJSON([])(
+ host.getAttribute("thresholds"),
+ );
+
+ return [
+ pass(valueEl, { value: () => host.value }),
+
+ watch("value", (value) => {
+ meter.value = value;
+ host.style.setProperty(
+ "--basic-gauge-degree",
+ `${(240 * value) / meter.max}deg`,
+ );
+ }),
+ watch(
+ () =>
+ thresholds.find((threshold) => host.value >= threshold.min) || {
+ label: "",
+ color: "var(--color-primary)",
+ },
+ (qualification) => {
+ labelEl.textContent = qualification.label;
+ host.style.setProperty("--basic-gauge-color", qualification.color);
+ },
+ ),
+ ];
+ },
+);
diff --git a/src/basic/hello/basic-hello.stories.ts b/src/basic/hello/basic-hello.stories.ts
index e41d1b4..bb8e1b9 100644
--- a/src/basic/hello/basic-hello.stories.ts
+++ b/src/basic/hello/basic-hello.stories.ts
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./basic-hello.ts";
-import type { Component } from "@zeix/le-truc";
import type { BasicHelloProps } from "./basic-hello.ts";
type BasicHelloArgs = {
@@ -44,9 +43,8 @@ export const DynamicUpdates: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-hello");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "basic-hello",
- ) as Component;
+ const el = canvasElement.querySelector("basic-hello") as HTMLElement &
+ BasicHelloProps;
const input = canvas.getByRole("textbox");
const output = el.querySelector("output");
@@ -67,9 +65,8 @@ export const FallbackOnClear: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-hello");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "basic-hello",
- ) as Component;
+ const el = canvasElement.querySelector("basic-hello") as HTMLElement &
+ BasicHelloProps;
const input = canvas.getByRole("textbox");
const output = el.querySelector("output");
@@ -85,9 +82,8 @@ export const InitialDOMValue: Story = {
args: { name: "Alice" },
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-hello");
- const el = canvasElement.querySelector(
- "basic-hello",
- ) as Component;
+ const el = canvasElement.querySelector("basic-hello") as HTMLElement &
+ BasicHelloProps;
const output = el.querySelector("output");
await expect(output).toHaveTextContent("Alice");
@@ -99,9 +95,8 @@ export const PropertyChanges: Story = {
args: { name: "World" },
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-hello");
- const el = canvasElement.querySelector(
- "basic-hello",
- ) as Component;
+ const el = canvasElement.querySelector("basic-hello") as HTMLElement &
+ BasicHelloProps;
const output = el.querySelector("output");
el.name = "Charlie";
diff --git a/src/basic/hello/basic-hello.ts b/src/basic/hello/basic-hello.ts
index 99a0c3c..5157f73 100644
--- a/src/basic/hello/basic-hello.ts
+++ b/src/basic/hello/basic-hello.ts
@@ -1,40 +1,28 @@
-import {
- asString,
- type Component,
- defineComponent,
- on,
- setText,
-} from "@zeix/le-truc";
+import { bindText, defineComponent } from "@zeix/le-truc";
export type BasicHelloProps = {
name: string;
};
-type BasicHelloUI = {
- input: HTMLInputElement;
- output: HTMLOutputElement;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "basic-hello": Component;
+ "basic-hello": HTMLElement & BasicHelloProps;
}
}
-export default defineComponent(
+export default defineComponent(
"basic-hello",
- {
- name: asString((ui) => ui.output.textContent),
- },
- ({ first }) => ({
- input: first("input", "Needed to enter the name."),
- output: first("output", "Needed to display the name."),
- }),
- ({ host, input }) => {
- const fallback = host.name;
- return {
- input: on("input", () => ({ name: input.value || fallback })),
- output: setText("name"),
- };
+ ({ expose, first, on, watch }) => {
+ const input = first("input", "Needed to enter the name.");
+ const output = first("output", "Needed to display the name.");
+ const fallback = output.textContent || "";
+
+ expose({ name: output.textContent ?? "" });
+
+ return [
+ on(input, "input", () => ({ name: input.value || fallback })),
+
+ watch("name", bindText(output, true)),
+ ];
},
);
diff --git a/src/basic/number/basic-number.stories.ts b/src/basic/number/basic-number.stories.ts
index e8291e2..17a467d 100644
--- a/src/basic/number/basic-number.stories.ts
+++ b/src/basic/number/basic-number.stories.ts
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from "@storybook/web-components";
import { html, nothing } from "lit";
import { expect } from "storybook/test";
import "./basic-number.ts";
-import type { Component } from "@zeix/le-truc";
import type { BasicNumberProps } from "./basic-number.ts";
type BasicNumberArgs = {
@@ -56,36 +55,44 @@ export const Default: Story = {
// ⚠️ Custom render: shows two instances side-by-side with locale labels, each with a different lang
export const Currency: Story = {
render: () => html`
- German (Switzerland):
-
- French (Switzerland):
-
+
+ German (Switzerland):
+
+
+
+ French (Switzerland):
+
+
`,
};
// ⚠️ Custom render: shows two instances side-by-side with locale labels, each with a different lang
export const Unit: Story = {
render: () => html`
- Arabic speed (km/h):
-
- Chinese time (seconds):
-
+
+ Arabic speed (km/h):
+
+
+
+ Chinese time (seconds):
+
+
`,
};
@@ -93,18 +100,19 @@ export const Unit: Story = {
export const LocaleInheritance: Story = {
render: () => html`
-
Euro currency, inherited German (Germany) locale:
-
+
+ Euro currency, inherited German (Germany) locale:
+
+
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-number");
- const el = canvasElement.querySelector(
- "basic-number",
- ) as Component;
+ const el = canvasElement.querySelector("basic-number") as HTMLElement &
+ BasicNumberProps;
await expect(el).toHaveTextContent("1.234,50\u00a0€");
},
};
@@ -112,14 +120,14 @@ export const LocaleInheritance: Story = {
export const DecimalFormatting: Story = {
args: {
value: 1234.56789,
- options: '{"style":"decimal","minimumFractionDigits":2,"maximumFractionDigits":3}',
+ options:
+ '{"style":"decimal","minimumFractionDigits":2,"maximumFractionDigits":3}',
lang: "",
},
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-number");
- const el = canvasElement.querySelector(
- "basic-number",
- ) as Component;
+ const el = canvasElement.querySelector("basic-number") as HTMLElement &
+ BasicNumberProps;
await expect(el).toHaveTextContent("1,234.568");
},
};
@@ -128,9 +136,8 @@ export const PropertyChanges: Story = {
args: { value: 0, options: "", lang: "" },
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-number");
- const el = canvasElement.querySelector(
- "basic-number",
- ) as Component;
+ const el = canvasElement.querySelector("basic-number") as HTMLElement &
+ BasicNumberProps;
await expect(el).toHaveTextContent("0");
diff --git a/src/basic/number/basic-number.ts b/src/basic/number/basic-number.ts
index 9b57758..e772d64 100644
--- a/src/basic/number/basic-number.ts
+++ b/src/basic/number/basic-number.ts
@@ -1,9 +1,5 @@
-import {
- asNumber,
- type Component,
- defineComponent,
- setText,
-} from "@zeix/le-truc";
+import { asNumber, bindText, defineComponent } from "@zeix/le-truc";
+import { getLocale } from "../../_common/getLocale";
export type BasicNumberProps = {
value: number;
@@ -11,7 +7,7 @@ export type BasicNumberProps = {
declare global {
interface HTMLElementTagNameMap {
- "basic-number": Component;
+ "basic-number": HTMLElement & BasicNumberProps;
}
}
@@ -20,8 +16,6 @@ type Logger = {
onError: (message: string) => void;
};
-const FALLBACK_LOCALE = "en";
-
function getNumberFormatter(
locale: string,
rawOptions: string | null,
@@ -115,15 +109,14 @@ function getNumberFormatter(
export default defineComponent(
"basic-number",
- { value: asNumber() },
- undefined,
- ({ host }) => {
+ ({ expose, host, watch }) => {
const formatter = getNumberFormatter(
- host.closest("[lang]")?.getAttribute("lang") || FALLBACK_LOCALE,
+ getLocale(host),
host.getAttribute("options"),
);
- return {
- host: setText(() => formatter.format(host.value)),
- };
+
+ expose({ value: asNumber() });
+
+ return [watch(() => formatter.format(host.value), bindText(host, true))];
},
);
diff --git a/src/basic/pluralize/basic-pluralize.stories.ts b/src/basic/pluralize/basic-pluralize.stories.ts
index 947a62a..3aed365 100644
--- a/src/basic/pluralize/basic-pluralize.stories.ts
+++ b/src/basic/pluralize/basic-pluralize.stories.ts
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from "@storybook/web-components";
import { html, nothing } from "lit";
import { expect } from "storybook/test";
import "./basic-pluralize.ts";
-import type { Component } from "@zeix/le-truc";
import type { BasicPluralizeProps } from "./basic-pluralize.ts";
type BasicPluralizeArgs = {
@@ -75,9 +74,8 @@ export const PeopleCount: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-pluralize");
- const el = canvasElement.querySelector(
- "basic-pluralize",
- ) as Component;
+ const el = canvasElement.querySelector("basic-pluralize") as HTMLElement &
+ BasicPluralizeProps;
await expect(el.querySelector(".none")).not.toBeVisible();
await expect(el.querySelector(".some")).toBeVisible();
@@ -103,15 +101,16 @@ export const Ordinal: Story = {
None
- st nd rd th
+ st nd rd th
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-pluralize");
- const el = canvasElement.querySelector(
- "basic-pluralize",
- ) as Component;
+ const el = canvasElement.querySelector("basic-pluralize") as HTMLElement &
+ BasicPluralizeProps;
await expect(el.querySelector(".count")).toHaveTextContent("1");
await expect(el.querySelector(".one")).toBeVisible(); // 1st
@@ -150,9 +149,8 @@ export const Welsh: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-pluralize");
- const el = canvasElement.querySelector(
- "basic-pluralize",
- ) as Component;
+ const el = canvasElement.querySelector("basic-pluralize") as HTMLElement &
+ BasicPluralizeProps;
// count=0 → .none shown, .some hidden
await expect(el.querySelector(".none")).toBeVisible();
@@ -182,9 +180,8 @@ export const NegativeClampedToZero: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("basic-pluralize");
- const el = canvasElement.querySelector(
- "basic-pluralize",
- ) as Component;
+ const el = canvasElement.querySelector("basic-pluralize") as HTMLElement &
+ BasicPluralizeProps;
await expect(el.count).toBe(0);
await expect(el.querySelector(".none")).toBeVisible();
diff --git a/src/basic/pluralize/basic-pluralize.ts b/src/basic/pluralize/basic-pluralize.ts
index a261f77..4a79dce 100644
--- a/src/basic/pluralize/basic-pluralize.ts
+++ b/src/basic/pluralize/basic-pluralize.ts
@@ -1,72 +1,70 @@
-import { type Component, defineComponent, setText, show } from "@zeix/le-truc";
-import { asClampedInteger } from "../../_common/asClampedInteger";
+import {
+ asClampedInteger,
+ bindText,
+ bindVisible,
+ defineComponent,
+} from "@zeix/le-truc";
+import { getLocale } from "../../_common/getLocale";
export type BasicPluralizeProps = {
count: number;
};
-type BasicPluralizeUI = Partial<
- Record<
- | "count"
- | "none"
- | "some"
- | "zero"
- | "one"
- | "two"
- | "few"
- | "many"
- | "other",
- HTMLElement | undefined
- >
->;
-
declare global {
interface HTMLElementTagNameMap {
- "basic-pluralize": Component;
+ "basic-pluralize": HTMLElement & BasicPluralizeProps;
}
}
-const FALLBACK_LOCALE = "en";
-
-export default defineComponent(
+export default defineComponent(
"basic-pluralize",
- {
- count: asClampedInteger(),
- },
- ({ first }) => ({
- count: first(".count"),
- none: first(".none"),
- some: first(".some"),
- zero: first(".zero"),
- one: first(".one"),
- two: first(".two"),
- few: first(".few"),
- many: first(".many"),
- other: first(".other"),
- }),
- ({ host }) => {
+ ({ expose, first, host, watch }) => {
+ const count = first(".count");
+ const none = first(".none");
+ const some = first(".some");
+ const zero = first(".zero");
+ const one = first(".one");
+ const two = first(".two");
+ const few = first(".few");
+ const many = first(".many");
+ const other = first(".other");
+
const pluralizer = new Intl.PluralRules(
- host.closest("[lang]")?.getAttribute("lang") || FALLBACK_LOCALE,
+ getLocale(host),
host.hasAttribute("ordinal") ? { type: "ordinal" } : undefined,
);
- // Basic effects
- const effects: {
- count: ReturnType;
- none: ReturnType;
- some: ReturnType;
- } & Partial>> = {
- count: setText(() => String(host.count)),
- none: show(() => host.count === 0),
- some: show(() => host.count > 0),
+ expose({
+ count: asClampedInteger(),
+ });
+
+ const categoryElements: Partial<
+ Record
+ > = {
+ zero,
+ one,
+ two,
+ few,
+ many,
+ other,
};
- // Subset of plural categories for applicable pluralizer: ['zero', 'one', 'two', 'few', 'many', 'other']
const categories = pluralizer.resolvedOptions().pluralCategories;
- for (const category of categories)
- effects[category] = show(
- () => pluralizer.select(host.count) === category,
- );
- return effects;
+
+ return [
+ count && watch("count", bindText(count, true)),
+ none && watch(() => host.count === 0, bindVisible(none)),
+ some && watch(() => host.count !== 0, bindVisible(some)),
+ ...categories.map((category) => {
+ const el = categoryElements[category];
+ return (
+ el &&
+ watch(
+ () => pluralizer.select(host.count) === category,
+ bindVisible(el),
+ )
+ );
+ }),
+ ];
},
);
diff --git a/src/card/blogmeta/card-blogmeta.css b/src/card/blogmeta/card-blogmeta.css
new file mode 100644
index 0000000..814a6be
--- /dev/null
+++ b/src/card/blogmeta/card-blogmeta.css
@@ -0,0 +1,27 @@
+card-blogmeta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-m);
+ font-size: var(--font-size-s);
+ color: var(--color-text-soft);
+ flex-wrap: wrap;
+ margin-bottom: var(--space-l);
+
+ & span {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+ }
+
+ & img {
+ width: var(--input-height);
+ height: var(--input-height);
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ }
+
+ & time {
+ font-variant-numeric: tabular-nums;
+ }
+}
diff --git a/src/card/blogmeta/card-blogmeta.mdx b/src/card/blogmeta/card-blogmeta.mdx
new file mode 100644
index 0000000..0448ee6
--- /dev/null
+++ b/src/card/blogmeta/card-blogmeta.mdx
@@ -0,0 +1,53 @@
+import { Meta, Canvas } from '@storybook/addon-docs/blocks';
+import * as CardBlogmetaStories from './card-blogmeta.stories';
+
+
+
+### Card Blogmeta
+
+A static date-formatting component that runs once at connect time. Reads `dateTime` from descendant `` elements and replaces their text content with locale-aware long-form dates using `Intl.DateTimeFormat`. If the modification date is invalid, the surrounding `` is removed entirely. No reactive props — all work is imperative and completes before the first paint.
+
+#### Tag Name
+
+`card-blogmeta`
+
+#### Preview
+
+
+
+#### All Variants
+
+
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('time.published')
+ HTMLTimeElement
+ required
+ Publication date element; its dateTime attribute is formatted and set as textContent
+
+
+ first('span.modified')
+ HTMLSpanElement
+ optional
+ Wrapper around the modification date; removed when the dateTime is invalid
+
+
+ first('.modified time')
+ HTMLTimeElement
+ optional
+ Modification date element; its dateTime attribute is formatted and set as textContent
+
+
+
diff --git a/src/card/blogmeta/card-blogmeta.stories.ts b/src/card/blogmeta/card-blogmeta.stories.ts
new file mode 100644
index 0000000..8e1edd3
--- /dev/null
+++ b/src/card/blogmeta/card-blogmeta.stories.ts
@@ -0,0 +1,85 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+import { expect } from "storybook/test";
+import "./card-blogmeta.ts";
+import "./card-blogmeta.css";
+
+const meta: Meta = {
+ title: "Card/Blogmeta",
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => html`
+
+
+
+ Esther Brunner
+
+ 2026-03-09
+ 5 min read
+
+ `,
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("card-blogmeta");
+ const time = canvasElement.querySelector("time.published");
+ await expect(time).not.toHaveTextContent("2026-03-09");
+ await expect(time?.textContent?.trim().length).toBeGreaterThan(0);
+ },
+};
+
+export const WithModifiedDate: Story = {
+ render: () => html`
+
+
+
+ Esther Brunner
+
+
+ 2026-04-04
+
+ · updated on 2026-04-08
+
+
+ 7 min read
+
+ `,
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("card-blogmeta");
+ const published = canvasElement.querySelector("time.published");
+ const modified = canvasElement.querySelector(".modified time");
+ const modifiedSpan = canvasElement.querySelector("span.modified");
+ await expect(published).not.toHaveTextContent("2026-04-04");
+ await expect(modified).not.toHaveTextContent("2026-04-08");
+ await expect(modifiedSpan).toBeInTheDocument();
+ },
+};
+
+export const InvalidModifiedDate: Story = {
+ render: () => html`
+
+
+ Esther Brunner
+
+
+ 2026-04-04
+
+ · updated on not-a-date
+
+
+ 3 min read
+
+ `,
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("card-blogmeta");
+ const modifiedSpan = canvasElement.querySelector("span.modified");
+ await expect(modifiedSpan).not.toBeInTheDocument();
+ },
+};
diff --git a/src/card/blogmeta/card-blogmeta.ts b/src/card/blogmeta/card-blogmeta.ts
new file mode 100644
index 0000000..2d420a6
--- /dev/null
+++ b/src/card/blogmeta/card-blogmeta.ts
@@ -0,0 +1,51 @@
+import { defineComponent } from "@zeix/le-truc";
+import { getLocale } from "../../_common/getLocale";
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "card-blogmeta": HTMLElement;
+ }
+}
+
+const INVALID_DATE = "invalid date";
+const UNKNOWN_DATE = "unknown date";
+
+function formatLocalDate(
+ locale: string,
+ isoDate: string,
+ { dateStyle = "long" }: Intl.DateTimeFormatOptions = {},
+): string {
+ const [year, month, day] = isoDate.split("-").map(Number);
+ if (
+ !year ||
+ Number.isNaN(year) ||
+ !month ||
+ Number.isNaN(month) ||
+ Number.isNaN(day)
+ )
+ return INVALID_DATE;
+ const date = new Date(year, month - 1, day); // avoid UTC offset shifting the day
+ return new Intl.DateTimeFormat(locale, { dateStyle }).format(date);
+}
+
+export default defineComponent("card-blogmeta", ({ host, first }) => {
+ const published = first(
+ "time.published",
+ "Add a element to display the publication date.",
+ );
+ const modifiedSpan = first("span.modified");
+ const modified = first(".modified time");
+ const locale = getLocale(host);
+
+ published.textContent = published.dateTime
+ ? formatLocalDate(locale, published.dateTime)
+ : UNKNOWN_DATE;
+
+ if (modified) {
+ const modifiedDate = modified.dateTime
+ ? formatLocalDate(locale, modified.dateTime)
+ : INVALID_DATE;
+ if (modifiedSpan && modifiedDate === INVALID_DATE) modifiedSpan.remove();
+ else modified.textContent = modifiedDate;
+ }
+});
diff --git a/src/card/blogpost/card-blogpost.css b/src/card/blogpost/card-blogpost.css
new file mode 100644
index 0000000..20d7451
--- /dev/null
+++ b/src/card/blogpost/card-blogpost.css
@@ -0,0 +1,32 @@
+card-blogpost {
+ display: block;
+ padding: var(--space-l);
+ border: 1px solid var(--color-border-soft);
+ border-radius: var(--space-s);
+
+ &:has(:focus-visible) {
+ box-shadow: 0 0 var(--space-xxs) 2px var(--color-selection);
+ }
+
+ & h2 {
+ font-size: var(--font-size-l);
+
+ & a {
+ color: inherit;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:focus-visible {
+ box-shadow: none;
+ }
+ }
+ }
+
+ & p {
+ color: var(--color-text);
+ margin-bottom: 0;
+ }
+}
diff --git a/src/card/blogpost/card-blogpost.md b/src/card/blogpost/card-blogpost.md
new file mode 100644
index 0000000..bd71455
--- /dev/null
+++ b/src/card/blogpost/card-blogpost.md
@@ -0,0 +1,11 @@
+### Card Blogpost
+
+A CSS-only component that displays a blogpost teaser card.
+
+#### Preview
+
+{% demo %}
+{{ content }}
+
+{% sources title="Source code" src="./sources/card-blogpost.html" /%}
+{% /demo %}
diff --git a/src/card/blogpost/card-blogpost.mdx b/src/card/blogpost/card-blogpost.mdx
new file mode 100644
index 0000000..ea02afe
--- /dev/null
+++ b/src/card/blogpost/card-blogpost.mdx
@@ -0,0 +1,20 @@
+import { Meta, Canvas } from '@storybook/addon-docs/blocks';
+import * as CardBlogpostStories from './card-blogpost.stories';
+
+
+
+### Card Blogpost
+
+A CSS-only component that displays a blogpost teaser card. Composes a heading with a link, a `` for author and date, and a short description paragraph. All styling is applied through CSS — no JavaScript component definition.
+
+#### Tag Name
+
+`card-blogpost`
+
+#### Preview
+
+
+
+#### All Variants
+
+
diff --git a/src/card/blogpost/card-blogpost.stories.ts b/src/card/blogpost/card-blogpost.stories.ts
new file mode 100644
index 0000000..f13bb19
--- /dev/null
+++ b/src/card/blogpost/card-blogpost.stories.ts
@@ -0,0 +1,87 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+import "./card-blogpost.css";
+import "../blogmeta/card-blogmeta.ts";
+import "../blogmeta/card-blogmeta.css";
+
+const meta: Meta = {
+ title: "Card/Blogpost",
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => html`
+
+
+
+
+
+ Esther Brunner
+
+ 2026-03-09
+ 5 min read
+
+
+ A reactive custom elements library that brings fine-grained reactivity
+ directly to the web platform.
+
+
+ `,
+};
+
+export const AllVariants: Story = {
+ render: () => html`
+
+
+
+
+
+ Esther Brunner
+
+ 2026-03-09
+ 5 min read
+
+
+ A reactive custom elements library that brings fine-grained reactivity
+ directly to the web platform.
+
+
+
+
+
+
+
+ Esther Brunner
+
+
+ 2026-04-04
+
+ · updated on
+ 2026-04-08
+
+
+ 7 min read
+
+
+ A deep dive into the OKLCH color space and how it enables perceptually
+ uniform color palettes for design systems.
+
+
+ `,
+};
diff --git a/src/card/colorscale/card-colorscale.css b/src/card/colorscale/card-colorscale.css
new file mode 100644
index 0000000..3a582e3
--- /dev/null
+++ b/src/card/colorscale/card-colorscale.css
@@ -0,0 +1,134 @@
+card-colorscale {
+ --scale-max-size: 18rem;
+ --scale-padding: 0.5em;
+ --color-text: white;
+
+ display: inline-flex;
+ margin: 0 auto;
+
+ & ol {
+ display: grid;
+ list-style: none;
+ margin: 0 auto;
+ border-radius: 6% / 4%;
+ padding: 0;
+ grid-template-areas:
+ "lighten80 lighten60 lighten40 lighten20"
+ "base base base base"
+ "darken20 darken40 darken60 darken80";
+ grid-template-columns: repeat(4, 1fr);
+ grid-template-rows: 1fr 4fr 1fr;
+ aspect-ratio: 2 / 3;
+ min-height: min(100%, 150cqw, 100cqh, var(--scale-max-size));
+ max-width: calc(2 * var(--scale-max-size) / 3);
+ overflow: hidden;
+ object-fit: contain;
+ align-self: center;
+ container-type: inline-size;
+ gap: 0;
+ }
+
+ &.tiny {
+ & ol {
+ height: 3rem;
+ }
+
+ .label {
+ display: none;
+ }
+ }
+
+ &.small {
+ & ol {
+ height: 7.5rem;
+ }
+
+ .label small {
+ display: none;
+ }
+ }
+
+ &.medium ol {
+ height: 12rem;
+ }
+
+ &.large ol {
+ height: 18rem;
+ }
+
+ & li {
+ margin: 0;
+ }
+
+ .lighten80 {
+ grid-area: lighten80;
+ background: var(--color-lighten80);
+ }
+ .lighten60 {
+ grid-area: lighten60;
+ background: var(--color-lighten60);
+ }
+ .lighten40 {
+ grid-area: lighten40;
+ background: var(--color-lighten40);
+ }
+ .lighten20 {
+ grid-area: lighten20;
+ background: var(--color-lighten20);
+ }
+ .base {
+ grid-area: base;
+ background: var(--color-base);
+ }
+ .darken20 {
+ grid-area: darken20;
+ background: var(--color-darken20);
+ }
+ .darken40 {
+ grid-area: darken40;
+ background: var(--color-darken40);
+ }
+ .darken60 {
+ grid-area: darken60;
+ background: var(--color-darken60);
+ }
+ .darken80 {
+ grid-area: darken80;
+ background: var(--color-darken80);
+ }
+
+ .base {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ align-items: flex-start;
+ padding: var(--scale-padding);
+ line-height: 1;
+ }
+
+ & strong {
+ display: block;
+ color: var(--color-text);
+ font-size: 15cqi;
+ font-weight: 700;
+ }
+
+ & small {
+ display: block;
+ color: var(--color-text-soft);
+ font-size: 10cqi;
+ font-weight: 400;
+ }
+}
+
+@container (width < 5rem) or (height < 7.5rem) {
+ card-colorscale .label {
+ display: none;
+ }
+}
+
+@container (width < 8rem) or (height < 12rem) {
+ card-colorscale .label small {
+ display: none;
+ }
+}
diff --git a/src/card/colorscale/card-colorscale.mdx b/src/card/colorscale/card-colorscale.mdx
new file mode 100644
index 0000000..c0ab968
--- /dev/null
+++ b/src/card/colorscale/card-colorscale.mdx
@@ -0,0 +1,95 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as CardColorscaleStories from './card-colorscale.stories';
+
+
+
+### Card Colorscale
+
+A color scale display card that renders a named color and its full lighten/darken scale as CSS custom properties on the host. Demonstrates `asOklch()` as a custom parser, `watch()` with a custom handler to compute and set multiple `--color-*` CSS properties, and `bindText()` for the label.
+
+#### Tag Name
+
+`card-colorscale`
+
+#### Preview
+
+
+
+#### Controls
+
+
+
+#### All Variants
+
+
+
+#### Reactive Properties
+
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+ name
+ string
+ Text content of .label strong
+ Display name of the color
+
+
+ color
+ Oklch
+ Parsed from color attribute
+ Base color in Oklch color space; drives all --color-* CSS custom properties on the host
+
+
+
+
+#### Attributes
+
+
+
+
+ Name
+ Description
+
+
+
+
+ color
+ Oklch color string (e.g. oklch(0.7 0.15 250)); parsed at connect time via asOklch()
+
+
+
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('.label strong')
+ HTMLElement
+ required
+ Displays the color name; bound to name via bindText()
+
+
+ first('.label small')
+ HTMLElement
+ required
+ Displays the hex value of the base color; updated in the color watch handler
+
+
+
diff --git a/src/card/colorscale/card-colorscale.stories.ts b/src/card/colorscale/card-colorscale.stories.ts
new file mode 100644
index 0000000..f898434
--- /dev/null
+++ b/src/card/colorscale/card-colorscale.stories.ts
@@ -0,0 +1,114 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html, nothing } from "lit";
+import { expect } from "storybook/test";
+import "./card-colorscale.ts";
+import "./card-colorscale.css";
+import type { CardColorscaleProps } from "./card-colorscale.ts";
+
+type CardColorscaleArgs = {
+ name: string;
+ color: string;
+ variant: "none" | "tiny" | "small" | "medium" | "large";
+};
+
+const render = ({ name, color, variant }: CardColorscaleArgs) => html`
+
+
+
+
+
+
+
+
+ ${name}
+
+
+
+
+
+
+
+
+
+`;
+
+const meta: Meta = {
+ title: "Card/Colorscale",
+ render,
+ argTypes: {
+ name: {
+ control: "text",
+ table: {
+ defaultValue: { summary: "Blue" },
+ category: "Reactive Properties",
+ },
+ },
+ color: {
+ control: "text",
+ description: "Oklch color string parsed at connect time",
+ table: {
+ defaultValue: { summary: "oklch(.48 .23 263)" },
+ category: "Attributes",
+ },
+ },
+ variant: {
+ control: { type: "select" },
+ options: ["none", "tiny", "small", "medium", "large"],
+ table: {
+ defaultValue: { summary: "none" },
+ category: "Classes",
+ },
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ name: "Blue",
+ color: "oklch(.48 .23 263)",
+ variant: "medium",
+ },
+};
+
+const allVariants: CardColorscaleArgs[] = [
+ { name: "Blue", color: "oklch(.48 .23 263)", variant: "tiny" },
+ { name: "Blue", color: "oklch(.48 .23 263)", variant: "small" },
+ { name: "Blue", color: "oklch(.48 .23 263)", variant: "medium" },
+ { name: "Blue", color: "oklch(.48 .23 263)", variant: "large" },
+];
+
+export const AllVariants: Story = {
+ render: () =>
+ html`${allVariants.map((args) => html`${render(args)} `)}`,
+};
+
+export const PropertyChanges: Story = {
+ args: {
+ name: "Blue",
+ color: "oklch(.48 .23 263)",
+ variant: "medium",
+ },
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("card-colorscale");
+ const el = canvasElement.querySelector(
+ "card-colorscale",
+ ) as HTMLElement & CardColorscaleProps;
+ const labelSmall = el.querySelector(".label small");
+
+ await expect(labelSmall?.textContent?.trim()).toMatch(/^#[0-9a-f]{6}$/i);
+ await expect(el.style.getPropertyValue("--color-base")).not.toBe("");
+
+ const initialHex = labelSmall?.textContent?.trim();
+ el.color = { mode: "oklch", l: 0.55, c: 0.22, h: 29 };
+ await expect(labelSmall?.textContent?.trim()).not.toBe(initialHex);
+ await expect(labelSmall?.textContent?.trim()).toMatch(/^#[0-9a-f]{6}$/i);
+
+ el.name = "Red";
+ await expect(el.querySelector(".label strong")).toHaveTextContent("Red");
+ },
+};
diff --git a/src/card/colorscale/card-colorscale.ts b/src/card/colorscale/card-colorscale.ts
new file mode 100644
index 0000000..2e59ac3
--- /dev/null
+++ b/src/card/colorscale/card-colorscale.ts
@@ -0,0 +1,62 @@
+import { bindText, defineComponent } from "@zeix/le-truc";
+import "culori/css";
+import { formatCss, formatHex, type Oklch } from "culori/fn";
+import { asOklch } from "../../_common/asOklch.ts";
+import { getStepColor } from "../../_common/getStepColor.ts";
+
+export type CardColorscaleProps = {
+ name: string;
+ color: Oklch;
+};
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "card-colorscale": HTMLElement & CardColorscaleProps;
+ }
+}
+
+const CONTRAST_THRESHOLD = 0.71; // lightness
+
+export default defineComponent(
+ "card-colorscale",
+ ({ expose, first, host, watch }) => {
+ const labelStrong = first(
+ ".label strong",
+ "Add a element inside .label.",
+ );
+ const labelSmall = first(
+ ".label small",
+ "Add a element inside .label.",
+ );
+
+ expose({
+ name: labelStrong.textContent?.trim() ?? "",
+ color: asOklch(),
+ });
+
+ return [
+ watch("name", bindText(labelStrong, true)),
+ watch("color", (color) => {
+ labelSmall.textContent = formatHex(color);
+ const props = new Map();
+ const isLight = color.l > CONTRAST_THRESHOLD;
+ const softStep = isLight ? 0.1 : 0.9;
+ props.set("base", formatCss(color));
+ props.set("text", isLight ? "black" : "white");
+ props.set("text-soft", formatCss(getStepColor(color, softStep)));
+ for (let i = 4; i > 0; i--)
+ props.set(
+ `lighten${i * 20}`,
+ formatCss(getStepColor(color, (5 + i) / 10)),
+ );
+ for (let i = 1; i < 5; i++)
+ props.set(
+ `darken${i * 20}`,
+ formatCss(getStepColor(color, (5 - i) / 10)),
+ );
+ for (const [key, value] of props)
+ host.style.setProperty(`--color-${key}`, value);
+ }),
+ ];
+ },
+);
diff --git a/src/card/mediaqueries/card-mediaqueries.stories.ts b/src/card/mediaqueries/card-mediaqueries.stories.ts
index 62bef18..724c74e 100644
--- a/src/card/mediaqueries/card-mediaqueries.stories.ts
+++ b/src/card/mediaqueries/card-mediaqueries.stories.ts
@@ -3,7 +3,6 @@ import { html } from "lit";
import { expect, within } from "storybook/test";
import "../../context/media/context-media.ts";
import "./card-mediaqueries.ts";
-import type { Component } from "@zeix/le-truc";
import type { CardMediaqueriesProps } from "./card-mediaqueries.ts";
type CardMediaqueriesArgs = {
@@ -45,9 +44,8 @@ export const WithoutContext: Story = {
args: { heading: "Without Context" },
play: async ({ canvasElement }) => {
await customElements.whenDefined("card-mediaqueries");
- const el = canvasElement.querySelector(
- "card-mediaqueries",
- ) as Component;
+ const el = canvasElement.querySelector("card-mediaqueries") as HTMLElement &
+ CardMediaqueriesProps;
await expect(el.querySelector(".motion")).toHaveTextContent("unknown");
await expect(el.querySelector(".theme")).toHaveTextContent("unknown");
@@ -59,17 +57,14 @@ export const WithoutContext: Story = {
// ⚠️ Custom render: wraps the card inside a context-media provider to test that values are populated
export const WithContext: Story = {
render: () => html`
-
- ${cardTemplate("With Context")}
-
+ ${cardTemplate("With Context")}
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("context-media");
await customElements.whenDefined("card-mediaqueries");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "card-mediaqueries",
- ) as Component;
+ const el = canvasElement.querySelector("card-mediaqueries") as HTMLElement &
+ CardMediaqueriesProps;
const motion = el.querySelector(".motion");
const theme = el.querySelector(".theme");
@@ -92,9 +87,7 @@ export const WithContext: Story = {
// ⚠️ Custom render: renders two cards simultaneously — one inside context-media, one outside — to compare outputs
export const SideBySide: Story = {
render: () => html`
-
- ${cardTemplate("With Context")}
-
+ ${cardTemplate("With Context")}
${cardTemplate("Without Context (fallback)")}
`,
};
diff --git a/src/card/mediaqueries/card-mediaqueries.ts b/src/card/mediaqueries/card-mediaqueries.ts
index 3705eb8..c80c10e 100644
--- a/src/card/mediaqueries/card-mediaqueries.ts
+++ b/src/card/mediaqueries/card-mediaqueries.ts
@@ -1,9 +1,4 @@
-import {
- type Component,
- defineComponent,
- requestContext,
- setText,
-} from "@zeix/le-truc";
+import { bindText, defineComponent } from "@zeix/le-truc";
import {
MEDIA_MOTION,
MEDIA_ORIENTATION,
@@ -15,34 +10,32 @@ type CardMediaqueriesPropKeys = "motion" | "theme" | "viewport" | "orientation";
export type CardMediaqueriesProps = Record;
-type CardMediaqueriesUI = Partial<
- Record
->;
-
declare global {
interface HTMLElementTagNameMap {
- "card-mediaqueries": Component;
+ "card-mediaqueries": HTMLElement & CardMediaqueriesProps;
}
}
-export default defineComponent(
+export default defineComponent(
"card-mediaqueries",
- {
- motion: requestContext(MEDIA_MOTION, "unknown"),
- theme: requestContext(MEDIA_THEME, "unknown"),
- viewport: requestContext(MEDIA_VIEWPORT, "unknown"),
- orientation: requestContext(MEDIA_ORIENTATION, "unknown"),
+ ({ expose, first, requestContext, watch }) => {
+ const motionEl = first(".motion");
+ const themeEl = first(".theme");
+ const viewportEl = first(".viewport");
+ const orientationEl = first(".orientation");
+
+ expose({
+ motion: requestContext(MEDIA_MOTION, "unknown"),
+ theme: requestContext(MEDIA_THEME, "unknown"),
+ viewport: requestContext(MEDIA_VIEWPORT, "unknown"),
+ orientation: requestContext(MEDIA_ORIENTATION, "unknown"),
+ });
+
+ return [
+ motionEl && watch("motion", bindText(motionEl, true)),
+ themeEl && watch("theme", bindText(themeEl, true)),
+ viewportEl && watch("viewport", bindText(viewportEl, true)),
+ orientationEl && watch("orientation", bindText(orientationEl, true)),
+ ];
},
- ({ first }) => ({
- motion: first(".motion"),
- theme: first(".theme"),
- viewport: first(".viewport"),
- orientation: first(".orientation"),
- }),
- () => ({
- motion: setText("motion"),
- theme: setText("theme"),
- viewport: setText("viewport"),
- orientation: setText("orientation"),
- }),
);
diff --git a/src/context/media/context-media.stories.ts b/src/context/media/context-media.stories.ts
index efa3e5c..ccc1bbc 100644
--- a/src/context/media/context-media.stories.ts
+++ b/src/context/media/context-media.stories.ts
@@ -3,7 +3,6 @@ import { html, nothing } from "lit";
import { expect, within } from "storybook/test";
import "./context-media.ts";
import "../../card/mediaqueries/card-mediaqueries.ts";
-import type { Component } from "@zeix/le-truc";
import type { CardMediaqueriesProps } from "../../card/mediaqueries/card-mediaqueries.ts";
type ContextMediaArgs = {
@@ -77,7 +76,7 @@ export const Default: Story = {
const canvas = within(canvasElement);
const card = canvasElement.querySelector(
"card-mediaqueries",
- ) as Component;
+ ) as HTMLElement & CardMediaqueriesProps;
await expect(card.querySelector(".motion")).not.toHaveTextContent(
"unknown",
@@ -113,15 +112,19 @@ export const MultipleConsumers: Story = {
Consumer A
- Theme:
- Viewport:
+ Theme:
+
+ Viewport:
+
Consumer B
- Motion:
- Orientation:
+ Motion:
+
+ Orientation:
+
diff --git a/src/context/media/context-media.ts b/src/context/media/context-media.ts
index 0e0a2ab..2b5e0de 100644
--- a/src/context/media/context-media.ts
+++ b/src/context/media/context-media.ts
@@ -1,10 +1,4 @@
-import {
- type Component,
- type Context,
- createState,
- defineComponent,
- provideContexts,
-} from "@zeix/le-truc";
+import { type Context, createSensor, defineComponent } from "@zeix/le-truc";
export type ContextMediaMotion = "no-preference" | "reduce";
export type ContextMediaTheme = "light" | "dark";
@@ -20,7 +14,7 @@ export type ContextMediaProps = {
declare global {
interface HTMLElementTagNameMap {
- "context-media": Component;
+ "context-media": HTMLElement & ContextMediaProps;
}
}
@@ -47,81 +41,106 @@ export const MEDIA_ORIENTATION = "media-orientation" as Context<
export default defineComponent(
"context-media",
- {
- // Context for motion preference; true for no-preference, false for reduce
- [MEDIA_MOTION]: () => {
- const mql = matchMedia("(prefers-reduced-motion: reduce)");
- const motion = createState(mql.matches ? "reduce" : "no-preference");
- mql.addEventListener("change", (e) => {
- motion.set(e.matches ? "reduce" : "no-preference");
- });
- return motion;
- },
+ ({ expose, host, provideContexts }) => {
+ const getBreakpoint = (attr: string, fallback: string) => {
+ const value = host.getAttribute(attr);
+ const trimmed = value?.trim();
+ if (!trimmed) return fallback;
+ const unit = trimmed.match(/em$/) ? "em" : "px";
+ const v = parseFloat(trimmed);
+ return Number.isFinite(v) ? v + unit : fallback;
+ };
- // Context for preferred color scheme
- [MEDIA_THEME]: () => {
- const mql = matchMedia("(prefers-color-scheme: dark)");
- const theme = createState(mql.matches ? "dark" : "light");
- mql.addEventListener("change", (e) => {
- theme.set(e.matches ? "dark" : "light");
- });
- return theme;
- },
+ expose({
+ // Context for motion preference
+ [MEDIA_MOTION]: createSensor(
+ (set) => {
+ const mql = matchMedia("(prefers-reduced-motion: reduce)");
+ const listener = (e: MediaQueryListEvent) =>
+ set(e.matches ? "reduce" : "no-preference");
+ mql.addEventListener("change", listener);
+ return () => mql.removeEventListener("change", listener);
+ },
+ {
+ value: matchMedia("(prefers-reduced-motion: reduce)").matches
+ ? "reduce"
+ : "no-preference",
+ },
+ ),
- // Context for screen viewport size
- [MEDIA_VIEWPORT]: (ui: { host: HTMLElement }) => {
- const getBreakpoint = (attr: string, fallback: string) => {
- const value = ui.host.getAttribute(attr);
- const trimmed = value?.trim();
- if (!trimmed) return fallback;
- const unit = trimmed.match(/em$/) ? "em" : "px";
- const v = parseFloat(trimmed);
- return Number.isFinite(v) ? v + unit : fallback;
- };
- const mqlSM = matchMedia(`(min-width: ${getBreakpoint("sm", "32em")})`);
- const mqlMD = matchMedia(`(min-width: ${getBreakpoint("md", "48em")})`);
- const mqlLG = matchMedia(`(min-width: ${getBreakpoint("lg", "72em")})`);
- const mqlXL = matchMedia(`(min-width: ${getBreakpoint("xl", "104em")})`);
- const getViewport = () => {
- if (mqlXL.matches) return "xl";
- if (mqlLG.matches) return "lg";
- if (mqlMD.matches) return "md";
- if (mqlSM.matches) return "sm";
- return "xs";
- };
- const viewport = createState(getViewport());
- mqlSM.addEventListener("change", () => {
- viewport.set(getViewport());
- });
- mqlMD.addEventListener("change", () => {
- viewport.set(getViewport());
- });
- mqlLG.addEventListener("change", () => {
- viewport.set(getViewport());
- });
- mqlXL.addEventListener("change", () => {
- viewport.set(getViewport());
- });
- return viewport;
- },
+ // Context for preferred color scheme
+ [MEDIA_THEME]: createSensor(
+ (set) => {
+ const mql = matchMedia("(prefers-color-scheme: dark)");
+ const listener = (e: MediaQueryListEvent) =>
+ set(e.matches ? "dark" : "light");
+ mql.addEventListener("change", listener);
+ return () => mql.removeEventListener("change", listener);
+ },
+ {
+ value: matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light",
+ },
+ ),
- // Context for screen orientation
- [MEDIA_ORIENTATION]: () => {
- const mql = matchMedia("(orientation: landscape)");
- const orientation = createState(mql.matches ? "landscape" : "portrait");
- mql.addEventListener("change", (e) => {
- orientation.set(e.matches ? "landscape" : "portrait");
- });
- return orientation;
- },
+ // Context for screen viewport size
+ [MEDIA_VIEWPORT]: (() => {
+ const mqlSM = matchMedia(`(min-width: ${getBreakpoint("sm", "32em")})`);
+ const mqlMD = matchMedia(`(min-width: ${getBreakpoint("md", "48em")})`);
+ const mqlLG = matchMedia(`(min-width: ${getBreakpoint("lg", "72em")})`);
+ const mqlXL = matchMedia(
+ `(min-width: ${getBreakpoint("xl", "104em")})`,
+ );
+ const getViewport = (): ContextMediaViewport => {
+ if (mqlXL.matches) return "xl";
+ if (mqlLG.matches) return "lg";
+ if (mqlMD.matches) return "md";
+ if (mqlSM.matches) return "sm";
+ return "xs";
+ };
+ return createSensor(
+ (set) => {
+ const listener = () => set(getViewport());
+ mqlSM.addEventListener("change", listener);
+ mqlMD.addEventListener("change", listener);
+ mqlLG.addEventListener("change", listener);
+ mqlXL.addEventListener("change", listener);
+ return () => {
+ mqlSM.removeEventListener("change", listener);
+ mqlMD.removeEventListener("change", listener);
+ mqlLG.removeEventListener("change", listener);
+ mqlXL.removeEventListener("change", listener);
+ };
+ },
+ { value: getViewport() },
+ );
+ })(),
+
+ // Context for screen orientation
+ [MEDIA_ORIENTATION]: createSensor(
+ (set) => {
+ const mql = matchMedia("(orientation: landscape)");
+ const listener = (e: MediaQueryListEvent) =>
+ set(e.matches ? "landscape" : "portrait");
+ mql.addEventListener("change", listener);
+ return () => mql.removeEventListener("change", listener);
+ },
+ {
+ value: matchMedia("(orientation: landscape)").matches
+ ? "landscape"
+ : "portrait",
+ },
+ ),
+ });
+
+ return [
+ provideContexts([
+ MEDIA_MOTION,
+ MEDIA_THEME,
+ MEDIA_VIEWPORT,
+ MEDIA_ORIENTATION,
+ ]),
+ ];
},
- undefined,
- () => ({
- host: provideContexts([
- MEDIA_MOTION,
- MEDIA_THEME,
- MEDIA_VIEWPORT,
- MEDIA_ORIENTATION,
- ]),
- }),
);
diff --git a/src/form/checkbox/form-checkbox.css b/src/form/checkbox/form-checkbox.css
index 1308c00..52b3283 100644
--- a/src/form/checkbox/form-checkbox.css
+++ b/src/form/checkbox/form-checkbox.css
@@ -1,182 +1,183 @@
form-checkbox {
- display: inline-block;
- flex-grow: 1;
-
- & input:focus {
- outline: none;
- box-shadow: none;
- }
-
- & label {
- font-size: var(--font-size-s);
- border-radius: var(--space-xs);
-
- &:has(:focus-visible) {
- box-shadow: 0 0 var(--space-xxs) 2px var(--color-selection);
- }
- }
-
- &.checkbox label {
- display: inline-flex;
- gap: var(--space-s);
- line-height: var(--input-height);
- cursor: pointer;
- align-items: center;
-
- &::before {
- display: inline-block;
- box-sizing: border-box;
- /* biome-ignore lint/suspicious/noIrregularWhitespace: fix-to ensure same line-height */
- content: " ";
- text-align: center;
- width: var(--space-l);
- height: var(--space-l);
- line-height: 1.5;
- border: 1px solid var(--color-border);
- border-radius: var(--space-xs);
- background-color: var(--color-secondary);
- }
-
- &:hover::before {
- background-color: var(--color-secondary-hover);
- }
-
- &:active::before {
- background-color: var(--color-secondary-active);
- }
- }
-
- &.checkbox[checked] label {
- &::before {
- color: var(--color-text-inverted);
- background-color: var(--color-selection-selected);
- border-color: var(--color-selection-active);
- text-shadow: 0 0 var(--space-xs) var(--color-success-active);
- content: "✔︎";
- }
-
- &:hover::before {
- background-color: var(--color-selection-active);
- }
-
- &:active::before {
- background-color: var(--color-selection-active);
- }
- }
-
- &.todo label {
- display: inline-flex;
- gap: var(--space-s);
- line-height: var(--input-height);
- cursor: pointer;
- align-items: center;
-
- &::before {
- display: inline-block;
- box-sizing: border-box;
- /* biome-ignore lint/suspicious/noIrregularWhitespace: fix-to ensure same line-height */
- content: " ";
- text-align: center;
- width: var(--space-l);
- height: var(--space-l);
- line-height: 1.5;
- border: 1px solid var(--color-border);
- border-radius: var(--space-xs);
- background-color: var(--color-secondary);
- }
-
- &:hover::before {
- background-color: var(--color-secondary-hover);
- }
-
- &:active::before {
- background-color: var(--color-secondary-active);
- }
- }
-
- &.todo[checked] label {
- & span {
- text-decoration: line-through var(--color-success);
- }
-
- &::before {
- color: var(--color-text-inverted);
- background-color: var(--color-success);
- border-color: var(--color-success-active);
- text-shadow: 0 0 var(--space-xs) var(--color-success-active);
- content: "✔︎";
- }
-
- &:hover::before {
- background-color: var(--color-success-hover);
- }
-
- &:active::before {
- background-color: var(--color-success-active);
- }
- }
-
- &.toggle label {
- --toggle-knob-size: calc(var(--space-l) * 2);
-
- display: inline-flex;
- gap: var(--space-s);
- line-height: var(--input-height);
- cursor: pointer;
- align-items: center;
-
- &::before {
- display: inline-block;
- flex-shrink: 0;
- box-sizing: border-box;
- /* biome-ignore lint/suspicious/noIrregularWhitespace: fix-to ensure same line-height */
- content: " ";
- width: calc(var(--space-l) * 2);
- height: var(--space-l);
- line-height: 1.5;
- padding-left: var(--space-l);
- border-radius: calc(var(--input-height) / 2);
- background-color: var(--color-secondary);
- color: var(--color-text-inverted);
- border: 1px solid var(--color-border);
- background-image: radial-gradient(
- circle,
- var(--color-input) 35%,
- transparent 36%
- );
- background-position: left calc(-0.5 * var(--space-l)) center;
- background-repeat: no-repeat;
- transition:
- background-color var(--transition-short) var(--easing-inout),
- border-color var(--transition-short) var(--easing-inout),
- background-position var(--transition-short) var(--easing-inout);
- }
-
- &:hover::before {
- background-color: var(--color-secondary-hover);
- }
-
- &:active::before {
- background-color: var(--color-secondary-active);
- }
- }
-
- &.toggle[checked] label {
- &::before {
- background-color: var(--color-selection-selected);
- border-color: var(--color-selection-active);
- content: "✔︎";
- text-shadow: 0 0 var(--space-xs) var(--color-success-active);
- padding-left: var(--space-s);
- background-position: left calc(0.5 * var(--space-l)) center;
- }
-
- &:hover::before {
- background-color: var(--color-selection-active);
- }
-
- &:active::before {
- background-color: var(--color-selection-active);
- }
- }
+ display: inline-block;
+ flex-grow: 1;
+
+ & input:focus {
+ outline: none;
+ box-shadow: none;
+ }
+
+ & label {
+ font-size: var(--font-size-s);
+ border-radius: var(--space-xs);
+ }
+
+ &:has(input:focus-visible) label {
+ box-shadow: 0 0 var(--space-xxs) 2px var(--color-selection);
+ }
+
+ &.checkbox label {
+ display: inline-flex;
+ gap: var(--space-s);
+ line-height: var(--input-height);
+ cursor: pointer;
+ align-items: center;
+
+ &::before {
+ display: inline-block;
+ box-sizing: border-box;
+ content: ' ';
+ text-align: center;
+ width: var(--space-l);
+ height: var(--space-l);
+ line-height: 1.5;
+ border: 1px solid var(--color-border);
+ border-radius: var(--space-xs);
+ background-color: var(--color-secondary);
+ }
+
+ &:hover::before {
+ background-color: var(--color-secondary-hover);
+ opacity: var(--opacity-solid);
+ }
+
+ &:active::before {
+ background-color: var(--color-secondary-active);
+ }
+ }
+
+ &.checkbox[checked] label {
+ &::before {
+ color: var(--color-text-inverted);
+ background-color: var(--color-selection-selected);
+ border-color: var(--color-selection-active);
+ text-shadow: 0 0 var(--space-xs) var(--color-success-active);
+ content: '✔︎';
+ }
+
+ &:hover::before {
+ background-color: var(--color-selection-active);
+ }
+
+ &:active::before {
+ background-color: var(--color-selection-active);
+ }
+ }
+
+ &.todo label {
+ display: inline-flex;
+ gap: var(--space-s);
+ line-height: var(--input-height);
+ cursor: pointer;
+ align-items: center;
+
+ &::before {
+ display: inline-block;
+ box-sizing: border-box;
+ content: ' ';
+ text-align: center;
+ width: var(--space-l);
+ height: var(--space-l);
+ line-height: 1.5;
+ border: 1px solid var(--color-border);
+ border-radius: var(--space-xs);
+ background-color: var(--color-secondary);
+ }
+
+ &:hover::before {
+ background-color: var(--color-secondary-hover);
+ opacity: var(--opacity-solid);
+ }
+
+ &:active::before {
+ background-color: var(--color-secondary-active);
+ }
+ }
+
+ &.todo[checked] label {
+ opacity: var(--opacity-translucent);
+
+ & span {
+ text-decoration: line-through;
+ }
+
+ &::before {
+ color: var(--color-text-inverted);
+ background-color: var(--color-success);
+ border-color: var(--color-success-active);
+ text-shadow: 0 0 var(--space-xs) var(--color-success-active);
+ content: '✔︎';
+ }
+
+ &:hover::before {
+ background-color: var(--color-success-hover);
+ }
+
+ &:active::before {
+ background-color: var(--color-success-active);
+ }
+ }
+
+ &.toggle label {
+ --toggle-knob-size: calc(var(--space-l) * 2);
+
+ display: inline-flex;
+ gap: var(--space-s);
+ line-height: var(--input-height);
+ cursor: pointer;
+ align-items: center;
+
+ &::before {
+ display: inline-block;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ content: ' ';
+ width: calc(var(--space-l) * 2);
+ height: var(--space-l);
+ line-height: 1.5;
+ padding-left: var(--space-l);
+ border-radius: calc(var(--input-height) / 2);
+ background-color: var(--color-secondary);
+ color: var(--color-text-inverted);
+ border: 1px solid var(--color-border);
+ background-image: radial-gradient(
+ circle,
+ var(--color-input) 35%,
+ transparent 36%
+ );
+ background-position: left calc(-0.5 * var(--space-l)) center;
+ background-repeat: no-repeat;
+ transition:
+ background-color var(--transition-short) var(--easing-inout),
+ border-color var(--transition-short) var(--easing-inout),
+ background-position var(--transition-short) var(--easing-inout);
+ }
+
+ &:hover::before {
+ background-color: var(--color-secondary-hover);
+ }
+
+ &:active::before {
+ background-color: var(--color-secondary-active);
+ }
+ }
+
+ &.toggle[checked] label {
+ &::before {
+ background-color: var(--color-selection-selected);
+ border-color: var(--color-selection-active);
+ content: '✔︎';
+ text-shadow: 0 0 var(--space-xs) var(--color-success-active);
+ padding-left: var(--space-s);
+ background-position: left calc(0.5 * var(--space-l)) center;
+ }
+
+ &:hover::before {
+ background-color: var(--color-selection-active);
+ }
+
+ &:active::before {
+ background-color: var(--color-selection-active);
+ }
+ }
}
diff --git a/src/form/checkbox/form-checkbox.stories.ts b/src/form/checkbox/form-checkbox.stories.ts
index 3cac8a6..95b58cd 100644
--- a/src/form/checkbox/form-checkbox.stories.ts
+++ b/src/form/checkbox/form-checkbox.stories.ts
@@ -3,7 +3,6 @@ import { html, nothing } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./form-checkbox.ts";
import "./form-checkbox.css";
-import type { Component } from "@zeix/le-truc";
import type { FormCheckboxProps } from "./form-checkbox.ts";
type FormCheckboxArgs = {
@@ -66,7 +65,13 @@ const allVariants: FormCheckboxArgs[] = [
];
export const AllVariants: Story = {
- render: () => html`${allVariants.map((args, i) => html`${render(args)}${i < allVariants.length - 1 ? html` ` : nothing}`)}`,
+ render: () =>
+ html`${allVariants.map(
+ (args, i) =>
+ html`${render(args)}${i < allVariants.length - 1
+ ? html` `
+ : nothing}`,
+ )}`,
};
export const InitialChecked: Story = {
@@ -77,9 +82,8 @@ export const InitialChecked: Story = {
},
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-checkbox");
- const el = canvasElement.querySelector(
- "form-checkbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-checkbox") as HTMLElement &
+ FormCheckboxProps;
await expect(el.checked).toBe(true);
await expect(el).toHaveAttribute("checked");
@@ -95,9 +99,8 @@ export const DynamicUpdates: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-checkbox");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-checkbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-checkbox") as HTMLElement &
+ FormCheckboxProps;
await expect(el.checked).toBe(false);
await expect(el).not.toHaveAttribute("checked");
@@ -119,9 +122,8 @@ export const PropertyChanges: Story = {
},
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-checkbox");
- const el = canvasElement.querySelector(
- "form-checkbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-checkbox") as HTMLElement &
+ FormCheckboxProps;
const checkbox = el.querySelector("input");
const labelEl = el.querySelector(".label");
diff --git a/src/form/checkbox/form-checkbox.ts b/src/form/checkbox/form-checkbox.ts
index 8bfc4e4..0d7b774 100644
--- a/src/form/checkbox/form-checkbox.ts
+++ b/src/form/checkbox/form-checkbox.ts
@@ -1,49 +1,35 @@
-import {
- asString,
- type Component,
- defineComponent,
- on,
- read,
- setProperty,
- setText,
- toggleAttribute,
-} from "@zeix/le-truc";
+import { bindText, defineComponent } from "@zeix/le-truc";
export type FormCheckboxProps = {
checked: boolean;
label: string;
};
-type FormCheckboxUI = {
- checkbox: HTMLInputElement;
- label?: HTMLElement | undefined;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "form-checkbox": Component;
+ "form-checkbox": HTMLElement & FormCheckboxProps;
}
}
-export default defineComponent(
+export default defineComponent(
"form-checkbox",
- {
- checked: read((ui) => ui.checkbox.checked, false),
- label: asString(
- ({ host, label }) =>
- label?.textContent ?? host.querySelector("label")?.textContent ?? "",
- ),
+ ({ expose, first, host, on, watch }) => {
+ const checkbox = first('input[type="checkbox"]', "Add a native checkbox.");
+ const label = first(".label") ?? first("label");
+
+ expose({
+ checked: checkbox.checked,
+ label: label?.textContent ?? "",
+ });
+
+ return [
+ on(checkbox, "change", () => ({ checked: checkbox.checked })),
+
+ watch("checked", (checked) => {
+ checkbox.checked = checked;
+ host.toggleAttribute("checked", checked);
+ }),
+ label && watch("label", bindText(label, true)),
+ ];
},
- ({ first }) => ({
- checkbox: first('input[type="checkbox"]', "Add a native checkbox."),
- label: first(".label"),
- }),
- ({ checkbox }) => ({
- host: toggleAttribute("checked"),
- checkbox: [
- on("change", () => ({ checked: checkbox.checked })),
- setProperty("checked"),
- ],
- label: setText("label"),
- }),
);
diff --git a/src/form/colorgraph/form-colorgraph.css b/src/form/colorgraph/form-colorgraph.css
new file mode 100644
index 0000000..e6fbdd8
--- /dev/null
+++ b/src/form/colorgraph/form-colorgraph.css
@@ -0,0 +1,291 @@
+form-colorgraph {
+ --color-base: transparent;
+ --step-size: 1.25rem;
+
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-s);
+ margin: var(--space-m) 0;
+
+ .graph {
+ --canvas-size: 400px;
+
+ position: relative;
+ width: 100%;
+ display: block;
+ user-select: none;
+ transition: opacity var(--transition-medium) var(--easing-inout);
+
+ & canvas {
+ width: var(--canvas-size);
+ height: var(--canvas-size);
+ aspect-ratio: 1;
+ user-select: none;
+ }
+
+ .knob {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: var(--input-height);
+ height: var(--input-height);
+ border-radius: 50%;
+ background-color: var(--color-base);
+ border: 1px solid var(--color-border);
+ box-shadow: 0 0 var(--space-xxs) 2px var(--color-shadow);
+ cursor: move;
+ user-select: none;
+ touch-action: none;
+ transform: translate(
+ calc(var(--input-height) / -2),
+ calc(var(--input-height) / -2)
+ );
+
+ &:focus-visible {
+ box-shadow: 0 0 var(--space-xxs) 2px var(--color-selection);
+ }
+ }
+
+ & ol {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ user-select: none;
+ }
+
+ & li {
+ position: absolute;
+ box-sizing: border-box;
+ top: 0;
+ left: 0;
+ width: var(--step-size);
+ height: var(--step-size);
+ transform: translate(
+ calc(var(--step-size) / -2),
+ calc(var(--step-size) / -2)
+ );
+ border-radius: 100%;
+ background-color: transparent;
+ border: 1px solid black;
+ opacity: var(--opacity-dimmed);
+ user-select: none;
+ }
+
+ .lighten80 {
+ background: var(--color-lighten80);
+ }
+ .lighten60 {
+ background: var(--color-lighten60);
+ }
+ .lighten40 {
+ background: var(--color-lighten40);
+ }
+ .lighten20 {
+ background: var(--color-lighten20);
+ }
+ .darken20 {
+ background: var(--color-darken20);
+ }
+ .darken40 {
+ background: var(--color-darken40);
+ }
+ .darken60 {
+ background: var(--color-darken60);
+ }
+ .darken80 {
+ background: var(--color-darken80);
+ }
+ }
+
+ > .lightness,
+ > .chroma,
+ > .hue {
+ display: grid;
+ align-items: center;
+ column-gap: var(--space-xs);
+ grid-template-areas: "label label" "input buttons" "error error";
+ grid-template-columns: 1fr auto;
+ max-width: 12rem;
+ }
+
+ & label,
+ & button {
+ opacity: var(--opacity-dimmed);
+ transition: opacity var(--transition-short) var(--easing-inout);
+ }
+
+ & label {
+ grid-area: label;
+ display: block;
+ font-size: var(--font-size-s);
+ color: var(--color-text);
+ margin-bottom: var(--space-xxs);
+ }
+
+ & input {
+ display: inline-block;
+ box-sizing: border-box;
+ background: var(--color-input);
+ color: var(--color-text);
+ border: none;
+ border-bottom: 1px solid var(--color-border);
+ padding: var(--space-xs) var(--space-xxs);
+ font-size: var(--font-size-m);
+ text-align: right;
+ height: var(--input-height);
+ width: 100%;
+ transition: color var(--transition-short) var(--easing-inout);
+ appearance: textfield;
+ -moz-appearance: textfield;
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+
+ &[aria-invalid="true"] {
+ box-shadow: 0 0 var(--space-xxs) 2px var(--color-error-invalid);
+ }
+
+ &::placeholder {
+ color: var(--color-text);
+ opacity: var(--opacity-translucent);
+ }
+ }
+
+ .input {
+ grid-area: input;
+ display: inline-flex;
+ align-items: center;
+ }
+
+ .error {
+ grid-area: error;
+ margin: var(--space-xs) 0 0;
+ font-size: var(--font-size-xs);
+ line-height: var(--line-height-s);
+ color: color-mix(in srgb, var(--color-text) 50%, var(--color-error));
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ .slider {
+ --slider-width: 400px;
+ --slider-height: 40px;
+ --track-width: 360px;
+ --track-height: 8px;
+ --track-offset: 20px;
+
+ position: relative;
+ display: inline-block;
+ height: var(--slider-height);
+ user-select: none;
+ transition: opacity var(--transition-medium) var(--easing-inout);
+
+ & canvas {
+ width: calc(100% - 2 * var(--track-offset));
+ height: var(--track-height);
+ margin: calc(var(--track-offset) - var(--track-height) / 2)
+ var(--track-offset);
+ user-select: none;
+ }
+ }
+
+ .thumb {
+ position: absolute;
+ top: calc(var(--slider-height) / 2);
+ left: var(--track-offset);
+ width: var(--input-height);
+ height: var(--input-height);
+ background-color: var(--color-base);
+ box-sizing: border-box;
+ border: 1px solid var(--color-border);
+ border-radius: 50%;
+ box-shadow: 0 0 var(--space-xxs) 2px var(--color-shadow);
+ cursor: ew-resize;
+ user-select: none;
+ touch-action: none;
+ opacity: var(--opacity-dimmed);
+ transform: translate(
+ calc(var(--input-height) / -2),
+ calc(var(--input-height) / -2)
+ );
+ }
+
+ .buttons {
+ grid-area: buttons;
+ display: flex;
+ align-items: center;
+
+ & button {
+ flex-grow: 0;
+ box-sizing: border-box;
+ height: var(--input-height);
+ min-width: var(--input-height);
+ border: 1px solid var(--color-border);
+ background-color: var(--color-secondary);
+ color: var(--color-text);
+ padding: 0 var(--space-s);
+ font-size: var(--font-size-s);
+ line-height: var(--line-height-s);
+ white-space: nowrap;
+ cursor: pointer;
+ transition: all var(--transition-shorter) var(--easing-inout);
+
+ &:disabled {
+ opacity: var(--opacity-translucent);
+ }
+
+ &:not(:disabled) {
+ cursor: pointer;
+ opacity: var(--opacity-solid);
+
+ &:hover {
+ background-color: var(--color-secondary-hover);
+ }
+
+ &:active {
+ background-color: var(--color-secondary-active);
+ }
+ }
+
+ &:first-of-type {
+ border-radius: var(--space-xs) 0 0 var(--space-xs);
+ border-right-width: 0;
+ }
+
+ &:last-of-type {
+ border-radius: 0 var(--space-xs) var(--space-xs) 0;
+ }
+ }
+ }
+}
+
+@container (width > 27rem) {
+ form-colorgraph {
+ display: grid;
+ grid-template-areas: "graph graph graph" "slider slider slider" "lightness chroma hue";
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: var(--space-s) var(--space-m);
+ align-items: start;
+
+ .graph {
+ grid-area: graph;
+ }
+ .slider {
+ grid-area: slider;
+ }
+ .lightness {
+ grid-area: lightness;
+ }
+ .chroma {
+ grid-area: chroma;
+ }
+ .hue {
+ grid-area: hue;
+ }
+ }
+}
diff --git a/src/form/colorgraph/form-colorgraph.mdx b/src/form/colorgraph/form-colorgraph.mdx
new file mode 100644
index 0000000..769621b
--- /dev/null
+++ b/src/form/colorgraph/form-colorgraph.mdx
@@ -0,0 +1,193 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as FormColorgraphStories from './form-colorgraph.stories';
+
+
+
+### Form Colorgraph
+
+An interactive Oklch color picker with a 2D lightness/chroma graph canvas and a hue slider canvas. Demonstrates `createState`/`createMemo` for internal reactive state, `each()` for per-element effects on dynamic input, error, decrement, and increment button collections, `defineMethod()` for the `stepDown`/`stepUp` API, pointer event handling with `throttle`, `ResizeObserver` for canvas sizing, and direct canvas rendering via the 2D context API.
+
+#### Tag Name
+
+`form-colorgraph`
+
+#### Preview
+
+
+
+#### Controls
+
+
+
+#### Reactive Properties
+
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+ color
+ Oklch
+ Parsed from color attribute
+ Current color in Oklch color space; the primary writable prop
+
+
+ lightness
+ number (readonly)
+ 0
+ Derived from color.l; cannot be set externally
+
+
+ chroma
+ number (readonly)
+ 0
+ Derived from color.c; cannot be set externally
+
+
+ hue
+ number (readonly)
+ 0
+ Derived from color.h; cannot be set externally
+
+
+
+
+#### Attributes
+
+
+
+
+ Name
+ Description
+
+
+
+
+ color
+ Oklch color string parsed at connect time via asOklch()
+
+
+
+
+#### Methods
+
+
+
+
+ Name
+ Signature
+ Description
+
+
+
+
+ stepDown
+ (axis: 'l' | 'c' | 'h', bigStep?: boolean) => void
+ Decrements the given axis by one step (or a large step when bigStep is true)
+
+
+ stepUp
+ (axis: 'l' | 'c' | 'h', bigStep?: boolean) => void
+ Increments the given axis by one step (or a large step when bigStep is true)
+
+
+
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('input[name="lightness"]')
+ HTMLInputElement
+ required
+ Numeric input for the lightness axis
+
+
+ first('input[name="chroma"]')
+ HTMLInputElement
+ required
+ Numeric input for the chroma axis
+
+
+ first('input[name="hue"]')
+ HTMLInputElement
+ required
+ Numeric input for the hue axis
+
+
+ first('.graph')
+ HTMLElement
+ required
+ Container for the 2D color graph; receives pointer events and hosts the ResizeObserver
+
+
+ first('.graph canvas')
+ HTMLCanvasElement
+ required
+ Canvas for the lightness/chroma gamut visualization; redrawn when hue or canvas size changes
+
+
+ first('.slider')
+ HTMLElement
+ required
+ Hue slider container with role="slider"; receives pointer events for hue dragging
+
+
+ first('.slider canvas')
+ HTMLCanvasElement
+ required
+ Canvas for the hue gradient track; redrawn when color or track width changes
+
+
+ first('.knob')
+ HTMLElement
+ required
+ Drag handle positioned in the graph at the current lightness/chroma coordinates
+
+
+ first('.thumb')
+ HTMLElement
+ required
+ Drag handle positioned on the hue slider track at the current hue value
+
+
+ all('input')
+ Memo<HTMLInputElement[]>
+ required
+ All axis inputs; per-element effects wire value sync, validation, and ARIA error state
+
+
+ all('.error')
+ Memo<HTMLElement[]>
+ optional
+ Per-axis error message elements; each receives its axis's error text via bindText()
+
+
+ all('button.decrement')
+ Memo<HTMLButtonElement[]>
+ optional
+ Decrement buttons; disabled when the axis value is already at its minimum
+
+
+ all('button.increment')
+ Memo<HTMLButtonElement[]>
+ optional
+ Increment buttons; disabled when the axis value is already at its maximum
+
+
+
diff --git a/src/form/colorgraph/form-colorgraph.stories.ts b/src/form/colorgraph/form-colorgraph.stories.ts
new file mode 100644
index 0000000..4dff1bd
--- /dev/null
+++ b/src/form/colorgraph/form-colorgraph.stories.ts
@@ -0,0 +1,156 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+import { expect } from "storybook/test";
+import "./form-colorgraph.ts";
+import "./form-colorgraph.css";
+import type { FormColorgraphProps } from "./form-colorgraph.ts";
+
+type FormColorgraphArgs = {
+ color: string;
+};
+
+const render = ({ color }: FormColorgraphArgs) => html`
+
+
+
+
+ Drag
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lightness
+
+
+ %
+
+
+
+ −
+
+
+ +
+
+
+
+
+
+
Chroma
+
+
+
+
+
+ −
+
+
+ +
+
+
+
+
+
+
Hue
+
+
+ °
+
+
+
+ −
+
+
+ +
+
+
+
+
+
+`;
+
+const meta: Meta = {
+ title: "Form/Colorgraph",
+ render,
+ argTypes: {
+ color: {
+ control: "text",
+ description: "Oklch color string parsed at connect time",
+ table: {
+ defaultValue: { summary: "oklch(.48 .23 263)" },
+ category: "Attributes",
+ },
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ color: "oklch(.48 .23 263)",
+ },
+};
+
+export const Red: Story = {
+ args: {
+ color: "oklch(.55 .22 29)",
+ },
+};
+
+export const Green: Story = {
+ args: {
+ color: "oklch(.55 .17 145)",
+ },
+};
+
+export const PropertyChanges: Story = {
+ args: {
+ color: "oklch(.48 .23 263)",
+ },
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("form-colorgraph");
+ const el = canvasElement.querySelector("form-colorgraph") as HTMLElement &
+ FormColorgraphProps;
+
+ await expect(el.lightness).toBeCloseTo(0.48, 1);
+ await expect(el.chroma).toBeCloseTo(0.23, 1);
+ await expect(el.hue).toBeCloseTo(263, 0);
+
+ const initialLightness = el.lightness;
+ el.stepDown("l");
+ await expect(el.lightness).toBeLessThan(initialLightness);
+
+ el.stepUp("l", true);
+ await expect(el.lightness).toBeGreaterThan(initialLightness);
+ },
+};
diff --git a/src/form/colorgraph/form-colorgraph.ts b/src/form/colorgraph/form-colorgraph.ts
new file mode 100644
index 0000000..1827681
--- /dev/null
+++ b/src/form/colorgraph/form-colorgraph.ts
@@ -0,0 +1,529 @@
+import {
+ batch,
+ bindStyle,
+ bindText,
+ createMemo,
+ createState,
+ defineComponent,
+ defineMethod,
+ each,
+ throttle,
+} from "@zeix/le-truc";
+import { clampChroma, formatCss, inGamut, type Oklch } from "culori/fn";
+import { asOklch } from "../../_common/asOklch.ts";
+import { getStepColor } from "../../_common/getStepColor.ts";
+
+export type FormColorgraphAxis = "l" | "c" | "h";
+
+export type FormColorgraphProps = {
+ color: Oklch;
+ readonly lightness: number;
+ readonly chroma: number;
+ readonly hue: number;
+ stepDown: (axis: FormColorgraphAxis, bigStep?: boolean) => void;
+ stepUp: (axis: FormColorgraphAxis, bigStep?: boolean) => void;
+};
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "form-colorgraph": HTMLElement & FormColorgraphProps;
+ }
+}
+
+const inP3Gamut = inGamut("p3");
+const inRGBGamut = inGamut("rgb");
+const fn2Digits = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 })
+ .format;
+const fn4Digits = new Intl.NumberFormat("en-US", { maximumFractionDigits: 4 })
+ .format;
+const TRACK_OFFSET = 20; // pixels
+const CONTRAST_THRESHOLD = 0.71; // lightness
+const AXIS_MAX = { l: 1, c: 0.4, h: 360 };
+const AXIS_STEP = { l: 0.0025, c: 0.001, h: 1 };
+const AXIS_BIGSTEP = { l: 0.05, c: 0.02, h: 15 };
+const getStep = (axis: FormColorgraphAxis, shiftKey: boolean) =>
+ shiftKey ? AXIS_BIGSTEP[axis] : AXIS_STEP[axis];
+
+export default defineComponent(
+ "form-colorgraph",
+ ({ all, expose, first, host, on, watch }) => {
+ // Required elements
+ const inputs = {
+ l: first(
+ 'input[name="lightness"]',
+ 'Add an element to control the lightness of the color.',
+ ),
+ c: first(
+ 'input[name="chroma"]',
+ 'Add an element to control the chroma of the color.',
+ ),
+ h: first(
+ 'input[name="hue"]',
+ 'Add an element to control the hue of the color.',
+ ),
+ };
+ const graphEl = first(
+ ".graph",
+ "Add a <.graph> element as a container for the color graph.",
+ );
+ const canvas = first(
+ ".graph canvas",
+ "Add a element inside the graph to display the lightness/chroma graph.",
+ );
+ const sliderEl = first(
+ ".slider",
+ "Add a <.slider> element as a container for track and thumb.",
+ );
+ const track = first(
+ ".slider canvas",
+ "Add a element inside the slider to display the hue slider track.",
+ ) as HTMLCanvasElement;
+ const knob = first(
+ ".knob",
+ "Add a <.knob> element as a drag knob to control lightness and chroma.",
+ );
+ const thumb = first(
+ ".thumb",
+ "Add a <.thumb> element as a drag knob to control the hue.",
+ );
+ const allInputs = all("input");
+ const allErrors = all(".error");
+ const decrementBtns = all("button.decrement");
+ const incrementBtns = all("button.increment");
+
+ // Initialize
+ for (const [key, input] of Object.entries(inputs)) {
+ input.min = "0";
+ input.max = key === "l" ? "100" : key === "c" ? "0.4" : "360";
+ input.step = "any";
+ }
+ sliderEl.setAttribute("aria-valuemin", "0");
+ sliderEl.setAttribute("aria-valuemax", "360");
+
+ // Internal states
+ const canvasSize = createState(graphEl.getBoundingClientRect().width);
+ const trackWidth = createMemo(() => canvasSize.get() - 2 * TRACK_OFFSET);
+ const errors = {
+ l: createState(""),
+ c: createState(""),
+ h: createState(""),
+ };
+
+ // Helper functions
+ const formatNumber = (axis: FormColorgraphAxis, value: number) => {
+ const v = axis === "l" ? value * 100 : value;
+ return axis === "c" ? fn4Digits(v) : fn2Digits(v);
+ };
+ const getAxis = (target: HTMLElement): FormColorgraphAxis | null => {
+ if (target.closest(".lightness")) return "l";
+ if (target.closest(".chroma")) return "c";
+ if (target.closest(".hue")) return "h";
+ return null;
+ };
+ const getColorFromPosition = (
+ x: number,
+ y: number,
+ h: number,
+ alpha: number = 1,
+ ): string =>
+ formatCss({
+ mode: "oklch",
+ l: 1 - y,
+ c: x * AXIS_MAX.c,
+ h,
+ alpha,
+ });
+ const setStepPosition = (target: HTMLLIElement, color: Oklch): void => {
+ const size = canvasSize.get();
+ const x = Math.round((color.c * size) / AXIS_MAX.c);
+ const y = Math.round((1 - color.l) * size);
+ target.style.setProperty("background-color", formatCss(color));
+ target.style.setProperty(
+ "border-color",
+ color.l > CONTRAST_THRESHOLD ? "black" : "white",
+ );
+ target.style.setProperty("left", `${x}px`);
+ target.style.setProperty("top", `${y}px`);
+ };
+ const getHueFromPosition = (x: number): Oklch => {
+ const newColor = { ...host.color, h: x * AXIS_MAX.h };
+ if (inRGBGamut(newColor)) return newColor;
+ if (inP3Gamut(newColor)) newColor.alpha = 0.5;
+ else newColor.alpha = 0;
+ return newColor;
+ };
+ const commit = (color: Oklch) => {
+ batch(() => {
+ host.color = color;
+ for (const key of ["l", "c", "h"])
+ errors[key as keyof typeof errors].set("");
+ });
+ };
+ const getValue = (axis: FormColorgraphAxis) =>
+ axis === "l" ? host.lightness : axis === "c" ? host.chroma : host.hue;
+ const setToNearestStep = (axis: FormColorgraphAxis, value: number) => {
+ const nearest = Math.round(value / AXIS_STEP[axis]) * AXIS_STEP[axis];
+ if (nearest < 0 || nearest > AXIS_MAX[axis]) return;
+ const color = { ...host.color, [axis]: nearest };
+ if (inP3Gamut(color)) {
+ commit(color);
+ } else {
+ inputs[axis].setCustomValidity("Color out of gamut");
+ errors[axis].set(inputs[axis].validationMessage);
+ }
+ };
+ const moveKnob = throttle(
+ (x: number, y: number, top: number, left: number, size: number) => {
+ const color = {
+ ...host.color,
+ c: Math.min(Math.max((x - left) / size, 0), 1) * AXIS_MAX.c,
+ l: 1 - Math.min(Math.max((y - top) / size, 0), 1),
+ };
+ if (inP3Gamut(color)) commit(color);
+ },
+ );
+ const moveThumb = throttle((x: number, left: number, width: number) => {
+ const color = {
+ ...host.color,
+ h: Math.min(Math.max((x - left) / width, 0), 1) * AXIS_MAX.h,
+ };
+ if (inP3Gamut(color)) commit(color);
+ });
+
+ expose({
+ color: asOklch(),
+ lightness: () => host.color.l,
+ chroma: () => host.color.c,
+ hue: () => host.color.h ?? 0,
+ stepDown: defineMethod((axis: FormColorgraphAxis, bigStep = false) => {
+ setToNearestStep(axis, getValue(axis) - getStep(axis, bigStep));
+ }),
+ stepUp: defineMethod((axis: FormColorgraphAxis, bigStep = false) => {
+ setToNearestStep(axis, getValue(axis) + getStep(axis, bigStep));
+ }),
+ });
+
+ const effects = [
+ // ResizeObserver — runs once at connect, cleanup at disconnect
+ watch(
+ () => graphEl,
+ () => {
+ const setCanvasSize = throttle((w: number) => {
+ canvasSize.set(w);
+ });
+ const resizeObserver = new ResizeObserver(() => {
+ setCanvasSize(graphEl.clientWidth);
+ });
+ resizeObserver.observe(graphEl);
+ return () => {
+ resizeObserver.disconnect();
+ setCanvasSize.cancel();
+ };
+ },
+ ),
+
+ // Host CSS variable
+ watch(() => formatCss(host.color), bindStyle(host, "--color-base")),
+
+ // Input per-element effects
+ each(allInputs, (input) => {
+ const axis = getAxis(input);
+ return [
+ axis &&
+ watch(errors[axis], (error) => {
+ input.ariaInvalid = String(!!error);
+ if (error && input.id)
+ input.setAttribute("aria-errormessage", `${input.id}-error`);
+ else input.removeAttribute("aria-errormessage");
+ }),
+ watch("color", (color) => {
+ if (axis) input.value = formatNumber(axis, color[axis] ?? 0);
+ }),
+ on(input, "change", () => {
+ if (!axis) return;
+ const value = input.valueAsNumber;
+ const newColor = {
+ ...host.color,
+ [axis]: axis === "l" ? value / 100 : value,
+ };
+ if (inP3Gamut(newColor)) {
+ commit(newColor);
+ } else {
+ input.setCustomValidity("Color out of gamut");
+ errors[axis].set(input.validationMessage);
+ }
+ }),
+ ];
+ }),
+
+ // Error text per-element effects
+ each(allErrors, (errorEl) => {
+ const axis = getAxis(errorEl as HTMLElement);
+ return [axis ? watch(errors[axis], bindText(errorEl, true)) : false];
+ }),
+
+ // Graph pointer interaction + canvas size CSS variable
+ on(graphEl, "pointerdown", (event) => {
+ const { top, left } = canvas.getBoundingClientRect();
+ const size = canvasSize.get();
+ knob.ariaPressed = "true";
+ graphEl.setPointerCapture(event.pointerId);
+ const handleMove = (e: PointerEvent) => {
+ const last = (e.getCoalescedEvents?.() || []).pop() || e;
+ moveKnob(last.clientX, last.clientY, top, left, size);
+ };
+ const handleUp = () => {
+ graphEl.removeEventListener("pointermove", handleMove);
+ graphEl.removeEventListener("pointerup", handleUp);
+ knob.ariaPressed = "false";
+ moveKnob.cancel();
+ };
+ graphEl.addEventListener("pointermove", handleMove, { passive: true });
+ graphEl.addEventListener("pointerup", handleUp);
+ }),
+ watch(() => `${canvasSize.get()}px`, bindStyle(graphEl, "--canvas-size")),
+
+ // Graph canvas: redraw on hue or size change
+ watch(
+ () => ({ hue: host.hue, n: Math.round(canvasSize.get()) }),
+ ({ hue, n }) => {
+ canvas.width = n;
+ canvas.height = n;
+ const ctx = canvas.getContext("2d", { colorSpace: "display-p3" });
+ if (!ctx) return;
+ const maxChroma = (l: number, gamut: "rgb" | "p3" = "rgb") =>
+ clampChroma(
+ { mode: "oklch", l, c: AXIS_MAX.c, h: hue },
+ "oklch",
+ gamut,
+ ).c / AXIS_MAX.c;
+ const gradientStops = (
+ minX: number,
+ maxX: number,
+ y: number,
+ alpha: number = 1,
+ ): [string, string] => [
+ getColorFromPosition(minX, y, hue, alpha),
+ getColorFromPosition(maxX, y, hue, alpha),
+ ];
+ const drawGradient = (
+ minX: number,
+ y: number,
+ gamut: "rgb" | "p3" = "rgb",
+ ): [number, string] => {
+ const maxX = maxChroma(1 - y / n, gamut) * n;
+ const gradient = ctx.createLinearGradient(minX, 0, maxX, 0);
+ const stops = gradientStops(
+ minX / n,
+ maxX / n,
+ y / n,
+ gamut === "p3" ? 0.5 : 1,
+ );
+ gradient.addColorStop(0, stops[0]);
+ gradient.addColorStop(1, stops[1]);
+ ctx.fillStyle = gradient;
+ ctx.fillRect(minX, y, maxX - minX, 1);
+ return [maxX, stops[1]];
+ };
+ ctx.clearRect(0, 0, n, n);
+ for (let y = 0; y < n; y++) {
+ const [maxRgbX, maxRgbColor] = drawGradient(0, y);
+ if (inP3Gamut(maxRgbColor)) drawGradient(maxRgbX, y, "p3");
+ }
+ },
+ ),
+
+ // Knob position
+ watch(
+ () => ({
+ l: host.lightness,
+ c: host.chroma,
+ size: canvasSize.get(),
+ }),
+ ({ l, c, size }) => {
+ knob.style.setProperty("top", `${Math.round((1 - l) * size)}px`);
+ knob.style.setProperty(
+ "left",
+ `${Math.round((c * size) / AXIS_MAX.c)}px`,
+ );
+ knob.style.setProperty(
+ "--color-border",
+ l > CONTRAST_THRESHOLD ? "black" : "white",
+ );
+ },
+ ),
+
+ // Slider pointer interaction + ARIA + CSS variable
+ on(sliderEl, "pointerdown", (event) => {
+ const left = track.getBoundingClientRect().left;
+ const width = trackWidth.get();
+ thumb.ariaPressed = "true";
+ sliderEl.setPointerCapture(event.pointerId);
+ const handleMove = (e: PointerEvent) => {
+ const last = (e.getCoalescedEvents?.() || []).pop() || e;
+ moveThumb(last.clientX, left, width);
+ };
+ const handleUp = () => {
+ sliderEl.removeEventListener("pointermove", handleMove);
+ sliderEl.removeEventListener("pointerup", handleUp);
+ thumb.ariaPressed = "false";
+ moveThumb.cancel();
+ };
+ sliderEl.addEventListener("pointermove", handleMove, { passive: true });
+ sliderEl.addEventListener("pointerup", handleUp);
+ }),
+ watch(
+ () => `${trackWidth.get()}px`,
+ bindStyle(sliderEl, "--track-width"),
+ ),
+ watch("hue", (hue) => {
+ sliderEl.setAttribute("aria-valuenow", String(hue));
+ sliderEl.setAttribute("aria-valuetext", `${formatNumber("h", hue)}°`);
+ }),
+
+ // Track canvas: redraw on color or track width change
+ watch(
+ () => ({ color: host.color, n: Math.round(trackWidth.get()) }),
+ ({ n }) => {
+ track.width = n;
+ const ctx = track.getContext("2d", { colorSpace: "display-p3" });
+ if (!ctx) return;
+ ctx.clearRect(0, 0, n, 1);
+ for (let x = 0; x < n; x++) {
+ ctx.fillStyle = formatCss(getHueFromPosition(x / n));
+ ctx.fillRect(x, 0, 1, 1);
+ }
+ },
+ ),
+
+ // Thumb position
+ watch(
+ () => ({
+ hue: host.hue,
+ l: host.lightness,
+ tw: trackWidth.get(),
+ }),
+ ({ hue, l, tw }) => {
+ thumb.style.setProperty(
+ "left",
+ `${Math.round((hue * tw) / AXIS_MAX.h) + TRACK_OFFSET}px`,
+ );
+ thumb.style.setProperty(
+ "--color-border",
+ l > CONTRAST_THRESHOLD ? "black" : "white",
+ );
+ },
+ ),
+
+ // Decrement buttons
+ each(decrementBtns, (btn) => {
+ const axis = getAxis(btn);
+ return [
+ on(btn, "click", (event) => {
+ if (axis) host.stepDown(axis, (event as MouseEvent).shiftKey);
+ }),
+ watch("color", (color) => {
+ if (!axis) {
+ btn.disabled = true;
+ return;
+ }
+ btn.disabled = (color[axis] ?? 0) <= 0;
+ }),
+ ];
+ }),
+
+ // Increment buttons
+ each(incrementBtns, (btn) => {
+ const axis = getAxis(btn);
+ return [
+ on(btn, "click", (event) => {
+ if (axis) host.stepUp(axis, (event as MouseEvent).shiftKey);
+ }),
+ watch("color", (color) => {
+ if (!axis) {
+ btn.disabled = true;
+ return;
+ }
+ btn.disabled = (color[axis] ?? 0) >= AXIS_MAX[axis];
+ }),
+ ];
+ }),
+
+ // Keyboard navigation
+ on(host, "keydown", (event) => {
+ const { key, shiftKey } = event as KeyboardEvent;
+ const target = (event as KeyboardEvent).target as HTMLElement | null;
+ if (
+ !target ||
+ (target.localName === "input" &&
+ (key === "ArrowLeft" || key === "ArrowRight"))
+ )
+ return;
+ if (key.substring(0, 5) === "Arrow" || ["+", "-"].includes(key)) {
+ event.preventDefault();
+ event.stopPropagation();
+ const axis = getAxis(target);
+ if (axis) {
+ if (key === "ArrowLeft" || key === "ArrowDown" || key === "-")
+ host.stepDown(axis, shiftKey);
+ else if (key === "ArrowRight" || key === "ArrowUp" || key === "+")
+ host.stepUp(axis, shiftKey);
+ } else if (target.role === "slider") {
+ if (key === "ArrowLeft" || key === "ArrowDown" || key === "-")
+ host.stepDown("h", shiftKey);
+ else if (key === "ArrowRight" || key === "ArrowUp" || key === "+")
+ host.stepUp("h", shiftKey);
+ } else {
+ switch (key) {
+ case "ArrowDown":
+ host.stepDown("l", shiftKey);
+ break;
+ case "ArrowUp":
+ host.stepUp("l", shiftKey);
+ break;
+ case "ArrowLeft":
+ host.stepDown("c", shiftKey);
+ break;
+ case "ArrowRight":
+ host.stepUp("c", shiftKey);
+ break;
+ case "-":
+ host.stepDown("h");
+ break;
+ case "+":
+ host.stepUp("h");
+ break;
+ }
+ }
+ }
+ }),
+ ];
+
+ for (let i = 1; i < 5; i++) {
+ const li = first(`li.lighten${(5 - i) * 20}`);
+ if (li)
+ effects.push(
+ watch(
+ () => ({ color: host.color, size: canvasSize.get() }),
+ ({ color }) => {
+ setStepPosition(li, getStepColor(color, 1 - i / 10));
+ },
+ ),
+ );
+ }
+ for (let i = 1; i < 5; i++) {
+ const li = first(`li.darken${i * 20}`);
+ if (li)
+ effects.push(
+ watch(
+ () => ({ color: host.color, size: canvasSize.get() }),
+ ({ color }) => {
+ setStepPosition(li, getStepColor(color, 1 - (i + 5) / 10));
+ },
+ ),
+ );
+ }
+
+ return effects;
+ },
+);
diff --git a/src/form/combobox/form-combobox.mdx b/src/form/combobox/form-combobox.mdx
index f0c2779..b1cddf8 100644
--- a/src/form/combobox/form-combobox.mdx
+++ b/src/form/combobox/form-combobox.mdx
@@ -5,7 +5,7 @@ import * as FormComboboxStories from './form-combobox.stories';
### Form Combobox
-An advanced form component that coordinates a text input with a popup `form-listbox`. Demonstrates `createEventsSensor()` for the read-only `length` property, `createState()` and `createMemo()` for private reactive state inside the setup function, `pass()` to push the filter value into the child listbox reactively, multiple effects per UI element, and `setAttribute()` for dynamic ARIA attributes. The `clear` method property shows the MethodProducer pattern.
+An advanced form component that coordinates a text input with a popup `form-listbox`. Demonstrates `createState` with an `on('input', ...)` handler for the read-only `length` property, `createState()` and `createMemo()` for private reactive state, `pass()` to push the filter value into the child listbox reactively, and dynamic ARIA linkage via `setAttribute()`. The `clear` method property shows the `defineMethod()` pattern.
#### Tag Name
@@ -41,7 +41,7 @@ An advanced form component that coordinates a text input with a popup `form-list
length
number (readonly)
0
- Length of current input value; updated via createEventsSensor()
+ Length of current input value; read-only — backed by createState updated in an on(textbox, 'input', ...) handler
error
diff --git a/src/form/combobox/form-combobox.stories.ts b/src/form/combobox/form-combobox.stories.ts
index 46ff356..8897036 100644
--- a/src/form/combobox/form-combobox.stories.ts
+++ b/src/form/combobox/form-combobox.stories.ts
@@ -8,7 +8,6 @@ import "../../module/scrollarea/module-scrollarea.ts";
import "../../module/scrollarea/module-scrollarea.css";
import "./form-combobox.ts";
import "./form-combobox.css";
-import type { Component } from "@zeix/le-truc";
import type { FormComboboxProps } from "./form-combobox.ts";
type FormComboboxArgs = {
@@ -33,16 +32,30 @@ const render = ({ error, description }: FormComboboxArgs) => html`
- ${error}
- ${description}
+
+ ${error}
+
+
+ ${description}
+
`;
@@ -95,22 +108,31 @@ export const WithClear: Story = {
- ✕
+
+ ✕
+
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-combobox");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-combobox",
- ) as Component;
+ const el = canvasElement.querySelector("form-combobox") as HTMLElement &
+ FormComboboxProps;
const input = canvas.getByRole("combobox");
await expect(el.value).toBe("");
@@ -144,9 +166,15 @@ export const WithValidation: Story = {
@@ -155,9 +183,8 @@ export const WithValidation: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-combobox");
- const el = canvasElement.querySelector(
- "form-combobox",
- ) as Component;
+ const el = canvasElement.querySelector("form-combobox") as HTMLElement &
+ FormComboboxProps;
el.error = "Please select a valid language.";
await expect(el.querySelector(".error")).toHaveTextContent(
diff --git a/src/form/combobox/form-combobox.ts b/src/form/combobox/form-combobox.ts
index 25e9e29..cf59e7c 100644
--- a/src/form/combobox/form-combobox.ts
+++ b/src/form/combobox/form-combobox.ts
@@ -1,132 +1,118 @@
import {
batch,
- type Component,
- createEventsSensor,
+ bindAttribute,
+ bindText,
+ bindVisible,
createMemo,
createState,
defineComponent,
- on,
- pass,
- read,
- setAttribute,
- setProperty,
- setText,
- show,
+ defineMethod,
} from "@zeix/le-truc";
-import { clearEffects, clearMethod } from "../../_common/clear";
-import type { FormListboxProps } from "../listbox/form-listbox";
export type FormComboboxProps = {
value: string;
readonly length: number;
error: string;
description: string;
- readonly clear: () => void;
-};
-
-type FormComboboxUI = {
- textbox: HTMLInputElement;
- listbox: Component;
- clear?: HTMLButtonElement | undefined;
- error?: HTMLElement | undefined;
- description?: HTMLElement | undefined;
+ clear: () => void;
};
declare global {
interface HTMLElementTagNameMap {
- "form-combobox": Component;
+ "form-combobox": HTMLElement & FormComboboxProps;
}
}
-export default defineComponent(
+export default defineComponent(
"form-combobox",
- {
- value: read((ui) => ui.textbox.value, ""),
- length: createEventsSensor(
- read((ui) => ui.textbox.value.length, 0),
- "textbox",
- {
- input: ({ target }) => target.value.length,
- },
- ),
- error: "",
- description: read((ui) => ui.description?.textContent, ""),
- clear: clearMethod,
- },
- ({ first }) => ({
- textbox: first("input", "Needed to enter value."),
- listbox: first("form-listbox", "Needed to display options."),
- clear: first("button.clear"),
- error: first("form-combobox > .error"),
- description: first(".description"),
- }),
- (ui) => {
- const { host, error, description, listbox, textbox } = ui;
- const errorId = error?.id;
- const descriptionId = description?.id;
+ ({ expose, first, host, on, pass, watch }) => {
+ const textbox = first("input", "Needed to enter value.");
+ const listbox = first("form-listbox", "Needed to display options.");
+ const clearBtn = first("button.clear");
+ const errorEl = first("form-combobox > .error");
+ const descriptionEl = first(".description");
+
+ const errorId = errorEl?.id;
+ const descriptionId = descriptionEl?.id;
+ if (descriptionId) textbox.setAttribute("aria-describedby", descriptionId);
const showPopup = createState(false);
const isExpanded = createMemo(
() => showPopup.get() && listbox.options.length > 0,
);
+ const length = createState(textbox.value.length);
- return {
- host: [
- setAttribute("value"),
- on("keyup", ({ key }) => {
- if (key === "Escape") {
- showPopup.set(false);
- textbox.focus();
- }
- if (key === "Delete") host.clear();
- }),
- ],
- textbox: [
- setProperty("ariaInvalid", () => String(!!host.error)),
- setAttribute("aria-errormessage", () =>
- host.error && errorId ? errorId : null,
- ),
- setAttribute("aria-describedby", () =>
- host.description && descriptionId ? descriptionId : null,
- ),
- setProperty("ariaExpanded", () => String(isExpanded.get())),
- on("input", () => {
+ expose({
+ value: textbox.value,
+ length: length.get,
+ error: "",
+ description: descriptionEl?.textContent?.trim() ?? "",
+ clear: defineMethod(() => {
+ host.value = "";
+ textbox.value = "";
+ textbox.setCustomValidity("");
+ textbox.checkValidity();
+ textbox.dispatchEvent(new Event("input", { bubbles: true }));
+ textbox.dispatchEvent(new Event("change", { bubbles: true }));
+ textbox.focus();
+ }),
+ });
+
+ return [
+ pass(listbox, { filter: () => host.value }),
+
+ on(host, "keyup", ({ key }: KeyboardEvent) => {
+ if (key === "Escape") {
+ showPopup.set(false);
+ textbox.focus();
+ }
+ if (key === "Delete") host.clear();
+ }),
+ on(textbox, "input", () => {
+ length.set(textbox.value.length);
+ textbox.checkValidity();
+ batch(() => {
+ host.value = textbox.value;
+ host.error = textbox.validationMessage ?? "";
+ showPopup.set(true);
+ });
+ }),
+ on(textbox, "keydown", ({ key, altKey }) => {
+ if (key === "ArrowDown") {
+ if (altKey) showPopup.set(true);
+ if (isExpanded.get()) listbox.options[0]?.focus();
+ }
+ }),
+ on(listbox, "change", ({ target }: Event) => {
+ if (target instanceof HTMLInputElement) {
+ textbox.value = target.value;
textbox.checkValidity();
batch(() => {
- host.value = textbox.value;
+ host.value = target.value;
host.error = textbox.validationMessage ?? "";
- showPopup.set(true);
+ showPopup.set(false);
+ textbox.focus();
});
- }),
- on("keydown", (e) => {
- const { key, altKey } = e;
- if (key === "ArrowDown") {
- if (altKey) showPopup.set(true);
- if (isExpanded.get()) listbox.options[0]?.focus();
- }
- }),
- ],
- listbox: [
- show(isExpanded),
- pass({
- filter: () => host.value,
- }),
- on("change", ({ target }) => {
- if (target instanceof HTMLInputElement) {
- textbox.value = target.value;
- textbox.checkValidity();
- batch(() => {
- host.value = target.value;
- host.error = textbox.validationMessage ?? "";
- showPopup.set(false);
- textbox.focus();
- });
- }
- }),
- ],
- clear: [...clearEffects(ui)],
- error: setText("error"),
- description: setText("description"),
- };
+ }
+ }),
+ on(clearBtn, "click", () => {
+ host.clear();
+ }),
+
+ watch("value", bindAttribute(host, "value")),
+ watch("error", (error) => {
+ textbox.ariaInvalid = String(!!error);
+ if (error && errorId)
+ textbox.setAttribute("aria-errormessage", errorId);
+ else textbox.removeAttribute("aria-errormessage");
+ }),
+ errorEl && watch("error", bindText(errorEl, true)),
+ descriptionEl && watch("description", bindText(descriptionEl, true)),
+ watch(isExpanded, (expanded) => {
+ listbox.hidden = !expanded;
+ textbox.ariaExpanded = String(expanded);
+ }),
+ clearBtn && watch(length, bindVisible(clearBtn)),
+ ];
},
);
diff --git a/src/form/inplace-edit/form-inplace-edit.css b/src/form/inplace-edit/form-inplace-edit.css
new file mode 100644
index 0000000..10a0639
--- /dev/null
+++ b/src/form/inplace-edit/form-inplace-edit.css
@@ -0,0 +1,52 @@
+form-inplace-edit {
+ display: flex;
+ align-items: center;
+
+ .text {
+ flex: 1;
+ line-height: var(--line-height-s);
+ }
+
+ > button {
+ flex: 0;
+ height: var(--input-height);
+ min-inline-size: var(--input-height);
+ border-radius: var(--space-xs);
+ line-height: var(--line-height-s);
+ background: transparent;
+ border: none;
+ padding: var(--space-xs);
+ color: var(--color-primary);
+
+ &:hover {
+ background-color: var(--color-overlay-hover);
+ color: var(--color-primary-hover);
+ }
+
+ &:active {
+ background-color: var(--color-overlay-active);
+ color: var(--color-primary-active);
+ }
+ }
+
+ &[editing] {
+ .text {
+ display: none;
+ }
+
+ > button {
+ color: var(--color-primary-text);
+ background-color: var(--color-primary);
+ border-color: var(--color-primary-active);
+ border-radius: 0 var(--space-xs) var(--space-xs) 0;
+
+ &:hover {
+ background-color: var(--color-primary-hover);
+ }
+
+ &:active {
+ background-color: var(--color-primary-active);
+ }
+ }
+ }
+}
diff --git a/src/form/inplace-edit/form-inplace-edit.mdx b/src/form/inplace-edit/form-inplace-edit.mdx
new file mode 100644
index 0000000..d0720a0
--- /dev/null
+++ b/src/form/inplace-edit/form-inplace-edit.mdx
@@ -0,0 +1,91 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as FormInplaceEditStories from './form-inplace-edit.stories';
+
+
+
+### Form Inplace Edit
+
+An inline text editor that toggles between a read-only label and an editable ``. Demonstrates `asBoolean()` for the `editing` attribute, dynamic DOM construction inside a `watch()` handler (creating and inserting a `` on enter, removing it on exit), and focus management. The edit button, double-click on the label, Enter, and Escape all control the editing state.
+
+#### Tag Name
+
+`form-inplace-edit`
+
+#### Preview
+
+
+
+#### Controls
+
+
+
+#### Reactive Properties
+
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+ editing
+ boolean
+ false
+ Whether the component is in edit mode; toggled by the edit button, double-click, Enter, and Escape
+
+
+ value
+ string
+ Text content of .text
+ Current text value; updated when editing is confirmed
+
+
+
+
+#### Attributes
+
+
+
+
+ Name
+ Description
+
+
+
+
+ editing
+ Boolean attribute; presence sets editing to true at connect time
+
+
+
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('.text')
+ HTMLElement
+ required
+ Read-only label; displays value via bindText(); double-click sets editing = true
+
+
+ first('button')
+ HTMLButtonElement
+ required
+ Toggle button; shows ✎ in view mode and ✓ in edit mode; confirms the edit on click when editing
+
+
+
diff --git a/src/form/inplace-edit/form-inplace-edit.stories.ts b/src/form/inplace-edit/form-inplace-edit.stories.ts
new file mode 100644
index 0000000..3b424e6
--- /dev/null
+++ b/src/form/inplace-edit/form-inplace-edit.stories.ts
@@ -0,0 +1,127 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+import { expect, userEvent, within } from "storybook/test";
+import "./form-inplace-edit.ts";
+import "./form-inplace-edit.css";
+import "../../form/textbox/form-textbox.ts";
+import "../../form/textbox/form-textbox.css";
+import type { FormInplaceEditProps } from "./form-inplace-edit.ts";
+
+type FormInplaceEditArgs = {
+ value: string;
+ editing: boolean;
+};
+
+const render = ({ value, editing }: FormInplaceEditArgs) => html`
+
+ ${value}
+ ✎
+
+`;
+
+const meta: Meta = {
+ title: "Form/Inplace Edit",
+ render,
+ argTypes: {
+ value: {
+ control: "text",
+ table: {
+ defaultValue: { summary: "Edit me" },
+ category: "Reactive Properties",
+ },
+ },
+ editing: {
+ control: "boolean",
+ table: {
+ defaultValue: { summary: "false" },
+ category: "Reactive Properties",
+ },
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ value: "Edit me",
+ editing: false,
+ },
+};
+
+export const InitialEditing: Story = {
+ args: {
+ value: "Edit me",
+ editing: true,
+ },
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("form-inplace-edit");
+ const el = canvasElement.querySelector(
+ "form-inplace-edit",
+ ) as HTMLElement & FormInplaceEditProps;
+ await expect(el.editing).toBe(true);
+ await expect(canvasElement.querySelector("form-textbox")).toBeInTheDocument();
+ },
+};
+
+export const EditAndAccept: Story = {
+ args: {
+ value: "Edit me",
+ editing: false,
+ },
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("form-inplace-edit");
+ const canvas = within(canvasElement);
+ const el = canvasElement.querySelector(
+ "form-inplace-edit",
+ ) as HTMLElement & FormInplaceEditProps;
+
+ await expect(el.editing).toBe(false);
+
+ await userEvent.click(canvas.getByRole("button", { name: "Edit" }));
+ await expect(el.editing).toBe(true);
+ await expect(canvasElement.querySelector("form-textbox")).toBeInTheDocument();
+
+ const input = canvasElement.querySelector("input") as HTMLInputElement;
+ await userEvent.clear(input);
+ await userEvent.type(input, "Updated value");
+ await userEvent.keyboard("{Enter}");
+
+ await expect(el.editing).toBe(false);
+ await expect(el.value).toBe("Updated value");
+ await expect(canvasElement.querySelector(".text")).toHaveTextContent(
+ "Updated value",
+ );
+ },
+};
+
+export const EditAndCancel: Story = {
+ args: {
+ value: "Edit me",
+ editing: false,
+ },
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("form-inplace-edit");
+ const canvas = within(canvasElement);
+ const el = canvasElement.querySelector(
+ "form-inplace-edit",
+ ) as HTMLElement & FormInplaceEditProps;
+
+ await userEvent.click(canvas.getByRole("button", { name: "Edit" }));
+ await expect(el.editing).toBe(true);
+
+ const input = canvasElement.querySelector("input") as HTMLInputElement;
+ await userEvent.clear(input);
+ await userEvent.type(input, "Will be discarded");
+ await userEvent.keyboard("{Escape}");
+
+ await expect(el.editing).toBe(false);
+ await expect(el.value).toBe("Edit me");
+ await expect(canvasElement.querySelector(".text")).toHaveTextContent(
+ "Edit me",
+ );
+ await expect(
+ canvasElement.querySelector("form-textbox"),
+ ).not.toBeInTheDocument();
+ },
+};
diff --git a/src/form/inplace-edit/form-inplace-edit.ts b/src/form/inplace-edit/form-inplace-edit.ts
new file mode 100644
index 0000000..940a429
--- /dev/null
+++ b/src/form/inplace-edit/form-inplace-edit.ts
@@ -0,0 +1,98 @@
+import { asBoolean, bindText, defineComponent } from "@zeix/le-truc";
+
+export type FormInplaceEditProps = {
+ editing: boolean;
+ value: string;
+};
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "form-inplace-edit": HTMLElement & FormInplaceEditProps;
+ }
+}
+
+let idCounter = 0;
+
+export default defineComponent(
+ "form-inplace-edit",
+ ({ expose, first, host, on, watch }) => {
+ const textEl = first(
+ ".text",
+ 'Add an element with "text" class for label display.',
+ );
+ const editBtn = first(
+ "button",
+ "Add a element for edit mode toggle.",
+ );
+
+ const editInputId = `form-inplace-edit-input${++idCounter}`;
+ let input: HTMLInputElement | null = null;
+
+ expose({
+ editing: asBoolean(),
+ value: textEl.textContent?.trim() ?? "",
+ });
+
+ return [
+ on(editBtn, "click", (e) => {
+ e.stopPropagation();
+ if (host.editing && input)
+ return {
+ editing: false,
+ value: input.value,
+ };
+ else host.editing = !host.editing;
+ }),
+ on(textEl, "dblclick", () => ({ editing: true })),
+ on(host, "keydown", (e) => {
+ if (!host.editing) return;
+ if (e.key !== "Escape" && e.key !== "Enter") return;
+ e.preventDefault();
+ if (input && e.key === "Enter")
+ return {
+ editing: false,
+ value: input.value,
+ };
+ else host.editing = false;
+ }),
+ on(editBtn, "mousedown", (e) => {
+ e.preventDefault();
+ }),
+ on(host, "focusout", (e) => {
+ if (!host.editing) return;
+ const relatedTarget = e.relatedTarget as Element | null;
+ if (relatedTarget && host.contains(relatedTarget)) return;
+ host.editing = false;
+ }),
+
+ watch("value", bindText(textEl, true)),
+ watch("editing", (editing) => {
+ host.toggleAttribute("editing", editing);
+ if (editing) {
+ const textboxEl = document.createElement("form-textbox");
+ const labelEl = document.createElement("label");
+ labelEl.className = "visually-hidden";
+ labelEl.setAttribute("for", editInputId);
+ labelEl.textContent = "Edit";
+ const inputWrapper = document.createElement("div");
+ inputWrapper.className = "input";
+ input = document.createElement("input");
+ input.type = "text";
+ input.id = editInputId;
+ input.value = host.value;
+ inputWrapper.append(input);
+ textboxEl.append(labelEl, inputWrapper);
+ host.insertBefore(textboxEl, textEl);
+ editBtn.textContent = "✓";
+ editBtn.setAttribute("aria-label", "Accept");
+ input.focus();
+ input.select();
+ } else {
+ first("form-textbox")?.remove();
+ editBtn.textContent = "✎";
+ editBtn.setAttribute("aria-label", "Edit");
+ }
+ }),
+ ];
+ },
+);
diff --git a/src/form/listbox/form-listbox.css b/src/form/listbox/form-listbox.css
index 8ae3a29..21819cf 100644
--- a/src/form/listbox/form-listbox.css
+++ b/src/form/listbox/form-listbox.css
@@ -6,7 +6,7 @@ form-listbox {
position: relative;
margin-bottom: var(--space-m);
- input.filter {
+ & input.filter {
display: inline-block;
box-sizing: border-box;
background: var(--color-input);
@@ -25,7 +25,7 @@ form-listbox {
}
}
- button.clear {
+ & button.clear {
position: absolute;
bottom: 0;
right: 0;
@@ -46,7 +46,7 @@ form-listbox {
}
}
- module-scrollarea {
+ & module-scrollarea {
max-height: 40rem;
background-color: var(--color-background);
diff --git a/src/form/listbox/form-listbox.stories.ts b/src/form/listbox/form-listbox.stories.ts
index fcc88d8..434efa1 100644
--- a/src/form/listbox/form-listbox.stories.ts
+++ b/src/form/listbox/form-listbox.stories.ts
@@ -6,7 +6,6 @@ import "../../module/scrollarea/module-scrollarea.ts";
import "../../module/scrollarea/module-scrollarea.css";
import "./form-listbox.ts";
import "./form-listbox.css";
-import type { Component } from "@zeix/le-truc";
import type { FormListboxProps } from "./form-listbox.ts";
type FormListboxArgs = {
@@ -20,11 +19,51 @@ const render = ({ value }: FormListboxArgs) => html`
- Red
- Green
- Blue
- Yellow
- Purple
+
+ Red
+
+
+ Green
+
+
+ Blue
+
+
+ Yellow
+
+
+ Purple
+
@@ -77,16 +116,35 @@ export const WithFilter: Story = {
Filter fruits
-
- ✕
+
+
+ ✕
+
- Apple
- Banana
- Cherry
- Mango
- Orange
- Strawberry
+
+ Apple
+
+
+ Banana
+
+
+ Cherry
+
+
+ Mango
+
+
+ Orange
+
+
+ Strawberry
+
@@ -94,9 +152,8 @@ export const WithFilter: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-listbox");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-listbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-listbox") as HTMLElement &
+ FormListboxProps;
const filterInput = canvas.getByPlaceholderText("Filter fruits");
await expect(el.options.length).toBe(6);
@@ -120,14 +177,29 @@ export const WithGroups: Story = {
Citrus
-
Orange
-
Lemon
-
Lime
+
+ Orange
+
+
+ Lemon
+
+
+ Lime
+
Berries
-
Strawberry
-
Blueberry
+
+ Strawberry
+
+
+ Blueberry
+
@@ -136,9 +208,8 @@ export const WithGroups: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-listbox");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-listbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-listbox") as HTMLElement &
+ FormListboxProps;
await expect(el.value).toBe("");
@@ -166,9 +237,8 @@ export const WithSrc: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-listbox");
- const el = canvasElement.querySelector(
- "form-listbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-listbox") as HTMLElement &
+ FormListboxProps;
await waitFor(() => expect(el.options.length).toBeGreaterThan(0));
await expect(el.value).toBe("");
@@ -209,9 +279,8 @@ export const Selection: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-listbox");
- const el = canvasElement.querySelector(
- "form-listbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-listbox") as HTMLElement &
+ FormListboxProps;
await expect(el.value).toBe("fr");
diff --git a/src/form/listbox/form-listbox.ts b/src/form/listbox/form-listbox.ts
index ae3cdfa..0054ca8 100644
--- a/src/form/listbox/form-listbox.ts
+++ b/src/form/listbox/form-listbox.ts
@@ -1,29 +1,21 @@
import {
asString,
- type Component,
- createEffect,
+ bindVisible,
createElementsMemo,
createMemo,
createTask,
- dangerouslySetInnerHTML,
defineComponent,
- type Memo,
- on,
- read,
- setAttribute,
- setProperty,
- setText,
- show,
- toggleClass,
+ each,
+ escapeHTML,
+ schedule,
} from "@zeix/le-truc";
-import { escapeHTML } from "../../_common/escape";
import {
fetchWithCache,
isRecursiveURL,
isValidURL,
-} from "../../_common/fetch";
-import { manageFocus } from "../../_common/focus";
-import { highlightMatch } from "../../_common/highlight";
+} from "../../_common/fetchWithCache";
+import { highlightMatch } from "../../_common/highlightMatch";
+import { html } from "../../_common/html";
/**
* Form-aware Listbox Component
@@ -53,63 +45,56 @@ export type FormListboxProps = {
src: string;
};
-type FormListboxUI = {
- input: HTMLInputElement;
- listbox: HTMLElement;
- options: Memo;
- filter?: HTMLInputElement | undefined;
- clear?: HTMLButtonElement | undefined;
- callout?: HTMLElement | undefined;
- loading?: HTMLElement | undefined;
- error?: HTMLElement | undefined;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "form-listbox": Component;
+ "form-listbox": HTMLElement & FormListboxProps;
}
}
-export default defineComponent(
+/* === Constants === */
+
+const ENTER_KEY = "Enter";
+const DECREMENT_KEYS = ["ArrowUp"];
+const INCREMENT_KEYS = ["ArrowDown"];
+const FIRST_KEY = "Home";
+const LAST_KEY = "End";
+const HANDLED_KEYS = [
+ ...DECREMENT_KEYS,
+ ...INCREMENT_KEYS,
+ FIRST_KEY,
+ LAST_KEY,
+];
+
+export default defineComponent(
"form-listbox",
- {
- value: read(
- ({ listbox }: FormListboxUI) =>
- listbox.querySelector(
- 'button[role="option"][aria-selected="true"]',
- )?.value,
- "",
- ),
- options: ({ listbox }: FormListboxUI) =>
- createElementsMemo(listbox, 'button[role="option"]:not([hidden])'),
- filter: "",
- src: asString(),
- },
- ({ first, all }) => ({
- input: first('input[type="hidden"]', "Needed to store the selected value."),
- filter: first("input.filter"),
- clear: first("button.clear"),
- callout: first("card-callout"),
- loading: first(".loading"),
- error: first(".error"),
- listbox: first('[role="listbox"]', "Needed to display list of options."),
- options: all('button[role="option"]'),
- }),
- (ui) => {
- const { host, input } = ui;
+ ({ all, expose, first, host, on, watch }) => {
+ const input = first(
+ 'input[type="hidden"]',
+ "Needed to store the selected value.",
+ ) as HTMLInputElement;
+ const filterEl = first("input.filter") as HTMLInputElement | undefined;
+ const clearBtn = first("button.clear");
+ const callout = first("card-callout");
+ const loading = first(".loading");
+ const errorEl = first(".error");
+ const listbox = first(
+ '[role="listbox"]',
+ "Needed to display list of options.",
+ );
+ const options = all('button[role="option"]');
const renderOptions = (items: FormListboxOption[]) =>
items
.map(
- (item) => `
-
- ${escapeHTML(item.label)}
- `,
+ (item) =>
+ html`
+ ${escapeHTML(item.label)}
+ `,
)
.join("");
@@ -118,123 +103,158 @@ export default defineComponent(
let markup = "";
for (const [key, value] of Object.entries(items)) {
const groupId = `${id}-${escapeHTML(key)}`;
- markup += `
-
-
${escapeHTML(value.label)}
- ${renderOptions(value.items)}
-
`;
+ markup += html`
+
+ ${escapeHTML(value.label)}
+
+ ${renderOptions(value.items)}
+
`;
}
return markup;
};
- const content = createTask<{
- ok: boolean;
- value: string;
- error: string;
- pending: boolean;
- }>(
- async (_prev, abort) => {
- const url = host.src;
- const error = !url
- ? "No URL provided"
- : !isValidURL(url)
- ? "Invalid URL"
- : isRecursiveURL(url, host)
- ? "Recursive URL detected"
- : "";
- if (error) return { ok: false, value: "", error, pending: false };
-
- try {
- const { content } = await fetchWithCache(url, abort, (response) =>
- response.json(),
- );
- return {
- ok: true,
- value: Array.isArray(content)
- ? renderOptions(content)
- : renderGroups(content),
- error: "",
- pending: false,
- };
- } catch (err) {
- return { ok: false, value: "", error: String(err), pending: false };
- }
- },
- { value: { ok: false, value: "", error: "", pending: true } },
+ const content = createTask(async (_prev, abort) => {
+ const url = host.src;
+ if (!url) throw new Error("No URL provided");
+ if (!isValidURL(url)) throw new Error("Invalid URL");
+ if (isRecursiveURL(url, host)) throw new Error("Recursive URL detected");
+ try {
+ const { content: fetched } = await fetchWithCache(
+ url,
+ abort,
+ (response) => response.json(),
+ );
+ return Array.isArray(fetched)
+ ? renderOptions(fetched)
+ : renderGroups(fetched);
+ } catch (e) {
+ throw new Error(`Failed to fetch content for "${url}": ${String(e)}`);
+ }
+ });
+
+ const lowerFilter = createMemo(() => host.filter.toLowerCase());
+
+ // Roving tabindex focus management for listbox (inlined from manageFocus)
+ const getVisibleOptions = () =>
+ Array.from(
+ listbox.querySelectorAll(
+ 'button[role="option"]:not([hidden])',
+ ),
+ );
+
+ let focusIndex = getVisibleOptions().findIndex(
+ (option) => option.ariaSelected === "true",
);
- const maybeRender = () =>
- host.src
- ? [
- show(() => content.get().ok),
- dangerouslySetInnerHTML(() => content.get().value),
- ]
- : [];
+ expose({
+ value: first('button[role="option"][aria-selected="true"]')?.value ?? "",
+ options: createElementsMemo(
+ listbox,
+ 'button[role="option"]:not([hidden])',
+ ),
+ filter: "",
+ src: asString(),
+ });
- const lowerFilter = createMemo(() => host.filter.toLowerCase());
+ return [
+ on(filterEl, "input", (_e, el) => ({ filter: el.value ?? "" })),
+ on(clearBtn, "click", () => ({ filter: "" })),
+ // Focus management on listbox
+ on(listbox, "click", ({ target }) => {
+ const option = (target as HTMLElement).closest(
+ '[role="option"]',
+ ) as HTMLButtonElement;
+ if (option && option.value !== host.value) {
+ host.value = option.value;
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ }),
+ on(listbox, "keydown", (e) => {
+ const { key } = e as KeyboardEvent;
+ if (!HANDLED_KEYS.includes(key)) return;
- const hasError = () => (host.src ? !!content.get().error : false);
+ const elements = getVisibleOptions();
+ e.preventDefault();
+ e.stopPropagation();
+ if (key === FIRST_KEY) focusIndex = 0;
+ else if (key === LAST_KEY) focusIndex = elements.length - 1;
+ else
+ focusIndex =
+ (focusIndex +
+ (INCREMENT_KEYS.includes(key) ? 1 : -1) +
+ elements.length) %
+ elements.length;
+ elements[focusIndex]?.focus();
+ }),
+ on(listbox, "keyup", ({ key }) => {
+ if (key !== ENTER_KEY) return;
+ getVisibleOptions()[focusIndex]?.click();
+ }),
- return {
- host: setAttribute("value"),
- input: setProperty("value"),
- filter: on("input", () => {
- host.filter = ui.filter?.value ?? "";
+ watch("value", (value) => {
+ host.setAttribute("value", value);
+ input.value = value;
}),
- clear: [
- show(() => !!lowerFilter.get()),
- on("click", () => {
- host.filter = "";
+ host.src &&
+ watch(content, {
+ nil: () => {
+ if (callout) callout.hidden = false;
+ if (loading) {
+ loading.hidden = false;
+ return () => {
+ loading.hidden = false;
+ };
+ }
+ },
+ ok: (html) => {
+ if (callout) callout.hidden = true;
+ if (loading) loading.hidden = true;
+ if (errorEl) errorEl.hidden = true;
+ listbox.hidden = false;
+ schedule(listbox, () => {
+ listbox.innerHTML = html;
+ });
+ return () => {
+ listbox.hidden = true;
+ };
+ },
+ err: (error) => {
+ if (callout) {
+ callout.hidden = false;
+ callout.classList.add("danger");
+ }
+ if (errorEl) {
+ errorEl.hidden = false;
+ errorEl.textContent = error.message;
+ }
+ return () => {
+ if (callout) callout.classList.remove("danger");
+ if (errorEl) {
+ errorEl.hidden = true;
+ errorEl.textContent = "";
+ }
+ };
+ },
}),
- ],
- callout: [
- show(() => (host.src ? !content.get().ok : false)),
- toggleClass("danger", hasError),
- ],
- loading: show(() => (host.src ? content.get().pending : false)),
- error: [
- show(hasError),
- setText(() => (host.src ? content.get().error : "")),
- ],
- listbox: [
- ...manageFocus(
- () =>
- Array.from(
- ui.listbox.querySelectorAll(
- 'button[role="option"]:not([hidden])',
- ),
- ),
- (options) =>
- options.findIndex((option) => option.ariaSelected === "true"),
- ),
- on("click", ({ target }) => {
- const option = (target as HTMLElement).closest(
- '[role="option"]',
- ) as HTMLButtonElement;
- if (option && option.value !== host.value) {
- host.value = option.value;
- input.dispatchEvent(new Event("change", { bubbles: true }));
- }
- }),
- ...maybeRender(),
- ],
- options: [
- (_host, target) => {
- const textContent = target.textContent;
- const lowerText = textContent?.trim().toLowerCase();
- return createEffect(() => {
- const filterText = lowerFilter.get();
- target.hidden = !lowerText.includes(filterText);
- target.innerHTML = highlightMatch(textContent, filterText);
- });
- },
- (_host, target) =>
- createEffect(() => {
- const isSelected = host.value === target.value;
- target.tabIndex = isSelected ? 0 : -1;
- target.ariaSelected = String(isSelected);
+
+ // Per-option reactive effects
+ each(options, (option) => {
+ const textContent = option.textContent;
+ const lowerText = textContent?.trim().toLowerCase();
+ return [
+ watch(lowerFilter, (filterText) => {
+ option.hidden = !lowerText?.includes(filterText);
+ option.innerHTML = highlightMatch(textContent, filterText);
}),
- ],
- };
+ watch("value", () => {
+ const isSelected = host.value === option.value;
+ option.tabIndex = isSelected ? 0 : -1;
+ option.ariaSelected = String(isSelected);
+ }),
+ ];
+ }),
+
+ clearBtn && watch(lowerFilter, bindVisible(clearBtn)),
+ ];
},
);
diff --git a/src/form/radiogroup/form-radiogroup.css b/src/form/radiogroup/form-radiogroup.css
index 01e4f72..f768583 100644
--- a/src/form/radiogroup/form-radiogroup.css
+++ b/src/form/radiogroup/form-radiogroup.css
@@ -35,7 +35,7 @@ form-radiogroup {
cursor: pointer;
&::before {
- content: '';
+ content: "";
flex-shrink: 0;
display: inline-block;
box-sizing: border-box;
diff --git a/src/form/radiogroup/form-radiogroup.stories.ts b/src/form/radiogroup/form-radiogroup.stories.ts
index ed18bd4..2763acb 100644
--- a/src/form/radiogroup/form-radiogroup.stories.ts
+++ b/src/form/radiogroup/form-radiogroup.stories.ts
@@ -3,7 +3,6 @@ import { html, nothing } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./form-radiogroup.ts";
import "./form-radiogroup.css";
-import type { Component } from "@zeix/le-truc";
import type { FormRadiogroupProps } from "./form-radiogroup.ts";
type FormRadiogroupArgs = {
@@ -16,15 +15,33 @@ const render = ({ value, variant }: FormRadiogroupArgs) => html`
Theme
-
+
Light
-
+
Dark
-
+
System
@@ -85,15 +102,31 @@ export const AllVariants: Story = {
Theme
-
+
Light
-
+
Dark
-
+
System
@@ -103,15 +136,31 @@ export const AllVariants: Story = {
Filter
-
+
All
-
+
Active
-
+
Done
@@ -124,9 +173,8 @@ export const DynamicUpdates: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-radiogroup");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-radiogroup",
- ) as Component;
+ const el = canvasElement.querySelector("form-radiogroup") as HTMLElement &
+ FormRadiogroupProps;
await expect(el.value).toBe("light");
@@ -142,9 +190,8 @@ export const PropertyChanges: Story = {
args: { value: "light", variant: "split-button" },
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-radiogroup");
- const el = canvasElement.querySelector(
- "form-radiogroup",
- ) as Component;
+ const el = canvasElement.querySelector("form-radiogroup") as HTMLElement &
+ FormRadiogroupProps;
const labels = el.querySelectorAll("label");
await expect(el.value).toBe("light");
diff --git a/src/form/radiogroup/form-radiogroup.ts b/src/form/radiogroup/form-radiogroup.ts
index 70e4c5e..4192e32 100644
--- a/src/form/radiogroup/form-radiogroup.ts
+++ b/src/form/radiogroup/form-radiogroup.ts
@@ -1,58 +1,84 @@
-import {
- type Component,
- createEffect,
- defineComponent,
- type Memo,
- on,
- read,
-} from "@zeix/le-truc";
-import { manageFocus } from "../../_common/focus";
+import { defineComponent, each } from "@zeix/le-truc";
export type FormRadiogroupProps = {
value: string;
};
-type FormRadiogroupUI = {
- radios: Memo;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "form-radiogroup": Component;
+ "form-radiogroup": HTMLElement & FormRadiogroupProps;
}
}
+/* === Constants === */
+
+const ENTER_KEY = "Enter";
+const DECREMENT_KEYS = ["ArrowLeft", "ArrowUp"];
+const INCREMENT_KEYS = ["ArrowRight", "ArrowDown"];
+const FIRST_KEY = "Home";
+const LAST_KEY = "End";
+const HANDLED_KEYS = [
+ ...DECREMENT_KEYS,
+ ...INCREMENT_KEYS,
+ FIRST_KEY,
+ LAST_KEY,
+];
+
const getIndex = (radios: HTMLInputElement[]) =>
radios.findIndex((radio) => radio.checked);
-export default defineComponent(
+export default defineComponent(
"form-radiogroup",
- {
- value: read(({ radios }) => {
- const radiosArray = radios.get();
- return radiosArray[getIndex(radiosArray)]?.value;
- }, ""),
- },
- ({ all }) => ({
- radios: all(
+ ({ all, expose, host, on, watch }) => {
+ const radios = all(
'input[type="radio"]',
"Add at least two native radio buttons.",
- ),
- }),
- ({ host, radios }) => ({
- host: manageFocus(() => radios.get(), getIndex),
- radios: [
- on("change", (e) => {
- host.value = (e.target as HTMLInputElement).value;
+ );
+
+ // Roving tabindex focus management (inlined from manageFocus)
+ let focusIndex = getIndex(radios.get());
+
+ expose({ value: radios.get()[focusIndex]?.value ?? "" });
+
+ return [
+ on(radios, "change", (_e, el) => ({ value: el.value })),
+ on(host, "click", ({ target }) => {
+ if (!(target instanceof HTMLElement)) return;
+ if (target.hasAttribute("value"))
+ focusIndex = radios.get().indexOf(target as HTMLInputElement);
+ }),
+ on(host, "keydown", (e) => {
+ const { key } = e as KeyboardEvent;
+ if (!HANDLED_KEYS.includes(key)) return;
+
+ const elements = radios.get();
+ e.preventDefault();
+ e.stopPropagation();
+ if (key === FIRST_KEY) focusIndex = 0;
+ else if (key === LAST_KEY) focusIndex = elements.length - 1;
+ else
+ focusIndex =
+ (focusIndex +
+ (INCREMENT_KEYS.includes(key) ? 1 : -1) +
+ elements.length) %
+ elements.length;
+ elements[focusIndex]?.focus();
}),
- (_host, target) =>
- createEffect(() => {
- const isChecked = target.value === host.value;
- target.checked = isChecked;
- target.tabIndex = isChecked ? 0 : -1;
- const label = target.closest("label");
- if (label) label.classList.toggle("selected", isChecked);
- }),
- ],
- }),
+ on(host, "keyup", ({ key }) => {
+ if (key !== ENTER_KEY) return;
+ radios.get()[focusIndex]?.click();
+ }),
+
+ each(radios, (radio) =>
+ watch(
+ () => radio.value === host.value,
+ (isChecked) => {
+ radio.checked = isChecked;
+ radio.tabIndex = isChecked ? 0 : -1;
+ radio.closest("label")?.classList.toggle("selected", isChecked);
+ },
+ ),
+ ),
+ ];
+ },
);
diff --git a/src/form/spinbutton/form-spinbutton.stories.ts b/src/form/spinbutton/form-spinbutton.stories.ts
index 8d5d76c..55c53fe 100644
--- a/src/form/spinbutton/form-spinbutton.stories.ts
+++ b/src/form/spinbutton/form-spinbutton.stories.ts
@@ -3,7 +3,6 @@ import { html } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./form-spinbutton.ts";
import "./form-spinbutton.css";
-import type { Component } from "@zeix/le-truc";
import type { FormSpinbuttonProps } from "./form-spinbutton.ts";
type FormSpinbuttonArgs = {
@@ -13,7 +12,14 @@ type FormSpinbuttonArgs = {
const render = ({ value, max }: FormSpinbuttonArgs) => html`
- −
+
+ −
+
{
await customElements.whenDefined("form-spinbutton");
- const el = canvasElement.querySelector(
- "form-spinbutton",
- ) as Component;
+ const el = canvasElement.querySelector("form-spinbutton") as HTMLElement &
+ FormSpinbuttonProps;
await expect(el.value).toBe(3);
await expect(el.max).toBe(15);
@@ -80,9 +85,8 @@ export const IncrementDecrement: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-spinbutton");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-spinbutton",
- ) as Component;
+ const el = canvasElement.querySelector("form-spinbutton") as HTMLElement &
+ FormSpinbuttonProps;
const increment = canvas.getByLabelText("Increment");
await expect(el.value).toBe(0);
@@ -105,9 +109,8 @@ export const ClampedAtMax: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-spinbutton");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-spinbutton",
- ) as Component;
+ const el = canvasElement.querySelector("form-spinbutton") as HTMLElement &
+ FormSpinbuttonProps;
const increment = canvas.getByLabelText("Increment");
await expect(el.value).toBe(4);
@@ -122,9 +125,8 @@ export const PropertyChanges: Story = {
args: { value: 0, max: 10 },
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-spinbutton");
- const el = canvasElement.querySelector(
- "form-spinbutton",
- ) as Component;
+ const el = canvasElement.querySelector("form-spinbutton") as HTMLElement &
+ FormSpinbuttonProps;
const input = el.querySelector("input.value");
el.value = 7;
diff --git a/src/form/spinbutton/form-spinbutton.ts b/src/form/spinbutton/form-spinbutton.ts
index 2a66377..8fce31d 100644
--- a/src/form/spinbutton/form-spinbutton.ts
+++ b/src/form/spinbutton/form-spinbutton.ts
@@ -1,13 +1,8 @@
import {
- asInteger,
- type Component,
+ bindProperty,
+ bindVisible,
createMemo,
defineComponent,
- type Memo,
- on,
- read,
- setProperty,
- show,
} from "@zeix/le-truc";
export type FormSpinbuttonProps = {
@@ -15,97 +10,85 @@ export type FormSpinbuttonProps = {
max: number;
};
-type FormSpinbuttonUI = {
- controls: Memo<(HTMLButtonElement | HTMLInputElement)[]>;
- increment: HTMLButtonElement;
- decrement: HTMLButtonElement;
- input: HTMLInputElement;
- zero?: HTMLElement | undefined;
- other?: HTMLElement | undefined;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "form-spinbutton": Component;
+ "form-spinbutton": HTMLElement & FormSpinbuttonProps;
}
}
-export default defineComponent(
+export default defineComponent(
"form-spinbutton",
- {
- value: read((ui) => ui.input.value, asInteger()),
- max: read((ui) => ui.input.max, asInteger(10)),
- },
- ({ all, first }) => ({
- controls: all("button, input:not([disabled])"),
- increment: first(
+ ({ all, expose, first, host, on, watch }) => {
+ const controls = all("button, input:not([disabled])");
+ const increment = first(
"button.increment",
"Add a native button to increment the value",
- ),
- decrement: first(
+ );
+ const decrement = first(
"button.decrement",
"Add a native button to decrement the value",
- ),
- input: first("input.value", "Add a native input to display the value"),
- zero: first(".zero"),
- other: first(".other"),
- }),
- ({ host, increment, zero }) => {
+ );
+ const input = first(
+ "input.value",
+ "Add a native input to display the value",
+ );
+ const zero = first(".zero");
+ const other = first(".other");
+
const nonZero = createMemo(() => host.value !== 0);
const incrementLabel = increment.ariaLabel || "Increment";
- const ariaLabel = createMemo(() =>
- nonZero.get() || !zero ? incrementLabel : zero.textContent,
- );
- return {
- controls: [
- on("change", (e) => {
- const target = e.currentTarget as HTMLInputElement;
- if (!(target instanceof HTMLInputElement)) return;
+ expose({
+ value: Number.parseInt(input.value) || 0,
+ max: Number.parseInt(input.max) || 10,
+ });
- const next = Number(target.value);
- if (!Number.isInteger(next)) {
- target.value = String(host.value);
- target.checkValidity();
- return;
- }
- const clamped = Math.min(host.max, Math.max(0, next));
- if (next !== clamped) {
- target.value = String(clamped);
- target.checkValidity();
- }
- host.value = clamped;
- }),
- on("click", (e) => {
- const el = e.currentTarget as Element;
- if (el.classList.contains("decrement")) {
- host.value = Math.max(0, host.value - 1);
- } else if (el.classList.contains("increment")) {
- host.value = Math.min(host.max, host.value + 1);
- }
- }),
- on("keydown", (e) => {
- const { key } = e as KeyboardEvent;
- if (["ArrowUp", "ArrowDown", "-", "+"].includes(key)) {
- e.stopPropagation();
- e.preventDefault();
- const delta = key === "ArrowDown" || key === "-" ? -1 : 1;
- host.value = Math.min(host.max, Math.max(0, host.value + delta));
- }
+ return [
+ on(controls, "change", (_e, target) => {
+ if (!(target instanceof HTMLInputElement)) return;
+
+ const next = Number(target.value);
+ if (!Number.isInteger(next)) {
+ target.value = String(host.value);
+ target.checkValidity();
+ return;
+ }
+ const clamped = Math.min(host.max, Math.max(0, next));
+ if (next !== clamped) {
+ target.value = String(clamped);
+ target.checkValidity();
+ }
+ host.value = clamped;
+ }),
+ on(controls, "click", (_e, el) => {
+ if (el.classList.contains("decrement"))
+ host.value = Math.max(0, host.value - 1);
+ else if (el.classList.contains("increment"))
+ host.value = Math.min(host.max, host.value + 1);
+ }),
+ on(controls, "keydown", (e) => {
+ const { key } = e as KeyboardEvent;
+ if (["ArrowUp", "ArrowDown", "-", "+"].includes(key)) {
+ e.stopPropagation();
+ e.preventDefault();
+ const delta = key === "ArrowDown" || key === "-" ? -1 : 1;
+ host.value = Math.min(host.max, Math.max(0, host.value + delta));
+ }
+ }),
+
+ watch(nonZero, (nz) => {
+ input.hidden = !nz;
+ decrement.hidden = !nz;
+ }),
+ zero &&
+ watch(nonZero, (nz) => {
+ zero.hidden = nz;
+ increment.ariaLabel = nz ? incrementLabel : zero.textContent;
}),
- ],
- input: [
- show(nonZero),
- setProperty("value", () => String(host.value)),
- setProperty("max", () => String(host.max)),
- ],
- decrement: show(nonZero),
- increment: [
- setProperty("disabled", () => host.value >= host.max),
- setProperty("ariaLabel", ariaLabel),
- ],
- zero: show(() => !nonZero.get()),
- other: show(nonZero),
- };
+ other && watch(nonZero, bindVisible(other)),
+ watch(() => String(host.value), bindProperty(input, "value")),
+ watch(() => String(host.max), bindProperty(input, "max")),
+ watch(() => host.value >= host.max, bindProperty(increment, "disabled")),
+ ];
},
);
diff --git a/src/form/textbox/form-textbox.mdx b/src/form/textbox/form-textbox.mdx
index 67bca28..a79096b 100644
--- a/src/form/textbox/form-textbox.mdx
+++ b/src/form/textbox/form-textbox.mdx
@@ -5,7 +5,7 @@ import * as FormTextboxStories from './form-textbox.stories';
### Form Textbox
-A general-purpose text field wrapper for `input` or `textarea` elements. Demonstrates `createEventsSensor()` for the read-only `length` property, a conditional Reader initializer for `description` that produces either a static string or a computed function (remaining characters) depending on DOM attributes, the MethodProducer pattern for `clear`, and `setAttribute()` for dynamic ARIA linkage. Designed to be composable — `form-combobox` and `module-todo` both embed it and drive it via `pass()`.
+A general-purpose text field wrapper for `input` or `textarea` elements. Demonstrates `createState` with an `on('input', ...)` handler for the read-only `length` property, a conditional `createMemo()` initializer for `description` that computes remaining characters when `data-remaining` is present, the `defineMethod()` pattern for `clear`, and dynamic ARIA linkage via `setAttribute()`. Designed to be composable — `form-combobox` and `module-todo` both embed it and drive it via `pass()`.
#### Tag Name
@@ -41,7 +41,7 @@ A general-purpose text field wrapper for `input` or `textarea` elements. Demonst
length
number (readonly)
0
- Length of current textbox value; updated via createEventsSensor() on input events
+ Length of current textbox value; read-only — backed by createState updated in an on(textbox, 'input', ...) handler
error
@@ -77,23 +77,6 @@ A general-purpose text field wrapper for `input` or `textarea` elements. Demonst
-#### Attributes
-
-
-
-
- Name
- Description
-
-
-
-
- clearable
- Signals that a button.clear descendant is present; the button is shown/hidden automatically based on length
-
-
-
-
#### Descendant Elements
diff --git a/src/form/textbox/form-textbox.stories.ts b/src/form/textbox/form-textbox.stories.ts
index fd17149..1fbd1fc 100644
--- a/src/form/textbox/form-textbox.stories.ts
+++ b/src/form/textbox/form-textbox.stories.ts
@@ -3,7 +3,6 @@ import { html, nothing } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./form-textbox.ts";
import "./form-textbox.css";
-import type { Component } from "@zeix/le-truc";
import type { FormTextboxProps } from "./form-textbox.ts";
type FormTextboxArgs = {
@@ -16,11 +15,30 @@ const render = ({ error, description, clearable }: FormTextboxArgs) => html`
Name
-
- ${clearable ? html`✕ ` : nothing}
+
+ ${clearable
+ ? html`
+ ✕
+ `
+ : nothing}
- ${error}
- ${description}
+
+ ${error}
+
+
+ ${description}
+
`;
@@ -77,16 +95,17 @@ export const WithClear: Story = {
autocomplete="off"
placeholder="apple banana"
/>
- ✕
+
+ ✕
+
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-textbox");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-textbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-textbox") as HTMLElement &
+ FormTextboxProps;
const input = canvas.getByRole("textbox");
await expect(el.length).toBe(0);
@@ -114,7 +133,12 @@ export const WithTextarea: Story = {
maxlength="200"
>
-
+
{
await customElements.whenDefined("form-textbox");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "form-textbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-textbox") as HTMLElement &
+ FormTextboxProps;
const textarea = canvas.getByRole("textbox");
const description = el.querySelector(".description");
@@ -153,9 +176,8 @@ export const WithValidation: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("form-textbox");
- const el = canvasElement.querySelector(
- "form-textbox",
- ) as Component;
+ const el = canvasElement.querySelector("form-textbox") as HTMLElement &
+ FormTextboxProps;
const errorEl = el.querySelector(".error");
el.error = "Please enter a valid email address.";
diff --git a/src/form/textbox/form-textbox.ts b/src/form/textbox/form-textbox.ts
index 0a507f4..818dc64 100644
--- a/src/form/textbox/form-textbox.ts
+++ b/src/form/textbox/form-textbox.ts
@@ -1,105 +1,98 @@
import {
- type Component,
- type ComponentUI,
- createEventsSensor,
+ bindProperty,
+ bindText,
+ bindVisible,
+ createMemo,
+ createState,
defineComponent,
- on,
- read,
- setAttribute,
- setProperty,
- setText,
+ defineMethod,
} from "@zeix/le-truc";
-import { clearEffects, clearMethod } from "../../_common/clear";
export type FormTextboxProps = {
value: string;
readonly length: number;
error: string;
description: string;
- readonly clear: () => void;
-};
-
-type FormTextboxUI = {
- textbox: HTMLInputElement | HTMLTextAreaElement;
- clear?: HTMLButtonElement | undefined;
- error?: HTMLElement | undefined;
- description?: HTMLElement | undefined;
+ clear: () => void;
};
declare global {
interface HTMLElementTagNameMap {
- "form-textbox": Component;
+ "form-textbox": HTMLElement & FormTextboxProps;
}
}
-export default defineComponent(
+export default defineComponent(
"form-textbox",
- {
- value: read((ui) => ui.textbox.value, ""),
- length: createEventsSensor(
- read((ui) => ui.textbox.value.length, 0),
- "textbox",
- {
- input: ({ target }) => target.value.length,
- },
- ),
- error: "",
- description: ({
- host,
- description,
- textbox,
- }: ComponentUI) => {
- if (description) {
- if (textbox.maxLength > 0 && description.dataset.remaining) {
- return () =>
- description.dataset.remaining?.replace(
- // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal look-alike
- "${n}",
- String(textbox.maxLength - host.length),
- );
- }
- return description.textContent?.trim() ?? "";
- } else {
- return "";
- }
- },
- clear: clearMethod,
- },
- ({ first }) => ({
- textbox: first(
+ ({ expose, first, host, on, watch }) => {
+ const textbox = first(
"input, textarea",
"Add a native input or textarea as descendant element.",
- ),
- clear: first("button.clear"),
- error: first(".error"),
- description: first(".description"),
- }),
- (ui) => {
- const { host, textbox, error, description } = ui;
- const errorId = error?.id;
- const descriptionId = description?.id;
+ );
+ const clearBtn = first("button.clear");
+ const errorEl = first(".error");
+ const descriptionEl = first(".description");
+
+ const errorId = errorEl?.id;
+ const descriptionId = descriptionEl?.id;
+ if (descriptionId) textbox.setAttribute("aria-describedby", descriptionId);
+
+ // Reactive description: tracks remaining character count if template is present
+ const dataRemaining = descriptionEl?.dataset.remaining;
+ const descriptionMemo =
+ dataRemaining && textbox.maxLength > 0
+ ? createMemo(() =>
+ dataRemaining.replace(
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal lookalike
+ "${n}",
+ String(textbox.maxLength - host.length),
+ ),
+ )
+ : null;
+
+ const length = createState(textbox.value.length);
+
+ expose({
+ value: textbox.value,
+ length: length.get,
+ error: "",
+ description: descriptionMemo ?? descriptionEl?.textContent?.trim() ?? "",
+ clear: defineMethod(() => {
+ host.value = "";
+ textbox.value = "";
+ textbox.setCustomValidity("");
+ textbox.checkValidity();
+ textbox.dispatchEvent(new Event("input", { bubbles: true }));
+ textbox.dispatchEvent(new Event("change", { bubbles: true }));
+ textbox.focus();
+ }),
+ });
+
+ return [
+ on(textbox, "change", () => {
+ textbox.checkValidity();
+ return {
+ value: textbox.value,
+ error: textbox.validationMessage,
+ };
+ }),
+ on(textbox, "input", () => {
+ length.set(textbox.value.length);
+ }),
+ on(clearBtn, "click", () => {
+ host.clear();
+ }),
- return {
- textbox: [
- on("change", () => {
- textbox.checkValidity();
- return {
- value: textbox.value,
- error: textbox.validationMessage,
- };
- }),
- setProperty("value"),
- setProperty("ariaInvalid", () => String(!!host.error)),
- setAttribute("aria-errormessage", () =>
- host.error && errorId ? errorId : null,
- ),
- setAttribute("aria-describedby", () =>
- description && descriptionId ? descriptionId : null,
- ),
- ],
- clear: clearEffects(ui),
- error: setText("error"),
- description: setText("description"),
- };
+ watch("value", bindProperty(textbox, "value")),
+ watch("error", (error) => {
+ textbox.ariaInvalid = String(!!error);
+ if (error && errorId)
+ textbox.setAttribute("aria-errormessage", errorId);
+ else textbox.removeAttribute("aria-errormessage");
+ }),
+ errorEl && watch("error", bindText(errorEl, true)),
+ descriptionEl && watch("description", bindText(descriptionEl, true)),
+ clearBtn && watch(length, bindVisible(clearBtn)),
+ ];
},
);
diff --git a/src/main.css b/src/main.css
index ecb532f..96a9dc4 100644
--- a/src/main.css
+++ b/src/main.css
@@ -4,6 +4,7 @@
@import "./card/callout/card-callout.css";
@import "./form/checkbox/form-checkbox.css";
@import "./form/combobox/form-combobox.css";
+@import "./form/inplace-edit/form-inplace-edit.css";
@import "./form/listbox/form-listbox.css";
@import "./form/radiogroup/form-radiogroup.css";
@import "./form/spinbutton/form-spinbutton.css";
@@ -12,7 +13,6 @@
@import "./module/catalog/module-catalog.css";
@import "./module/codeblock/module-codeblock.css";
@import "./module/dialog/module-dialog.css";
-@import "./module/list/module-list.css";
@import "./module/listnav/module-listnav.css";
@import "./module/pagination/module-pagination.css";
@import "./module/scrollarea/module-scrollarea.css";
diff --git a/src/main.ts b/src/main.ts
index 7f69759..67652a7 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -7,6 +7,7 @@ import "./card/mediaqueries/card-mediaqueries.ts";
import "./context/media/context-media.ts";
import "./form/checkbox/form-checkbox.ts";
import "./form/combobox/form-combobox.ts";
+import "./form/inplace-edit/form-inplace-edit.ts";
import "./form/listbox/form-listbox.ts";
import "./form/radiogroup/form-radiogroup.ts";
import "./form/spinbutton/form-spinbutton.ts";
@@ -16,7 +17,6 @@ import "./module/catalog/module-catalog.ts";
import "./module/codeblock/module-codeblock.ts";
import "./module/dialog/module-dialog.ts";
import "./module/lazyload/module-lazyload.ts";
-import "./module/list/module-list.ts";
import "./module/listnav/module-listnav.ts";
import "./module/pagination/module-pagination.ts";
import "./module/scrollarea/module-scrollarea.ts";
diff --git a/src/module/carousel/module-carousel.css b/src/module/carousel/module-carousel.css
index b92373d..7a40281 100644
--- a/src/module/carousel/module-carousel.css
+++ b/src/module/carousel/module-carousel.css
@@ -1,179 +1,180 @@
module-carousel {
- display: flex;
- position: relative;
- overflow: hidden;
- margin-block-end: var(--space-l);
- border-radius: var(--space-s);
- container: carousel / inline-size;
-
- .slides {
- display: flex;
- align-items: center;
- width: 100%;
- min-height: calc(100cqi / (16 / 9));
- overflow-x: auto;
- scroll-snap-type: x mandatory;
- scroll-behavior: smooth;
- overscroll-behavior-x: none;
- }
-
- [role="tabpanel"] {
- width: 100%;
- height: calc(100% - var(--input-height));
- padding-bottom: var(--input-height);
- text-align: center;
- scroll-snap-align: start;
- flex: 0 0 100%;
-
- &.blue {
- background-color: var(--color-blue-20);
- --color-text: var(--color-blue-90);
- --color-text-soft: var(--color-blue-80);
- }
-
- &.purple {
- background-color: var(--color-purple-20);
- --color-text: var(--color-purple-90);
- --color-text-soft: var(--color-purple-80);
- }
-
- &.pink {
- background-color: var(--color-pink-20);
- --color-text: var(--color-pink-90);
- --color-text-soft: var(--color-pink-80);
- }
-
- &.orange {
- background-color: var(--color-orange-20);
- --color-text: var(--color-orange-90);
- --color-text-soft: var(--color-orange-80);
- }
-
- &.green {
- background-color: var(--color-green-20);
- --color-text: var(--color-green-90);
- --color-text-soft: var(--color-green-80);
- }
-
- & h3 {
- display: block;
- }
-
- & a[href].anchor {
- justify-content: center;
- padding: 0;
- }
-
- .slide-content {
- width: 80%;
- margin: 0 auto 0;
- padding-bottom: var(--space-xl);
- text-align: left;
- }
- }
-
- > nav {
- > button {
- position: absolute;
- top: 2%;
- height: 96%;
- border: 0;
- border-radius: var(--space-xs);
- background: transparent;
- padding: var(--space-m);
- font-size: var(--font-size-xl);
- color: var(--color-text);
- opacity: var(--opacity-dimmed);
- transition: opacity var(--transition-short) var(--easing-inout);
- cursor: pointer;
-
- &:hover {
- opacity: var(--opacity-solid);
- background-color: var(--color-overlay-hover);
- }
-
- &:active {
- background-color: var(--color-overlay-active);
- }
-
- &:focus {
- opacity: var(--opacity-solid);
- }
-
- &.prev {
- left: 1%;
- }
-
- &.next {
- right: 1%;
- }
- }
-
- [role="tablist"] {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- display: flex;
- justify-content: center;
- margin-block: var(--space-m);
-
- [role="tab"] {
- width: var(--space-l);
- height: var(--space-l);
- border: 0;
- padding: 0;
- font-size: var(--font-size-l);
- line-height: var(--line-height-xs);
- border-radius: 50%;
- color: var(--color-text);
- background-color: transparent;
- opacity: var(--opacity-translucent);
- transition: opacity var(--transition-short) var(--easing-inout);
- cursor: pointer;
-
- &:hover {
- opacity: var(--opacity-dimmed);
- }
-
- &[aria-selected="true"] {
- opacity: var(--opacity-solid);
- }
- }
- }
- }
+ display: flex;
+ position: relative;
+ overflow: hidden;
+ margin-block-end: var(--space-l);
+ border-radius: var(--space-s);
+ container: carousel / inline-size;
+
+ .slides {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ min-height: calc(100cqi / (16 / 9));
+ overflow-x: auto;
+ scroll-snap-type: x mandatory;
+ scroll-behavior: smooth;
+ overscroll-behavior-x: none;
+ }
+
+ [role="tabpanel"] {
+ width: 100%;
+ height: calc(100% - var(--input-height));
+ padding-bottom: var(--input-height);
+ text-align: center;
+ scroll-snap-align: start;
+ flex: 0 0 100%;
+
+ &.blue {
+ background-color: var(--color-blue-20);
+ --color-text: var(--color-blue-90);
+ --color-text-soft: var(--color-blue-80);
+ }
+
+ &.purple {
+ background-color: var(--color-purple-20);
+ --color-text: var(--color-purple-90);
+ --color-text-soft: var(--color-purple-80);
+ }
+
+ &.pink {
+ background-color: var(--color-pink-20);
+ --color-text: var(--color-pink-90);
+ --color-text-soft: var(--color-pink-80);
+ }
+
+ &.orange {
+ background-color: var(--color-orange-20);
+ --color-text: var(--color-orange-90);
+ --color-text-soft: var(--color-orange-80);
+ }
+
+ &.green {
+ background-color: var(--color-green-20);
+ --color-text: var(--color-green-90);
+ --color-text-soft: var(--color-green-80);
+ }
+
+ & h3 {
+ display: block;
+ }
+
+ & a[href].anchor {
+ justify-content: center;
+ padding: 0;
+ }
+
+ .slide-content {
+ width: 80%;
+ margin: 0 auto 0;
+ padding-bottom: var(--space-xl);
+ text-align: left;
+ }
+ }
+
+ > nav {
+ > button {
+ position: absolute;
+ top: 2%;
+ height: 96%;
+ border: 0;
+ border-radius: var(--space-xs);
+ background: transparent;
+ padding: var(--space-m);
+ font-size: var(--font-size-xl);
+ color: var(--color-text);
+ opacity: var(--opacity-dimmed);
+ transition: opacity var(--transition-short) var(--easing-inout);
+ cursor: pointer;
+
+ &:hover,
+ &:active,
+ &:focus {
+ opacity: var(--opacity-solid);
+ }
+
+ &:hover {
+ background-color: var(--color-overlay-hover);
+ }
+
+ &:active {
+ background-color: var(--color-overlay-active);
+ }
+
+ &.prev {
+ left: 1%;
+ }
+
+ &.next {
+ right: 1%;
+ }
+ }
+
+ [role="tablist"] {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ margin-block: var(--space-m);
+
+ [role="tab"] {
+ width: var(--space-l);
+ height: var(--space-l);
+ border: 0;
+ padding: 0;
+ font-size: var(--font-size-l);
+ line-height: var(--line-height-xs);
+ border-radius: 50%;
+ color: var(--color-text);
+ background-color: transparent;
+ opacity: var(--opacity-translucent);
+ transition: opacity var(--transition-short) var(--easing-inout);
+ cursor: pointer;
+
+ &:hover {
+ opacity: var(--opacity-dimmed);
+ }
+
+ &[aria-selected="true"] {
+ opacity: var(--opacity-solid);
+ }
+ }
+ }
+ }
}
@media (prefers-color-scheme: dark) {
- module-carousel [role="tabpanel"] {
- &.blue {
- background-color: var(--color-blue-80);
- --color-text: var(--color-blue-10);
- --color-text-soft: var(--color-blue-20);
- }
-
- &.purple {
- background-color: var(--color-purple-80);
- --color-text: var(--color-purple-10);
- --color-text-soft: var(--color-purple-20);
- }
-
- &.pink {
- background-color: var(--color-pink-80);
- --color-text: var(--color-pink-10);
- --color-text-soft: var(--color-pink-20);
- }
-
- &.orange {
- background-color: var(--color-orange-80);
- --color-text: var(--color-orange-10);
- --color-text-soft: var(--color-orange-20);
- }
-
- &.green {
- background-color: var(--color-green-80);
- --color-text: var(--color-green-10);
- --color-text-soft: var(--color-green-20);
- }
- }
+ module-carousel [role="tabpanel"] {
+ &.blue {
+ background-color: var(--color-blue-80);
+ --color-text: var(--color-blue-10);
+ --color-text-soft: var(--color-blue-20);
+ }
+
+ &.purple {
+ background-color: var(--color-purple-80);
+ --color-text: var(--color-purple-10);
+ --color-text-soft: var(--color-purple-20);
+ }
+
+ &.pink {
+ background-color: var(--color-pink-80);
+ --color-text: var(--color-pink-10);
+ --color-text-soft: var(--color-pink-20);
+ }
+
+ &.orange {
+ background-color: var(--color-orange-80);
+ --color-text: var(--color-orange-10);
+ --color-text-soft: var(--color-orange-20);
+ }
+
+ &.green {
+ background-color: var(--color-green-80);
+ --color-text: var(--color-green-10);
+ --color-text-soft: var(--color-green-20);
+ }
+ }
}
diff --git a/src/module/carousel/module-carousel.stories.ts b/src/module/carousel/module-carousel.stories.ts
index c7164c8..91af5a9 100644
--- a/src/module/carousel/module-carousel.stories.ts
+++ b/src/module/carousel/module-carousel.stories.ts
@@ -3,7 +3,6 @@ import { html } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./module-carousel.ts";
import "./module-carousel.css";
-import type { Component } from "@zeix/le-truc";
import type { ModuleCarouselProps } from "./module-carousel.ts";
type ModuleCarouselArgs = {
@@ -105,9 +104,8 @@ export const Navigation: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-carousel");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "module-carousel",
- ) as Component;
+ const el = canvasElement.querySelector("module-carousel") as HTMLElement &
+ ModuleCarouselProps;
await expect(el.index).toBe(0);
// prev hidden on first slide
@@ -129,9 +127,8 @@ export const DotNavigation: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-carousel");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "module-carousel",
- ) as Component;
+ const el = canvasElement.querySelector("module-carousel") as HTMLElement &
+ ModuleCarouselProps;
await userEvent.click(canvas.getByLabelText("Slide 3"));
await expect(el.index).toBe(2);
@@ -145,9 +142,8 @@ export const PropertyChanges: Story = {
args: { index: 0 },
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-carousel");
- const el = canvasElement.querySelector(
- "module-carousel",
- ) as Component;
+ const el = canvasElement.querySelector("module-carousel") as HTMLElement &
+ ModuleCarouselProps;
const slides = el.querySelectorAll('[role="tabpanel"]');
el.index = 1;
diff --git a/src/module/carousel/module-carousel.ts b/src/module/carousel/module-carousel.ts
index 433bc0a..29cee65 100644
--- a/src/module/carousel/module-carousel.ts
+++ b/src/module/carousel/module-carousel.ts
@@ -1,133 +1,104 @@
import {
- asInteger,
- type Component,
- createEffect,
+ bindProperty,
+ bindVisible,
defineComponent,
- type Memo,
- on,
- setProperty,
- show,
+ each,
} from "@zeix/le-truc";
export type ModuleCarouselProps = {
index: number;
};
-type ModuleCarouselUI = {
- dots: Memo;
- slides: Memo;
- buttons: Memo;
- prev: HTMLButtonElement;
- next: HTMLButtonElement;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "module-carousel": Component;
+ "module-carousel": HTMLElement & ModuleCarouselProps;
}
}
const clamp = (index: number, total: number) =>
Math.max(0, Math.min(index, total - 1));
-export default defineComponent(
+export default defineComponent(
"module-carousel",
- {
- index: asInteger((ui) =>
- Math.max(
- ui.slides.get().findIndex((slide) => slide.ariaCurrent === "true"),
+ ({ all, expose, first, host, on, watch }) => {
+ const dots = all('button[role="tab"]');
+ const slides = all('[role="tabpanel"]');
+ const buttons = all("nav button");
+ const prev = first("button.prev", "Add a previous button");
+ const next = first("button.next", "Add a next button");
+
+ let isNavigating = false;
+ let lastScrolled = -1;
+
+ expose({
+ index: Math.max(
+ slides.get().findIndex((slide) => slide.ariaCurrent === "true"),
0,
),
- ),
- },
- ({ all, first }) => ({
- dots: all('button[role="tab"]'),
- slides: all('[role="tabpanel"]'),
- buttons: all("nav button"),
- prev: first("button.prev", "Add a previous button"),
- next: first("button.next", "Add a next button"),
- }),
- ({ host, slides, prev, next, dots }) => {
- let isNavigating = false;
- let lastScrolled = host.index;
-
- return {
- host: [
- // Set up IntersectionObserver to detect scroll-based navigation
- () => {
- const observer = new IntersectionObserver(
- (entries) => {
- for (const entry of entries) {
- if (entry.intersectionRatio > 0.5) {
- const slideIndex = slides
- .get()
- .indexOf(entry.target as HTMLElement);
- if (isNavigating) {
- if (slideIndex === host.index) isNavigating = false;
- } else if (slideIndex !== host.index && slideIndex >= 0) {
- lastScrolled = slideIndex;
- host.index = slideIndex;
- }
- break;
+ });
+
+ return [
+ // Set up IntersectionObserver to detect scroll-based navigation
+ () => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.intersectionRatio > 0.5) {
+ const slideIndex = slides
+ .get()
+ .indexOf(entry.target as HTMLElement);
+ if (isNavigating) {
+ if (slideIndex === host.index) isNavigating = false;
+ } else if (slideIndex !== host.index && slideIndex >= 0) {
+ lastScrolled = slideIndex;
+ host.index = slideIndex;
}
+ break;
}
- },
- { root: host, threshold: 0.5 },
- );
- for (const slide of slides.get()) observer.observe(slide);
- return () => observer.disconnect();
- },
- // Scroll to slide when index changes (skip if IO already scrolled there)
- () =>
- createEffect(() => {
- const idx = host.index;
- if (lastScrolled !== idx) {
- lastScrolled = idx;
- isNavigating = true;
- slides.get()[idx]?.scrollIntoView({
- behavior: "smooth",
- block: "nearest",
- });
}
- }),
- ],
-
- // Prev button: hide on first slide; move focus to next when hidden
- prev: [
- show(() => host.index !== 0),
- on("click", () => {
- const newIndex = clamp(host.index - 1, slides.get().length);
- host.index = newIndex;
- if (newIndex === 0) next.focus();
- }),
- ],
-
- // Next button: hide on last slide; move focus to prev when hidden
- next: [
- show(() => host.index !== slides.get().length - 1),
- on("click", () => {
- const newIndex = clamp(host.index + 1, slides.get().length);
- host.index = newIndex;
- if (newIndex === slides.get().length - 1) prev.focus();
- }),
- ],
+ },
+ { root: host, threshold: 0.5 },
+ );
+ for (const slide of slides.get()) observer.observe(slide);
+ return () => observer.disconnect();
+ },
+
+ // Scroll to slide when index changes (skip if IO already scrolled there)
+ watch("index", (idx) => {
+ if (lastScrolled < 0) {
+ lastScrolled = idx;
+ return;
+ }
+ if (lastScrolled !== idx) {
+ lastScrolled = idx;
+ isNavigating = true;
+ slides
+ .get()
+ [idx]?.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ }),
+
+ // Prev button: move focus to next when hidden
+ on(prev, "click", () => {
+ const newIndex = clamp(host.index - 1, slides.get().length);
+ host.index = newIndex;
+ if (newIndex === 0) next.focus();
+ }),
+
+ // Next button: move focus to prev when hidden
+ on(next, "click", () => {
+ const newIndex = clamp(host.index + 1, slides.get().length);
+ host.index = newIndex;
+ if (newIndex === slides.get().length - 1) prev.focus();
+ }),
// Dot navigation
- dots: [
- on("click", ({ target }) => {
- if (target instanceof HTMLElement)
- host.index = parseInt(target.dataset.index || "0", 10);
- }),
- setProperty("ariaSelected", (target) =>
- String(target.dataset.index === String(host.index)),
- ),
- setProperty("tabIndex", (target) =>
- target.dataset.index === String(host.index) ? 0 : -1,
- ),
- ],
+ on(dots, "click", (_e, target) => {
+ host.index = Number.parseInt(target.dataset.index || "0", 10);
+ }),
// Keyboard navigation for all nav buttons (prev, next, dots)
- buttons: on("keyup", (e) => {
+ on(buttons, "keyup", (e) => {
const { key } = e;
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(key)) return;
e.preventDefault();
@@ -152,10 +123,28 @@ export default defineComponent(
}
}),
- // Active slide indicator
- slides: setProperty("ariaCurrent", (target) =>
- String(target.id === slides.get()[host.index]?.id),
+ // Effects for slides
+ each(slides, (slide) =>
+ watch(
+ () => String(slide.id === slides.get()[host.index]?.id),
+ bindProperty(slide, "ariaCurrent"),
+ ),
+ ),
+
+ // Effects for dot navigation
+ each(dots, (dot) =>
+ watch(
+ () => dot.dataset.index === String(host.index),
+ (selected) => {
+ dot.ariaSelected = String(selected);
+ dot.tabIndex = selected ? 0 : -1;
+ },
+ ),
),
- };
+
+ // Effects for prev/next navigation
+ watch(() => host.index > 0, bindVisible(prev)),
+ watch(() => host.index < slides.get().length - 1, bindVisible(next)),
+ ];
},
);
diff --git a/src/module/catalog/module-catalog.mdx b/src/module/catalog/module-catalog.mdx
index 43e4a6b..496c9be 100644
--- a/src/module/catalog/module-catalog.mdx
+++ b/src/module/catalog/module-catalog.mdx
@@ -33,13 +33,13 @@ None. This component coordinates child component properties through effects.
first('basic-button')
- Component<BasicButtonProps>
+ HTMLElement & BasicButtonProps
required
Shopping cart button receiving disabled and badge via pass()
all('form-spinbutton')
- Memo<Component<FormSpinbuttonProps>[]>
+ Memo<(HTMLElement & FormSpinbuttonProps)[]>
required
Spinbuttons whose value properties are summed to compute the cart total
diff --git a/src/module/catalog/module-catalog.stories.ts b/src/module/catalog/module-catalog.stories.ts
index 544dddd..84614d3 100644
--- a/src/module/catalog/module-catalog.stories.ts
+++ b/src/module/catalog/module-catalog.stories.ts
@@ -7,7 +7,6 @@ import "../../form/spinbutton/form-spinbutton.ts";
import "../../form/spinbutton/form-spinbutton.css";
import "./module-catalog.ts";
import "./module-catalog.css";
-import type { Component } from "@zeix/le-truc";
import type { BasicButtonProps } from "../../basic/button/basic-button.ts";
const meta: Meta = {
@@ -20,8 +19,20 @@ const spinbuttonItem = (name: string, label: string, max: number) => html`
${label}
- −
-
+
+ −
+
+
Add to Cart
+
@@ -53,9 +64,8 @@ export const Default: Story = {
await customElements.whenDefined("module-catalog");
await customElements.whenDefined("form-spinbutton");
const canvas = within(canvasElement);
- const button = canvasElement.querySelector(
- "basic-button",
- ) as Component;
+ const button = canvasElement.querySelector("basic-button") as HTMLElement &
+ BasicButtonProps;
// Cart button starts disabled (total = 0)
await expect(button.disabled).toBe(true);
diff --git a/src/module/catalog/module-catalog.ts b/src/module/catalog/module-catalog.ts
index 139050d..c29a61f 100644
--- a/src/module/catalog/module-catalog.ts
+++ b/src/module/catalog/module-catalog.ts
@@ -1,45 +1,28 @@
-import {
- type Component,
- createMemo,
- defineComponent,
- type Memo,
- pass,
-} from "@zeix/le-truc";
-import type { BasicButtonProps } from "../../basic/button/basic-button";
-import type { FormSpinbuttonProps } from "../../form/spinbutton/form-spinbutton";
-
-type ModuleCatalogProps = Record;
-
-type ModuleCatalogUI = {
- button: Component;
- spinbuttons: Memo[]>;
-};
+import { createMemo, defineComponent } from "@zeix/le-truc";
declare global {
interface HTMLElementTagNameMap {
- "module-catalog": Component;
+ "module-catalog": HTMLElement;
}
}
-export default defineComponent(
- "module-catalog",
- {},
- ({ all, first }) => ({
- button: first("basic-button", "Add a button to go to the Shopping Cart"),
- spinbuttons: all(
- "form-spinbutton",
- "Add spinbutton components to calculate sum from.",
- ),
- }),
- ({ spinbuttons }) => {
- const total = createMemo(() =>
- spinbuttons.get().reduce((sum, item) => sum + item.value, 0),
- );
- return {
- button: pass({
- disabled: () => !total.get(),
- badge: () => (total.get() > 0 ? String(total.get()) : ""),
- }),
- };
- },
-);
+export default defineComponent("module-catalog", ({ all, first, pass }) => {
+ const button = first(
+ "basic-button",
+ "Add a button to go to the Shopping Cart",
+ );
+ const spinbuttons = all(
+ "form-spinbutton",
+ "Add spinbutton components to calculate sum from.",
+ );
+ const total = createMemo(() =>
+ spinbuttons.get().reduce((sum, item) => sum + item.value, 0),
+ );
+
+ return [
+ pass(button, {
+ disabled: () => !total.get(),
+ badge: () => (total.get() > 0 ? String(total.get()) : ""),
+ }),
+ ];
+});
diff --git a/src/module/codeblock/module-codeblock.css b/src/module/codeblock/module-codeblock.css
index 05397c7..dfd4399 100644
--- a/src/module/codeblock/module-codeblock.css
+++ b/src/module/codeblock/module-codeblock.css
@@ -50,7 +50,7 @@ module-codeblock {
border-radius: var(--space-s) var(--space-s) 0 0;
&::after {
- content: '';
+ content: "";
display: block;
position: absolute;
bottom: 0;
@@ -58,13 +58,13 @@ module-codeblock {
height: var(--space-m);
background:
linear-gradient(-135deg, var(--color-secondary) 0.5rem, transparent 0) 0
- 0.5rem,
+ 0.5rem,
linear-gradient(
- 135deg,
- var(--color-secondary) 0.5rem,
- var(--color-background) 0
- )
- 0 0.5rem;
+ 135deg,
+ var(--color-secondary) 0.5rem,
+ var(--color-background) 0
+ )
+ 0 0.5rem;
background-color: var(--color-secondary);
background-size: var(--space-m) var(--space-m);
background-position: bottom;
diff --git a/src/module/codeblock/module-codeblock.stories.ts b/src/module/codeblock/module-codeblock.stories.ts
index d1312e1..be67365 100644
--- a/src/module/codeblock/module-codeblock.stories.ts
+++ b/src/module/codeblock/module-codeblock.stories.ts
@@ -7,7 +7,6 @@ import "../../module/scrollarea/module-scrollarea.ts";
import "../../module/scrollarea/module-scrollarea.css";
import "./module-codeblock.ts";
import "./module-codeblock.css";
-import type { Component } from "@zeix/le-truc";
import type { ModuleCodeblockProps } from "./module-codeblock.ts";
type ModuleCodeblockArgs = {
@@ -30,7 +29,11 @@ const render = ({ collapsed }: ModuleCodeblockArgs) => html`
Copy
-
+
Expand
@@ -61,9 +64,8 @@ export const Collapsed: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-codeblock");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "module-codeblock",
- ) as Component;
+ const el = canvasElement.querySelector("module-codeblock") as HTMLElement &
+ ModuleCodeblockProps;
await expect(el.collapsed).toBe(true);
await expect(el).toHaveAttribute("collapsed");
@@ -86,9 +88,8 @@ export const PropertyChanges: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-codeblock");
- const el = canvasElement.querySelector(
- "module-codeblock",
- ) as Component;
+ const el = canvasElement.querySelector("module-codeblock") as HTMLElement &
+ ModuleCodeblockProps;
await expect(el.collapsed).toBe(false);
diff --git a/src/module/codeblock/module-codeblock.ts b/src/module/codeblock/module-codeblock.ts
index 1b6e292..f859433 100644
--- a/src/module/codeblock/module-codeblock.ts
+++ b/src/module/codeblock/module-codeblock.ts
@@ -1,10 +1,4 @@
-import {
- asBoolean,
- type Component,
- defineComponent,
- on,
- toggleAttribute,
-} from "@zeix/le-truc";
+import { asBoolean, bindAttribute, defineComponent } from "@zeix/le-truc";
import type { BasicButtonProps } from "../../basic/button/basic-button";
import { copyToClipboard } from "../../basic/button/copyToClipboard";
@@ -12,34 +6,32 @@ export type ModuleCodeblockProps = {
collapsed: boolean;
};
-type ModuleCodeblockUI = {
- code: HTMLElement;
- overlay?: HTMLButtonElement | undefined;
- copy?: Component | undefined;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "module-codeblock": Component;
+ "module-codeblock": HTMLElement & ModuleCodeblockProps;
}
}
-export default defineComponent(
+export default defineComponent(
"module-codeblock",
- { collapsed: asBoolean() },
- ({ first }) => ({
- code: first("code", "Needed as source container to copy from."),
- overlay: first("button.overlay"),
- copy: first("basic-button.copy"),
- }),
- ({ code, copy }) => ({
- host: toggleAttribute("collapsed"),
- overlay: on("click", () => ({ collapsed: false })),
- copy: copyToClipboard(code, {
- success: copy?.getAttribute("copy-success") || "Copied!",
- error:
- copy?.getAttribute("copy-error") ||
- "Error trying to copy to clipboard!",
- }),
- }),
+ ({ expose, first, host, on, watch }) => {
+ const code = first("code", "Needed as source container to copy from.");
+ const overlay = first("button.overlay");
+ const copy = first("basic-button.copy");
+
+ expose({ collapsed: asBoolean() });
+
+ return [
+ on(overlay, "click", () => ({ collapsed: false })),
+ copy &&
+ copyToClipboard(code, copy as HTMLElement & BasicButtonProps, {
+ success: copy.getAttribute("copy-success") || "Copied!",
+ error:
+ copy.getAttribute("copy-error") ||
+ "Error trying to copy to clipboard!",
+ }),
+
+ watch("collapsed", bindAttribute(host, "collapsed")),
+ ];
+ },
);
diff --git a/src/module/coloreditor/module-coloreditor.css b/src/module/coloreditor/module-coloreditor.css
new file mode 100644
index 0000000..098674b
--- /dev/null
+++ b/src/module/coloreditor/module-coloreditor.css
@@ -0,0 +1,56 @@
+module-coloreditor {
+ display: grid;
+ grid-template-areas:
+ "scale name"
+ "graph graph"
+ "lightness lightness"
+ "chroma chroma"
+ "hue hue"
+ "info info";
+ grid-template-columns: auto 1fr;
+ column-gap: var(--space-m);
+
+ > form-colorgraph {
+ grid-area: graph;
+ }
+
+ > .hue {
+ grid-area: hue;
+ }
+
+ > .lightness {
+ grid-area: lightness;
+ }
+
+ > .chroma {
+ grid-area: chroma;
+ }
+
+ > .scale {
+ grid-area: scale;
+ }
+
+ > .name {
+ grid-area: name;
+ margin: var(--space-s) 0;
+ }
+
+ > .info {
+ grid-area: info;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ }
+}
+
+@container (width > 45rem) {
+ module-coloreditor {
+ grid-template-areas:
+ "scale name info"
+ "graph graph info"
+ "lightness lightness info"
+ "chroma chroma info"
+ "hue hue info";
+ grid-template-columns: auto 3fr 2fr;
+ }
+}
diff --git a/src/module/coloreditor/module-coloreditor.mdx b/src/module/coloreditor/module-coloreditor.mdx
new file mode 100644
index 0000000..06dd062
--- /dev/null
+++ b/src/module/coloreditor/module-coloreditor.mdx
@@ -0,0 +1,139 @@
+import { Meta, Canvas } from '@storybook/addon-docs/blocks';
+import * as ModuleColoreditorStories from './module-coloreditor.stories';
+
+
+
+### Module Color Editor
+
+An orchestrator component that wires together a `` for the color name, a `` color picker, a `` preview, and a series of `` panels for base and derived shades. Demonstrates `pass()` to share signals across multiple child components, derived readonly props via `MemoCallback`, and `on(host, 'change', ...)` to capture name edits from a bubbled input event.
+
+#### Tag Name
+
+`module-coloreditor`
+
+#### Preview
+
+
+
+#### Reactive Properties
+
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+ color
+ Oklch
+ Parsed from color attribute
+ Currently selected color in Oklch color space
+
+
+ name
+ string
+ 'Blue'
+ Display name of the color; updated via a bubbled change event from the name input
+
+
+ nearest
+ string (readonly)
+ ''
+ Nearest named CSS color to the current color; derived via CIEDE2000 difference
+
+
+ lightness
+ number (readonly)
+ 0
+ Derived from color.l
+
+
+ chroma
+ number (readonly)
+ 0
+ Derived from color.c
+
+
+ hue
+ number (readonly)
+ 0
+ Derived from color.h
+
+
+
+
+#### Attributes
+
+
+
+
+ Name
+ Description
+
+
+
+
+ color
+ Oklch color string parsed at connect time via asOklch()
+
+
+ name
+ Initial color name string; default 'Blue'
+
+
+
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('form-textbox')
+ HTMLElement
+ optional
+ Name input; receives value (the color name) and a description showing the nearest CSS color via pass()
+
+
+ first('form-colorgraph')
+ HTMLElement
+ optional
+ Color picker graph; receives color via pass()
+
+
+ first('card-colorscale')
+ HTMLElement
+ optional
+ Color scale preview card; receives color and name via pass()
+
+
+ first('module-colorinfo.base')
+ HTMLElement
+ optional
+ Color info panel for the base (500) shade; receives color and a formatted name via pass()
+
+
+ first('module-colorinfo.lighten20') … first('module-colorinfo.lighten80')
+ HTMLElement
+ optional
+ Color info panels for the four lighter shades (100–400); receive derived colors and names via pass()
+
+
+ first('module-colorinfo.darken20') … first('module-colorinfo.darken80')
+ HTMLElement
+ optional
+ Color info panels for the four darker shades (600–900); receive derived colors and names via pass()
+
+
+
diff --git a/src/module/coloreditor/module-coloreditor.stories.ts b/src/module/coloreditor/module-coloreditor.stories.ts
new file mode 100644
index 0000000..9768564
--- /dev/null
+++ b/src/module/coloreditor/module-coloreditor.stories.ts
@@ -0,0 +1,299 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+import { expect } from "storybook/test";
+import "./module-coloreditor.ts";
+import "./module-coloreditor.css";
+import "../../form/textbox/form-textbox.ts";
+import "../../form/textbox/form-textbox.css";
+import "../../form/colorgraph/form-colorgraph.ts";
+import "../../form/colorgraph/form-colorgraph.css";
+import "../../card/colorscale/card-colorscale.ts";
+import "../../card/colorscale/card-colorscale.css";
+import "../../module/colorinfo/module-colorinfo.ts";
+import "../../module/colorinfo/module-colorinfo.css";
+import "../../basic/number/basic-number.ts";
+import type { ModuleColoreditorProps } from "./module-coloreditor.ts";
+
+const colorInfoBlock = () => html`
+
+
+
+
+
+
+
+
+
+
+
+
+ Lightness:
+
+
+
+ Chroma:
+
+
+
+ Hue:
+
+
+
+
+
+ OKLCH:
+
+ oklch(
+
+ )
+
+ RGB:
+
+ HSL:
+
+
+
+
+`;
+
+const coloreditorTemplate = html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Color name
+
+
+
+
+
+
+
+
+
+
+ Drag
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lightness
+
+
+ %
+
+
+
+ −
+
+
+ +
+
+
+
+
+
+
Chroma
+
+
+
+
+
+ −
+
+
+ +
+
+
+
+
+
+
Hue
+
+
+ °
+
+
+
+ −
+
+
+ +
+
+
+
+
+
+
+
${colorInfoBlock()}
+
${colorInfoBlock()}
+
${colorInfoBlock()}
+
${colorInfoBlock()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Lightness:
+
+
+
+ Chroma:
+
+
+
+ Hue:
+
+
+
+
+
+ OKLCH:
+
+ oklch(
+
+ )
+
+ RGB:
+
+ HSL:
+
+
+
+
+
+
${colorInfoBlock()}
+
${colorInfoBlock()}
+
${colorInfoBlock()}
+
${colorInfoBlock()}
+
+
+`;
+
+const meta: Meta = {
+ title: "Module/Color Editor",
+ render: () => coloreditorTemplate,
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const PropertyChanges: Story = {
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("module-coloreditor");
+ const el = canvasElement.querySelector(
+ "module-coloreditor",
+ ) as HTMLElement & ModuleColoreditorProps;
+
+ await expect(el.name).toBe("Blue");
+ await expect(el.lightness).toBeCloseTo(0.48, 1);
+
+ el.color = { mode: "oklch", l: 0.55, c: 0.22, h: 29 };
+ await expect(el.hue).toBeCloseTo(29, 0);
+
+ const baseInfo = canvasElement.querySelector("module-colorinfo.base") as
+ | (HTMLElement & { hex: string })
+ | null;
+ await expect(baseInfo?.hex).toMatch(/^#[0-9a-f]{6}$/i);
+ },
+};
diff --git a/src/module/coloreditor/module-coloreditor.ts b/src/module/coloreditor/module-coloreditor.ts
new file mode 100644
index 0000000..5f5f45f
--- /dev/null
+++ b/src/module/coloreditor/module-coloreditor.ts
@@ -0,0 +1,89 @@
+import { asString, defineComponent } from "@zeix/le-truc";
+import {
+ colorsNamed,
+ differenceCiede2000,
+ nearest,
+ type Oklch,
+} from "culori/fn";
+import { asOklch } from "../../_common/asOklch";
+import { getStepColor } from "../../_common/getStepColor";
+
+export type ModuleColoreditorProps = {
+ color: Oklch;
+ name: string;
+ readonly nearest: string;
+ readonly lightness: number;
+ readonly chroma: number;
+ readonly hue: number;
+};
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "module-coloreditor": HTMLElement & ModuleColoreditorProps;
+ }
+}
+
+const nearestNamedColor = nearest(
+ Object.keys(colorsNamed),
+ differenceCiede2000(),
+);
+
+export default defineComponent(
+ "module-coloreditor",
+ ({ expose, first, host, on, pass }) => {
+ const textbox = first("form-textbox");
+ const colorgraph = first("form-colorgraph");
+ const colorscale = first("card-colorscale");
+ const colorinfoBase = first("module-colorinfo.base");
+
+ expose({
+ color: asOklch(),
+ name: asString("Blue"),
+ nearest: () => nearestNamedColor(host.color)[0] ?? "",
+ lightness: () => host.color.l,
+ chroma: () => host.color.c,
+ hue: () => host.color.h ?? 0,
+ });
+
+ const effects = [
+ on(host, "change", (event) => {
+ const { target } = event;
+ if (target instanceof HTMLInputElement && target.name === "name")
+ return { name: target.value };
+ }),
+ textbox &&
+ pass(textbox, {
+ value: "name",
+ description: () => `Nearest named CSS color: ${host.nearest}`,
+ }),
+ colorgraph && pass(colorgraph, { color: "color" }),
+ colorscale && pass(colorscale, { color: "color", name: "name" }),
+ colorinfoBase &&
+ pass(colorinfoBase, {
+ color: "color",
+ name: () => `${host.name} 500`,
+ }),
+ ];
+
+ for (let i = 1; i < 5; i++) {
+ const infoLighten = first(`module-colorinfo.lighten${(5 - i) * 20}`);
+ effects.push(
+ pass(infoLighten, {
+ color: () => getStepColor(host.color, 1 - i / 10),
+ name: () => `${host.name} ${i * 100}`,
+ }),
+ );
+ }
+ for (let i = 1; i < 5; i++) {
+ const infoDarken = first(`module-colorinfo.darken${i * 20}`);
+ effects.push(
+ pass(infoDarken, {
+ color: () => getStepColor(host.color, 1 - (i + 5) / 10),
+ name: () => `${host.name} ${(i + 5) * 100}`,
+ }),
+ );
+ }
+
+ return effects;
+ },
+);
diff --git a/src/module/colorinfo/module-colorinfo.css b/src/module/colorinfo/module-colorinfo.css
new file mode 100644
index 0000000..f127393
--- /dev/null
+++ b/src/module/colorinfo/module-colorinfo.css
@@ -0,0 +1,103 @@
+module-colorinfo {
+ --swatch-size: var(--input-height);
+ --color-fallback: transparent;
+
+ display: inline-flex;
+ gap: var(--space-l);
+
+ & summary {
+ cursor: pointer;
+ margin: 0 0 var(--space-s);
+
+ &::marker {
+ color: var(--color-text-soft);
+ }
+ }
+
+ & details[open] summary {
+ margin-bottom: var(--space-xs);
+ }
+
+ .summary {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-s);
+ margin-left: var(--space-xs);
+ vertical-align: middle;
+ }
+
+ .swatch {
+ position: relative;
+ display: inline-block;
+ background: var(--color-swatch);
+ width: var(--swatch-size);
+ height: var(--swatch-size);
+ border-radius: var(--space-xxs);
+ overflow: hidden;
+
+ &::before {
+ position: absolute;
+ content: "";
+ display: block;
+ width: 0;
+ height: 0;
+ border-left: var(--swatch-size) solid transparent;
+ border-bottom: var(--swatch-size) solid var(--color-fallback);
+ }
+ }
+
+ .label {
+ display: inline-block;
+ line-height: 1;
+ }
+
+ & strong,
+ & small {
+ display: block;
+ }
+
+ & strong {
+ font-size: var(--font-size-m);
+ }
+
+ & small {
+ color: var(--color-text-soft);
+ font-size: var(--font-size-xs);
+ }
+
+ .details {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 var(--space-m);
+ margin-left: var(--space-l);
+ }
+
+ & dl {
+ margin: 0 0 var(--space-s);
+ display: inline-grid;
+ grid-template-rows: auto auto;
+ gap: var(--space-xxs) var(--space-xs);
+
+ &:last-of-type dt {
+ display: none;
+ }
+ }
+
+ & dt {
+ grid-column: 1;
+ color: var(--color-text-soft);
+ display: inline-block;
+ text-align: right;
+ font-size: var(--font-size-s);
+ font-weight: 400;
+ line-height: var(--line-height-s);
+ }
+
+ & dd {
+ grid-column: 2;
+ display: inline-block;
+ margin: 0;
+ font-size: var(--font-size-s);
+ line-height: var(--line-height-s);
+ }
+}
diff --git a/src/module/colorinfo/module-colorinfo.mdx b/src/module/colorinfo/module-colorinfo.mdx
new file mode 100644
index 0000000..56fe6a8
--- /dev/null
+++ b/src/module/colorinfo/module-colorinfo.mdx
@@ -0,0 +1,163 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as ModuleColorinfoStories from './module-colorinfo.stories';
+
+
+
+### Module Color Info
+
+A color information panel that displays a color's name, swatch, and format conversions (CSS Oklch, HEX, RGB, HSL). Demonstrates multiple derived readonly props via `MemoCallback`, `bindStyle()` for the swatch CSS custom properties, `pass()` to drive a collection of `` children via `all()`, and `bindText()` for the format string elements.
+
+#### Tag Name
+
+`module-colorinfo`
+
+#### Preview
+
+
+
+#### Controls
+
+
+
+#### Reactive Properties
+
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+ name
+ string
+ Text content of .label strong
+ Display name of the color shade
+
+
+ color
+ Oklch
+ Parsed from color attribute
+ Color in Oklch color space; drives all derived props and the swatch
+
+
+ css
+ string (readonly)
+ ''
+ CSS oklch() string for color; set as --color-swatch on the host
+
+
+ hex
+ string (readonly)
+ ''
+ Hex string for color; set as --color-fallback on the host
+
+
+ rgb
+ string (readonly)
+ ''
+ CSS rgb() string for color
+
+
+ hsl
+ string (readonly)
+ ''
+ CSS hsl() string for color
+
+
+ lightness
+ number (readonly)
+ 0
+ Derived from color.l
+
+
+ chroma
+ number (readonly)
+ 0
+ Derived from color.c
+
+
+ hue
+ number (readonly)
+ 0
+ Derived from color.h
+
+
+
+
+#### Attributes
+
+
+
+
+ Name
+ Description
+
+
+
+
+ color
+ Oklch color string parsed at connect time via asOklch()
+
+
+
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('.label strong')
+ HTMLElement
+ required
+ Displays the color name; bound to name via bindText()
+
+
+ first('.hex')
+ HTMLElement
+ optional
+ Displays the hex string; bound to hex via bindText()
+
+
+ first('.rgb')
+ HTMLElement
+ optional
+ Displays the RGB string; bound to rgb via bindText()
+
+
+ first('.hsl')
+ HTMLElement
+ optional
+ Displays the HSL string; bound to hsl via bindText()
+
+
+ all('basic-number.lightness')
+ Memo<HTMLElement[]>
+ optional
+ <basic-number> elements displaying the lightness value; receive lightness via pass()
+
+
+ all('basic-number.chroma')
+ Memo<HTMLElement[]>
+ optional
+ <basic-number> elements displaying the chroma value; receive chroma via pass()
+
+
+ all('basic-number.hue')
+ Memo<HTMLElement[]>
+ optional
+ <basic-number> elements displaying the hue value; receive hue via pass()
+
+
+
diff --git a/src/module/colorinfo/module-colorinfo.stories.ts b/src/module/colorinfo/module-colorinfo.stories.ts
new file mode 100644
index 0000000..c04fd4d
--- /dev/null
+++ b/src/module/colorinfo/module-colorinfo.stories.ts
@@ -0,0 +1,138 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+import { expect } from "storybook/test";
+import "./module-colorinfo.ts";
+import "./module-colorinfo.css";
+import "../../basic/number/basic-number.ts";
+import type { ModuleColorinfoProps } from "./module-colorinfo.ts";
+
+type ModuleColorinfoArgs = {
+ name: string;
+ color: string;
+};
+
+const render = ({ name, color }: ModuleColorinfoArgs) => html`
+
+
+
+
+
+
+ ${name}
+
+
+
+
+
+
+ Lightness:
+
+
+
+ Chroma:
+
+
+
+ Hue:
+
+
+
+
+
+ OKLCH:
+
+ oklch(
+
+ )
+
+ RGB:
+
+ HSL:
+
+
+
+
+
+`;
+
+const meta: Meta = {
+ title: "Module/Color Info",
+ render,
+ argTypes: {
+ name: {
+ control: "text",
+ table: {
+ defaultValue: { summary: "Blue" },
+ category: "Reactive Properties",
+ },
+ },
+ color: {
+ control: "text",
+ description: "Oklch color string parsed at connect time",
+ table: {
+ defaultValue: { summary: "oklch(.48 .23 263)" },
+ category: "Attributes",
+ },
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ name: "Blue",
+ color: "oklch(.48 .23 263)",
+ },
+};
+
+export const Red: Story = {
+ args: {
+ name: "Red",
+ color: "oklch(.55 .22 29)",
+ },
+};
+
+export const PropertyChanges: Story = {
+ args: {
+ name: "Blue",
+ color: "oklch(.48 .23 263)",
+ },
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("module-colorinfo");
+ const el = canvasElement.querySelector(
+ "module-colorinfo",
+ ) as HTMLElement & ModuleColorinfoProps;
+ const hexEl = canvasElement.querySelector(".hex");
+
+ await expect(hexEl?.textContent?.trim()).toMatch(/^#[0-9a-f]{6}$/i);
+
+ const initialHex = el.hex;
+ el.color = { mode: "oklch", l: 0.7, c: 0.15, h: 120 };
+ await expect(el.hex).not.toBe(initialHex);
+ await expect(hexEl?.textContent?.trim()).toMatch(/^#[0-9a-f]{6}$/i);
+
+ el.name = "Green";
+ const labelStrong = canvasElement.querySelector(".label strong");
+ await expect(labelStrong).toHaveTextContent("Green");
+ },
+};
diff --git a/src/module/colorinfo/module-colorinfo.ts b/src/module/colorinfo/module-colorinfo.ts
new file mode 100644
index 0000000..edad282
--- /dev/null
+++ b/src/module/colorinfo/module-colorinfo.ts
@@ -0,0 +1,69 @@
+import { bindStyle, bindText, defineComponent } from "@zeix/le-truc";
+import "culori/css";
+import {
+ formatCss,
+ formatHex,
+ formatHsl,
+ formatRgb,
+ type Oklch,
+} from "culori/fn";
+import { asOklch } from "../../_common/asOklch";
+
+export type ModuleColorinfoProps = {
+ name: string;
+ color: Oklch;
+ readonly css: string;
+ readonly hex: string;
+ readonly rgb: string;
+ readonly hsl: string;
+ readonly lightness: number;
+ readonly chroma: number;
+ readonly hue: number;
+};
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "module-colorinfo": HTMLElement & ModuleColorinfoProps;
+ }
+}
+
+export default defineComponent(
+ "module-colorinfo",
+ ({ all, expose, first, host, pass, watch }) => {
+ const labelStrong = first(
+ ".label strong",
+ "Add a element inside .label.",
+ );
+ const hexEl = first(".hex");
+ const rgbEl = first(".rgb");
+ const hslEl = first(".hsl");
+ const lightnessEls = all("basic-number.lightness");
+ const chromaEls = all("basic-number.chroma");
+ const hueEls = all("basic-number.hue");
+
+ expose({
+ name: labelStrong.textContent?.trim() ?? "",
+ color: asOklch(),
+ css: () => formatCss(host.color),
+ hex: () => formatHex(host.color),
+ rgb: () => formatRgb(host.color) ?? "",
+ hsl: () => formatHsl(host.color) ?? "",
+ lightness: () => host.color.l,
+ chroma: () => host.color.c,
+ hue: () => host.color.h ?? 0,
+ });
+
+ return [
+ pass(lightnessEls, { value: "lightness" }),
+ pass(chromaEls, { value: "chroma" }),
+ pass(hueEls, { value: "hue" }),
+
+ watch("css", bindStyle(host, "--color-swatch")),
+ watch("hex", bindStyle(host, "--color-fallback")),
+ watch("name", bindText(labelStrong, true)),
+ hexEl && watch("hex", bindText(hexEl, true)),
+ rgbEl && watch("rgb", bindText(rgbEl, true)),
+ hslEl && watch("hsl", bindText(hslEl, true)),
+ ];
+ },
+);
diff --git a/src/module/dialog/module-dialog.css b/src/module/dialog/module-dialog.css
index b3d065c..9945ac0 100644
--- a/src/module/dialog/module-dialog.css
+++ b/src/module/dialog/module-dialog.css
@@ -1,97 +1,97 @@
/* Exception to scoping rule: class on body for scroll lock */
body.scroll-lock {
- position: fixed;
- overflow-y: hidden;
+ position: fixed;
+ overflow-y: hidden;
}
module-dialog {
- > button {
- border: 0;
- padding: 0;
- background: none;
- cursor: pointer;
- color: var(--color-text);
- }
+ > button {
+ border: 0;
+ padding: 0;
+ background: none;
+ cursor: pointer;
+ color: var(--color-text);
+ }
- & dialog {
- display: none;
- flex-direction: column;
- border: 0;
- padding: 0;
- margin: auto 0;
- width: 100vw;
- max-width: 100%;
- max-height: 100dvh;
- color: var(--color-text);
- background: var(--color-background);
- opacity: var(--opacity-transparent);
- transition: opacity var(--transition-medium) var(--easing-inout);
+ & dialog {
+ display: none;
+ flex-direction: column;
+ border: 0;
+ padding: 0;
+ margin: auto 0;
+ width: 100vw;
+ max-width: 100%;
+ max-height: 100dvh;
+ color: var(--color-text);
+ background: var(--color-background);
+ opacity: var(--opacity-transparent);
+ transition: opacity var(--transition-medium) var(--easing-inout);
- &::backdrop {
- backdrop-filter: blur(0);
- transition: backdrop-filter var(--transition-medium) var(--easing-inout);
- background-color: transparent;
- }
- }
+ &::backdrop {
+ backdrop-filter: blur(0);
+ transition: backdrop-filter var(--transition-medium) var(--easing-inout);
+ background-color: transparent;
+ }
+ }
- & dialog[open] {
- display: flex;
- opacity: var(--opacity-solid);
+ & dialog[open] {
+ display: flex;
+ opacity: var(--opacity-solid);
- > header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin: 0;
- padding: 0 0 0 var(--space-l);
+ > header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0;
+ padding: 0 0 0 var(--space-l);
- > h2 {
- font-size: var(--font-size-l);
- font-weight: var(--font-weight-bold);
- margin: var(--space-s) 0;
- }
+ > h2 {
+ font-size: var(--font-size-l);
+ font-weight: var(--font-weight-bold);
+ margin: var(--space-s) 0;
+ }
- .close {
- border: 0;
- background: none;
- cursor: pointer;
- font-size: var(--font-size-l);
- line-height: var(--line-height-xs);
- margin: var(--space-s) var(--space-s) var(--space-s) var(--space-m);
- padding: 0 0 var(--space-xxs);
- color: var(--color-primary);
- border-radius: var(--space-xxs);
- box-sizing: border-box;
- height: var(--input-height);
- width: var(--input-height);
+ .close {
+ border: 0;
+ background: none;
+ cursor: pointer;
+ font-size: var(--font-size-l);
+ line-height: var(--line-height-xs);
+ margin: var(--space-s) var(--space-s) var(--space-s) var(--space-m);
+ padding: 0 0 var(--space-xxs);
+ color: var(--color-primary);
+ border-radius: var(--space-xxs);
+ box-sizing: border-box;
+ height: var(--input-height);
+ width: var(--input-height);
- &:hover {
- color: var(--color-primary-hover);
- }
+ &:hover {
+ color: var(--color-primary-hover);
+ }
- &:active {
- color: var(--color-primary-active);
- }
- }
- }
+ &:active {
+ color: var(--color-primary-active);
+ }
+ }
+ }
- .content {
- padding: 0 var(--space-l) 0;
- }
+ .content {
+ padding: 0 var(--space-l) 0;
+ }
- &::backdrop {
- backdrop-filter: blur(1rem);
- background-color: var(--color-shadow);
- }
- }
+ &::backdrop {
+ backdrop-filter: blur(1rem);
+ background-color: var(--color-shadow);
+ }
+ }
}
@media (min-width: 48em) {
- module-dialog dialog[open] {
- width: min(var(--content-max-width), calc(100% - 2 * var(--space-l)));
- max-height: calc(100dvh - 2rem);
- border-radius: var(--space-s);
- box-shadow: 0 0 var(--space-s) var(--color-shadow);
- margin: auto auto;
- }
+ module-dialog dialog[open] {
+ width: min(var(--content-max-width), calc(100% - 2 * var(--space-l)));
+ max-height: calc(100dvh - 2rem);
+ border-radius: var(--space-s);
+ box-shadow: 0 0 var(--space-s) var(--color-shadow);
+ margin: auto auto;
+ }
}
diff --git a/src/module/dialog/module-dialog.stories.ts b/src/module/dialog/module-dialog.stories.ts
index 0126d79..d2aaec8 100644
--- a/src/module/dialog/module-dialog.stories.ts
+++ b/src/module/dialog/module-dialog.stories.ts
@@ -7,7 +7,6 @@ import "../../basic/button/basic-button.ts";
import "../../basic/button/basic-button.css";
import "../../module/scrollarea/module-scrollarea.ts";
import "../../module/scrollarea/module-scrollarea.css";
-import type { Component } from "@zeix/le-truc";
import type { ModuleDialogProps } from "./module-dialog.ts";
type ModuleDialogArgs = {
@@ -29,8 +28,14 @@ const render = ({ open }: ModuleDialogArgs) => html`
@@ -65,9 +70,8 @@ export const OpenClose: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-dialog");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "module-dialog",
- ) as Component;
+ const el = canvasElement.querySelector("module-dialog") as HTMLElement &
+ ModuleDialogProps;
await expect(el.open).toBe(false);
@@ -85,9 +89,8 @@ export const PropertyChanges: Story = {
args: { open: false },
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-dialog");
- const el = canvasElement.querySelector(
- "module-dialog",
- ) as Component;
+ const el = canvasElement.querySelector("module-dialog") as HTMLElement &
+ ModuleDialogProps;
await expect(el.open).toBe(false);
diff --git a/src/module/dialog/module-dialog.ts b/src/module/dialog/module-dialog.ts
index 4cc6ae5..7179d07 100644
--- a/src/module/dialog/module-dialog.ts
+++ b/src/module/dialog/module-dialog.ts
@@ -1,85 +1,69 @@
-import {
- type Component,
- createEffect,
- defineComponent,
- on,
-} from "@zeix/le-truc";
+import { defineComponent } from "@zeix/le-truc";
export type ModuleDialogProps = {
open: boolean;
};
-type ModuleDialogUI = {
- openButton: HTMLButtonElement;
- dialog: HTMLDialogElement;
- closeButton: HTMLButtonElement;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "module-dialog": Component;
+ "module-dialog": HTMLElement & ModuleDialogProps;
}
}
const SCROLL_LOCK_CLASS = "scroll-lock";
-export default defineComponent(
+export default defineComponent(
"module-dialog",
- {
- open: false,
- },
- ({ first }) => ({
- openButton: first(
+ ({ expose, first, on, watch }) => {
+ const openButton = first(
'button[aria-haspopup="dialog"]',
"Add a button to open the dialog.",
- ),
- dialog: first("dialog", "Add a native dialog element."),
- closeButton: first(
+ );
+ const dialog = first("dialog", "Add a native dialog element.");
+ const closeButton = first(
"dialog button.close",
"Add a close button in the dialog.",
- ),
- }),
- ({ host, dialog, closeButton }) => {
+ );
let scrollTop = 0;
let activeElement: HTMLElement | null = null;
- return {
- host: () =>
- createEffect(() => {
- if (host.open) {
- scrollTop = document.documentElement.scrollTop;
- activeElement = document.activeElement as HTMLElement | null;
- dialog.showModal();
- document.body.classList.add(SCROLL_LOCK_CLASS);
- document.body.style.setProperty("top", `-${scrollTop}px`);
- closeButton.focus();
- } else {
- document.body.classList.remove(SCROLL_LOCK_CLASS);
- window.scrollTo({
- top: scrollTop,
- left: 0,
- behavior: "instant",
- });
- document.body.style.removeProperty("top");
- dialog.close();
- if (activeElement) activeElement.focus();
- }
- return () => {
- document.body.classList.remove(SCROLL_LOCK_CLASS);
- document.body.style.removeProperty("top");
- dialog.close();
- };
- }),
- openButton: on("click", () => ({ open: true })),
- closeButton: on("click", () => ({ open: false })),
- dialog: [
- on("click", ({ target }) => {
- if (target === dialog) host.open = false;
- }),
- on("keydown", ({ key }) => {
- if (key === "Escape") host.open = false;
- }),
- ],
- };
+ expose({ open: false });
+
+ return [
+ on(openButton, "click", () => ({ open: true })),
+ on(closeButton, "click", () => ({ open: false })),
+ on(dialog, "click", ({ target }) => target === dialog && { open: false }),
+ on(dialog, "keydown", (event) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ return { open: false };
+ }),
+
+ watch("open", (open) => {
+ if (open) {
+ scrollTop = document.documentElement.scrollTop;
+ activeElement = document.activeElement as HTMLElement | null;
+ dialog.showModal();
+ document.body.classList.add(SCROLL_LOCK_CLASS);
+ document.body.style.setProperty("top", `-${scrollTop}px`);
+ closeButton.focus();
+ } else {
+ document.body.classList.remove(SCROLL_LOCK_CLASS);
+ window.scrollTo({
+ top: scrollTop,
+ left: 0,
+ behavior: "instant",
+ });
+ document.body.style.removeProperty("top");
+ dialog.close();
+ if (activeElement) activeElement.focus();
+ }
+ return () => {
+ document.body.classList.remove(SCROLL_LOCK_CLASS);
+ document.body.style.removeProperty("top");
+ dialog.close();
+ };
+ }),
+ ];
},
);
diff --git a/src/module/lazyload/module-lazyload.stories.ts b/src/module/lazyload/module-lazyload.stories.ts
index 73f948c..7ec5b40 100644
--- a/src/module/lazyload/module-lazyload.stories.ts
+++ b/src/module/lazyload/module-lazyload.stories.ts
@@ -3,7 +3,6 @@ import { html, nothing } from "lit";
import { expect, waitFor } from "storybook/test";
import "./module-lazyload.ts";
import "../../card/callout/card-callout.css";
-import type { Component } from "@zeix/le-truc";
import type { ModuleLazyloadProps } from "./module-lazyload.ts";
type ModuleLazyloadArgs = {
@@ -11,7 +10,10 @@ type ModuleLazyloadArgs = {
"allow-scripts": boolean;
};
-const render = ({ src, "allow-scripts": allowScripts }: ModuleLazyloadArgs) => html`
+const render = ({
+ src,
+ "allow-scripts": allowScripts,
+}: ModuleLazyloadArgs) => html`
Loading...
@@ -58,9 +60,8 @@ export const WithContent: Story = {
},
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-lazyload");
- const el = canvasElement.querySelector(
- "module-lazyload",
- ) as Component;
+ const el = canvasElement.querySelector("module-lazyload") as HTMLElement &
+ ModuleLazyloadProps;
const content = canvasElement.querySelector(".content");
await waitFor(() => expect(content).toBeVisible());
@@ -72,9 +73,8 @@ export const NoSrc: Story = {
args: { src: "", "allow-scripts": false },
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-lazyload");
- const el = canvasElement.querySelector(
- "module-lazyload",
- ) as Component;
+ const el = canvasElement.querySelector("module-lazyload") as HTMLElement &
+ ModuleLazyloadProps;
await expect(el.src).toBe("");
},
@@ -84,9 +84,8 @@ export const InvalidURL: Story = {
args: { src: "not-a-valid-url", "allow-scripts": false },
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-lazyload");
- const el = canvasElement.querySelector(
- "module-lazyload",
- ) as Component;
+ const el = canvasElement.querySelector("module-lazyload") as HTMLElement &
+ ModuleLazyloadProps;
const errorEl = canvasElement.querySelector(".error");
await expect(el.src).toBe("not-a-valid-url");
@@ -98,9 +97,8 @@ export const PropertyChanges: Story = {
args: { src: "", "allow-scripts": false },
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-lazyload");
- const el = canvasElement.querySelector(
- "module-lazyload",
- ) as Component;
+ const el = canvasElement.querySelector("module-lazyload") as HTMLElement &
+ ModuleLazyloadProps;
await expect(el.src).toBe("");
diff --git a/src/module/lazyload/module-lazyload.ts b/src/module/lazyload/module-lazyload.ts
index 63ad15b..ef914c5 100644
--- a/src/module/lazyload/module-lazyload.ts
+++ b/src/module/lazyload/module-lazyload.ts
@@ -1,93 +1,88 @@
import {
asString,
- type Component,
createTask,
- dangerouslySetInnerHTML,
+ dangerouslyBindInnerHTML,
defineComponent,
- setText,
- show,
- toggleClass,
} from "@zeix/le-truc";
import {
fetchWithCache,
isRecursiveURL,
isValidURL,
-} from "../../_common/fetch";
+} from "../../_common/fetchWithCache";
export type ModuleLazyloadProps = {
src: string;
};
-type ModuleLazyloadUI = Record<
- "callout" | "loading" | "error" | "content",
- HTMLElement
->;
-
declare global {
interface HTMLElementTagNameMap {
- "module-lazyload": Component;
+ "module-lazyload": HTMLElement & ModuleLazyloadProps;
}
}
-export default defineComponent(
+export default defineComponent(
"module-lazyload",
- {
- src: asString(),
- },
- ({ first }) => ({
- callout: first(
+ ({ expose, first, host, watch }) => {
+ const callout = first(
"card-callout",
"Needed to display loading state and error messages.",
- ),
- loading: first(".loading", "Needed to display loading state."),
- error: first(".error", "Needed to display error messages."),
- content: first(".content", "Needed to display content."),
- }),
- (ui) => {
- const { host } = ui;
- const result = createTask<{
- ok: boolean;
- value: string;
- error: string;
- pending: boolean;
- }>(
- async (_prev, abort) => {
- const url = host.src;
- const error = !url
- ? "No URL provided"
- : !isValidURL(url)
- ? "Invalid URL"
- : isRecursiveURL(url, host)
- ? "Recursive URL detected"
- : "";
- if (error) return { ok: false, value: "", error, pending: false };
-
- try {
- const { content } = await fetchWithCache(url, abort);
- return { ok: true, value: content, error: "", pending: false };
- } catch (error) {
- return {
- ok: false,
- value: "",
- error: `Failed to fetch content for "${url}": ${String(error)}`,
- pending: false,
- };
- }
- },
- { value: { ok: false, value: "", error: "", pending: true } },
);
- const hasError = () => !!result.get().error;
+ const loading = first(".loading", "Needed to display loading state.");
+ const errorEl = first(".error", "Needed to display error messages.");
+ const contentEl = first(".content", "Needed to display content.");
+
+ const content = createTask(async (_prev, abort) => {
+ const url = host.src;
+ if (!url) throw new Error("No URL provided");
+ if (!isValidURL(url)) throw new Error("Invalid URL");
+ if (isRecursiveURL(url, host)) throw new Error("Recursive URL detected");
+ try {
+ const { content: fetched } = await fetchWithCache(url, abort);
+ return fetched;
+ } catch (e) {
+ throw new Error(`Failed to fetch content for "${url}": ${String(e)}`);
+ }
+ });
- return {
- callout: [show(() => !result.get().ok), toggleClass("danger", hasError)],
- loading: show(() => !!result.get().pending),
- error: [show(hasError), setText(() => result.get().error ?? "")],
- content: [
- show(() => result.get().ok),
- dangerouslySetInnerHTML(() => result.get().value ?? "", {
- allowScripts: host.hasAttribute("allow-scripts"),
- }),
- ],
- };
+ const { ok: setHTML } = dangerouslyBindInnerHTML(contentEl, {
+ allowScripts: host.hasAttribute("allow-scripts"),
+ });
+
+ expose({ src: asString() });
+
+ return [
+ watch(content, {
+ ok: (content) => {
+ callout.hidden = true;
+ loading.hidden = true;
+ contentEl.hidden = false;
+ setHTML(content);
+ },
+ nil: () => {
+ callout.hidden = false;
+ loading.hidden = false;
+ contentEl.hidden = true;
+ },
+ stale: () => {
+ contentEl.style.setProperty("opacity", "var(--opacity-dimmed)");
+ return () => {
+ contentEl.style.removeProperty("opacity");
+ };
+ },
+ err: (error) => {
+ callout.hidden = false;
+ callout.classList.add("danger");
+ loading.hidden = true;
+ errorEl.hidden = false;
+ errorEl.textContent = error.message;
+ contentEl.hidden = true;
+ return () => {
+ callout.classList.remove("danger");
+ errorEl.hidden = true;
+ errorEl.textContent = "";
+ };
+ },
+ }),
+ ];
},
);
diff --git a/src/module/list/module-list.css b/src/module/list/module-list.css
deleted file mode 100644
index ceea2d8..0000000
--- a/src/module/list/module-list.css
+++ /dev/null
@@ -1,43 +0,0 @@
-module-list {
- display: flex;
- flex-direction: column;
- gap: var(--space-l);
-
- & form {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: var(--space-m);
- justify-content: space-between;
- }
-
- ol,
- ul {
- list-style: none;
- padding: 0;
- margin: 0;
- gap: 0;
-
- &:empty {
- display: none;
- }
- }
-
- & li {
- display: flex;
- align-items: center;
- gap: var(--space-m);
- padding: var(--space-s) 0;
- border-bottom: 1px solid var(--color-border-soft);
- justify-content: space-between;
- }
-}
-
-@container (width > 32rem) {
- module-list {
- & form {
- flex-direction: row;
- align-items: flex-end;
- }
- }
-}
diff --git a/src/module/list/module-list.mdx b/src/module/list/module-list.mdx
deleted file mode 100644
index dea0b98..0000000
--- a/src/module/list/module-list.mdx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Meta, Canvas } from '@storybook/addon-docs/blocks';
-import * as ModuleListStories from './module-list.stories';
-
-
-
-### Module List
-
-A dynamic list with add and delete functionality. Exposes `add(process?)` and `delete(key)` as reactive methods via `asMethod()`. The `add` method clones the ``, assigns a `data-key`, optionally processes the item, and appends it to the container. Delete buttons are handled via event delegation on the host. A `max` attribute limits the number of items; the add button is disabled at the limit.
-
-#### Tag Name
-
-`module-list`
-
-#### Preview
-
-
-
-#### Reactive Properties (Methods)
-
-
-
-
- Name
- Signature
- Description
-
-
-
-
- add
- (process?: (item: HTMLElement) => void) => void
- Clones the template and appends a new item; optional process callback customises the item before insertion
-
-
- delete
- (key: string) => void
- Removes the item with the matching data-key from the container
-
-
-
-
-#### Attributes
-
-
-
-
- Name
- Type
- Default
- Description
-
-
-
-
- max
- integer
- 1000
- Maximum number of items allowed; the add button is disabled when the limit is reached
-
-
-
-
-#### Descendant Elements
-
-
-
-
- Selector
- Type
- Required
- Description
-
-
-
-
- first('[data-container]')
- HTMLElement
- required
- Container where cloned items are appended
-
-
- first('template')
- HTMLTemplateElement
- required
- Template for each list item; must contain a single root element
-
-
- first('form')
- HTMLFormElement
- optional
- Submit handler calls host.add() with the textbox value
-
-
- first('form-textbox')
- Component<FormTextboxProps>
- optional
- Text input for the new item; cleared after successful add
-
-
- first('basic-button.add')
- Component<BasicButtonProps>
- optional
- Add button; disabled when textbox is empty or the max is reached
-
-
- basic-button.delete (inside items)
- Component<BasicButtonProps>
- optional
- Delete button per item; handled via event delegation on the host
-
-
-
diff --git a/src/module/list/module-list.stories.ts b/src/module/list/module-list.stories.ts
deleted file mode 100644
index dee0114..0000000
--- a/src/module/list/module-list.stories.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/web-components";
-import { html } from "lit";
-import { expect, userEvent, within } from "storybook/test";
-import "./module-list.ts";
-import "./module-list.css";
-import "../../basic/button/basic-button.ts";
-import "../../basic/button/basic-button.css";
-import "../../form/textbox/form-textbox.ts";
-import "../../form/textbox/form-textbox.css";
-
-type ModuleListArgs = {
- max: number;
-};
-
-const render = ({ max }: ModuleListArgs) => html`
-
-
-
-
-
-
- Remove
-
-
-
-
-
-`;
-
-const meta: Meta = {
- title: "Module/List",
- render,
- argTypes: {
- max: {
- control: "number",
- table: {
- defaultValue: { summary: "1000" },
- category: "Attributes",
- },
- },
- },
-};
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {
- args: {
- max: 1000,
- },
-};
-
-export const AddItem: Story = {
- args: { max: 1000 },
- play: async ({ canvasElement }) => {
- await customElements.whenDefined("module-list");
- const canvas = within(canvasElement);
- const input = canvas.getByLabelText("New item");
- const addButton = canvas.getByRole("button", { name: "Add" });
- const container = canvasElement.querySelector("[data-container]");
-
- await expect(addButton).toBeDisabled();
-
- await userEvent.type(input, "Buy groceries");
- await expect(addButton).not.toBeDisabled();
-
- await userEvent.click(addButton);
- await expect(container?.children.length).toBe(1);
-
- await userEvent.type(input, "Walk the dog");
- await userEvent.click(addButton);
- await expect(container?.children.length).toBe(2);
- },
-};
-
-// ⚠️ Custom render: pre-populates the container with existing items to test that the component recognises them
-export const WithInitialItems: Story = {
- render: () => html`
-
-
-
- Existing item 1
-
- Remove
-
-
-
- Existing item 2
-
- Remove
-
-
-
-
-
-
-
- Remove
-
-
-
-
-
- `,
-};
-
-// ⚠️ Custom render: pre-populates the container at the max limit to verify the add button starts disabled
-export const WithMax: Story = {
- render: () => html`
-
-
-
- Item 1
-
- Remove
-
-
-
- Item 2
-
- Remove
-
-
-
- Item 3
-
- Remove
-
-
-
-
-
-
-
- Remove
-
-
-
-
-
- `,
- play: async ({ canvasElement }) => {
- await customElements.whenDefined("module-list");
- const canvas = within(canvasElement);
- const addButton = canvas.getByRole("button", { name: "Add" });
-
- await expect(addButton).toBeDisabled();
- },
-};
diff --git a/src/module/list/module-list.ts b/src/module/list/module-list.ts
deleted file mode 100644
index 8b655f6..0000000
--- a/src/module/list/module-list.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import {
- asInteger,
- asMethod,
- type Component,
- defineComponent,
- MissingElementError,
- on,
- pass,
-} from "@zeix/le-truc";
-import type { BasicButtonProps } from "../../basic/button/basic-button";
-import type { FormTextboxProps } from "../../form/textbox/form-textbox";
-
-export type ModuleListProps = {
- add: (process?: (item: HTMLElement) => void) => void;
- delete: (key: string) => void;
-};
-
-type ModuleListUI = {
- container: HTMLElement;
- template: HTMLTemplateElement;
- form?: HTMLFormElement | undefined;
- textbox?: Component | undefined;
- add?: Component | undefined;
-};
-
-declare global {
- interface HTMLElementTagNameMap {
- "module-list": Component;
- }
-}
-
-const MAX_ITEMS = 1000;
-
-export default defineComponent(
- "module-list",
- {
- add: asMethod(({ host, container, template }) => {
- let key = 0;
- host.add = (process?: (item: HTMLElement) => void) => {
- const item = (template.content.cloneNode(true) as DocumentFragment)
- .firstElementChild;
- if (item && item instanceof HTMLElement) {
- item.dataset.key = String(key++);
- if (process) process(item);
- container.append(item);
- } else {
- throw new MissingElementError(
- host,
- "*",
- "Template does not contain an item element.",
- );
- }
- };
- }),
- delete: asMethod(({ host, container }) => {
- host.delete = (key: string) => {
- const item = container.querySelector(`[data-key="${key}"]`);
- if (item) item.remove();
- };
- }),
- },
- ({ first }) => ({
- container: first("[data-container]", "Add a container element for items."),
- template: first("template", "Add a template element for items."),
- form: first("form"),
- textbox: first("form-textbox"),
- add: first("basic-button.add"),
- }),
- (ui) => {
- const { host, container, textbox } = ui;
- const max = asInteger(MAX_ITEMS)(ui, host.getAttribute("max"));
-
- return {
- form: on("submit", (e) => {
- e.preventDefault();
- const content = textbox?.value;
- if (content) {
- host.add((item) => {
- item.querySelector("slot")?.replaceWith(content);
- });
- textbox.clear();
- }
- }),
- add: pass({
- disabled: () =>
- (textbox && !textbox.length) || container.children.length >= max,
- }),
- host: on("click", (e) => {
- const { target } = e;
- if (
- target instanceof HTMLElement &&
- target.closest("basic-button.delete")
- ) {
- e.stopPropagation();
- target.closest("[data-key]")?.remove();
- }
- }),
- };
- },
-);
diff --git a/src/module/listnav/module-listnav.css b/src/module/listnav/module-listnav.css
index 1f47ea1..5e5bab9 100644
--- a/src/module/listnav/module-listnav.css
+++ b/src/module/listnav/module-listnav.css
@@ -5,7 +5,7 @@ module-listnav module-lazyload {
@media screen and (min-width: 48em) {
module-listnav {
display: grid;
- grid-template-columns: 1fr 2fr;
+ grid-template-columns: 1fr 3fr;
gap: var(--space-xl);
& module-lazyload {
diff --git a/src/module/listnav/module-listnav.ts b/src/module/listnav/module-listnav.ts
index a9113ff..4e64256 100644
--- a/src/module/listnav/module-listnav.ts
+++ b/src/module/listnav/module-listnav.ts
@@ -1,18 +1,4 @@
-import {
- batch,
- type Component,
- type ComponentProps,
- createEffect,
- defineComponent,
- pass,
-} from "@zeix/le-truc";
-import type { FormListboxProps } from "../../form/listbox/form-listbox";
-import type { ModuleLazyloadProps } from "../lazyload/module-lazyload";
-
-type ModuleListnavUI = {
- listbox: Component;
- lazyload: Component;
-};
+import { batch, createEffect, defineComponent } from "@zeix/le-truc";
/**
* Extract the base path (first path segment) from an option value.
@@ -76,66 +62,61 @@ const valueToHash = (value: string, listbox: HTMLElement): string => {
return hash;
};
-export default defineComponent(
- "module-listnav",
- {},
- ({ first }) => ({
- listbox: first("form-listbox", "Required to select a partial to load"),
- lazyload: first("module-lazyload", "Required to load a partial into"),
- }),
- ({ listbox }) => {
- const hasOption = (value: string): boolean =>
- !!listbox.querySelector(
- `button[role="option"][value="${CSS.escape(value)}"]`,
- );
-
- // Set initial selection from hash
- if (location.hash) {
- const value = hashToValue(location.hash, listbox);
- if (value && hasOption(value)) listbox.value = value;
+export default defineComponent("module-listnav", ({ first, pass }) => {
+ const listbox = first("form-listbox", "Required to select a partial to load");
+ const lazyload = first("module-lazyload", "Required to load a partial into");
+
+ const hasOption = (value: string): boolean =>
+ !!listbox.querySelector(
+ `button[role="option"][value="${CSS.escape(value)}"]`,
+ );
+
+ // Set initial selection from hash
+ if (location.hash) {
+ const value = hashToValue(location.hash, listbox);
+ if (value && hasOption(value)) listbox.value = value;
+ }
+
+ // Track whether we're updating the hash ourselves to avoid loops
+ let updatingHash = false;
+
+ // Update selection when hash changes (browser back/forward)
+ const onHashChange = () => {
+ if (updatingHash) return;
+
+ const value = hashToValue(location.hash, listbox);
+ if (value && value !== listbox.value && hasOption(value)) {
+ batch(() => {
+ listbox.filter = "";
+ listbox.value = value;
+ });
}
+ };
- // Track whether we're updating the hash ourselves to avoid loops
- let updatingHash = false;
-
- // Update selection when hash changes (browser back/forward)
- const onHashChange = () => {
- if (updatingHash) return;
-
- const value = hashToValue(location.hash, listbox);
- if (value && value !== listbox.value && hasOption(value)) {
- batch(() => {
- listbox.filter = "";
- listbox.value = value;
- });
- }
- };
-
- return {
- lazyload: pass({ src: () => listbox.value }),
-
- // Sync location.hash ↔ listbox selection
- host: () => {
- // Update hash when selection changes
- const cleanup = createEffect(() => {
- const value = listbox.value;
- if (!value) return;
-
- const hash = valueToHash(value, listbox);
- if (hash && location.hash !== `#${hash}`) {
- updatingHash = true;
- history.replaceState(null, "", `#${hash}`);
- updatingHash = false;
- }
- });
-
- window.addEventListener("hashchange", onHashChange);
-
- return () => {
- cleanup();
- window.removeEventListener("hashchange", onHashChange);
- };
- },
- };
- },
-);
+ return [
+ pass(lazyload, { src: () => listbox.value }),
+
+ // Sync location.hash ↔ listbox selection
+ () => {
+ // Update hash when selection changes
+ const cleanup = createEffect(() => {
+ const value = listbox.value;
+ if (!value) return;
+
+ const hash = valueToHash(value, listbox);
+ if (hash && location.hash !== `#${hash}`) {
+ updatingHash = true;
+ history.replaceState(null, "", `#${hash}`);
+ updatingHash = false;
+ }
+ });
+
+ window.addEventListener("hashchange", onHashChange);
+
+ return () => {
+ cleanup();
+ window.removeEventListener("hashchange", onHashChange);
+ };
+ },
+ ];
+});
diff --git a/src/module/pagination/module-pagination.stories.ts b/src/module/pagination/module-pagination.stories.ts
index 8efed66..a77d9a6 100644
--- a/src/module/pagination/module-pagination.stories.ts
+++ b/src/module/pagination/module-pagination.stories.ts
@@ -3,7 +3,6 @@ import { html } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./module-pagination.ts";
import "./module-pagination.css";
-import type { Component } from "@zeix/le-truc";
import type { ModulePaginationProps } from "./module-pagination.ts";
type ModulePaginationArgs = {
@@ -22,8 +21,22 @@ const render = ({ value, max }: ModulePaginationArgs) => html`
${max}
- ❮
- = max} aria-label="Next page">❯
+
+ ❮
+
+ = max}
+ aria-label="Next page"
+ >
+ ❯
+
`;
@@ -63,9 +76,8 @@ export const Navigation: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-pagination");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "module-pagination",
- ) as Component;
+ const el = canvasElement.querySelector("module-pagination") as HTMLElement &
+ ModulePaginationProps;
const next = canvas.getByRole("button", { name: "Next page" });
const prev = canvas.getByRole("button", { name: "Previous page" });
@@ -90,9 +102,8 @@ export const ClampedAtBounds: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-pagination");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "module-pagination",
- ) as Component;
+ const el = canvasElement.querySelector("module-pagination") as HTMLElement &
+ ModulePaginationProps;
const next = canvas.getByRole("button", { name: "Next page" });
await expect(el.value).toBe(3);
@@ -105,9 +116,8 @@ export const PropertyChanges: Story = {
args: { value: 1, max: 10 },
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-pagination");
- const el = canvasElement.querySelector(
- "module-pagination",
- ) as Component;
+ const el = canvasElement.querySelector("module-pagination") as HTMLElement &
+ ModulePaginationProps;
await expect(el.value).toBe(1);
await expect(el.max).toBe(10);
diff --git a/src/module/pagination/module-pagination.ts b/src/module/pagination/module-pagination.ts
index 9163c5f..9712a78 100644
--- a/src/module/pagination/module-pagination.ts
+++ b/src/module/pagination/module-pagination.ts
@@ -1,63 +1,49 @@
import {
- type Component,
+ asClampedInteger,
+ bindProperty,
+ bindText,
defineComponent,
- on,
- read,
- setAttribute,
- setProperty,
- setText,
- show,
} from "@zeix/le-truc";
-import { asClampedInteger } from "../../_common/asClampedInteger";
export type ModulePaginationProps = {
max: number;
value: number;
};
-type ModulePaginationUI = {
- input: HTMLInputElement;
- prev: HTMLButtonElement;
- next: HTMLButtonElement;
- max?: HTMLElement | undefined;
- value?: HTMLElement | undefined;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "module-pagination": Component;
+ "module-pagination": HTMLElement & ModulePaginationProps;
}
}
-export default defineComponent(
+export default defineComponent(
"module-pagination",
- {
- max: read((ui) => ui.input.max, asClampedInteger(1)),
- value: read(
- (ui) => ui.input.value,
- asClampedInteger(1, (ui) => ui.host.max),
- ),
- },
- ({ first }) => ({
- input: first(
+ ({ expose, first, host, on, watch }) => {
+ const input = first(
"input",
'Add an to enter the page number to go to.',
- ),
- prev: first(
+ );
+ const prev = first(
"button.prev",
"Add a to go to the previous page.",
- ),
- next: first("button.next", "Add a to go to the next page."),
- value: first(".value"),
- max: first(".max"),
- }),
- ({ host, input, prev, next }) => ({
- host: [
- show(() => host.max > 1),
- setAttribute("value", () => String(host.value)),
- setAttribute("max", () => String(host.max)),
- on("keyup", ({ target, key }) => {
- if (target instanceof HTMLInputElement) return;
+ );
+ const next = first(
+ "button.next",
+ "Add a to go to the next page.",
+ );
+ const valueEl = first(".value");
+ const maxEl = first(".max");
+
+ expose({
+ max: asClampedInteger(Number(input.max) ?? 1),
+ value: asClampedInteger(input.valueAsNumber ?? 1, host.max),
+ });
+
+ return [
+ on(host, "keyup", (e) => {
+ const { key } = e;
+ if (e.target instanceof HTMLInputElement) return;
+
let nextPage = host.value;
if ((key === "ArrowLeft" || key === "-") && host.value > 1) nextPage--;
else if ((key === "ArrowRight" || key === "+") && host.value < host.max)
@@ -67,9 +53,7 @@ export default defineComponent(
prev.focus();
host.value = nextPage;
}),
- ],
- input: [
- on("change", () => {
+ on(input, "change", () => {
const numValue = input.valueAsNumber;
const clamped = Number.isNaN(numValue)
? 1
@@ -77,24 +61,28 @@ export default defineComponent(
input.valueAsNumber = clamped;
host.value = clamped;
}),
- setProperty("value", () => String(host.value)),
- setProperty("max", () => String(host.max)),
- ],
- prev: [
- on("click", () => {
+ on(prev, "click", () => {
host.value--;
if (host.value <= 1) next.focus();
}),
- setProperty("disabled", () => host.value <= 1),
- ],
- next: [
- on("click", () => {
+ on(next, "click", () => {
host.value++;
if (host.value >= host.max) prev.focus();
}),
- setProperty("disabled", () => host.value >= host.max),
- ],
- value: setText(() => String(host.value)),
- max: setText(() => String(host.max)),
- }),
+
+ watch("value", (value) => {
+ host.setAttribute("value", String(value));
+ input.value = String(value);
+ prev.disabled = value <= 1;
+ }),
+ watch("max", (max) => {
+ host.hidden = max <= 1;
+ host.setAttribute("max", String(max));
+ input.max = String(max);
+ }),
+ watch(() => host.value >= host.max, bindProperty(next, "disabled")),
+ valueEl && watch("value", bindText(valueEl, true)),
+ maxEl && watch("max", bindText(maxEl, true)),
+ ];
+ },
);
diff --git a/src/module/scrollarea/module-scrollarea.css b/src/module/scrollarea/module-scrollarea.css
index f7dfbcb..42fa546 100644
--- a/src/module/scrollarea/module-scrollarea.css
+++ b/src/module/scrollarea/module-scrollarea.css
@@ -1,59 +1,61 @@
+/* @media (prefers-reduced-motion: no-preference) { */
+
module-scrollarea {
- display: block;
- position: relative;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
-
- &::before,
- &::after {
- content: "";
- position: sticky;
- display: block;
- width: 100%;
- height: var(--space-m);
- opacity: 0;
- pointer-events: none;
- transition: opacity var(--transition-short);
- z-index: 1;
- }
-
- &::before {
- top: 0;
- background: linear-gradient(180deg, var(--color-shadow), transparent);
- }
-
- &::after {
- bottom: 0;
- background: linear-gradient(0deg, var(--color-shadow), transparent);
- }
-
- &.overflow-start::before {
- opacity: 1;
- }
-
- &.overflow-end::after {
- opacity: 1;
- }
-
- &[orientation="horizontal"] {
- overflow-x: auto;
- overflow-y: clip;
-
- &::before,
- &::after {
- width: var(--space-m);
- height: 1000vh;
- margin-block-end: -1000vh;
- }
-
- &::before {
- left: 0;
- background: linear-gradient(90deg, var(--color-shadow), transparent);
- }
-
- &::after {
- left: calc(100% - var(--space-m));
- background: linear-gradient(270deg, var(--color-shadow), transparent);
- }
- }
+ display: block;
+ position: relative;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+
+ &::before,
+ &::after {
+ content: "";
+ position: sticky;
+ display: block;
+ width: 100%;
+ height: var(--space-m);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--transition-short);
+ z-index: 1;
+ }
+
+ &::before {
+ top: 0;
+ background: linear-gradient(180deg, var(--color-shadow), transparent);
+ }
+
+ &::after {
+ bottom: 0;
+ background: linear-gradient(0deg, var(--color-shadow), transparent);
+ }
+
+ &.overflow-start::before {
+ opacity: 1;
+ }
+
+ &.overflow-end::after {
+ opacity: 1;
+ }
+
+ &[orientation="horizontal"] {
+ overflow-x: auto;
+ overflow-y: clip;
+
+ &::before,
+ &::after {
+ width: var(--space-m);
+ height: 1000vh;
+ margin-block-end: -1000vh;
+ }
+
+ &::before {
+ left: 0;
+ background: linear-gradient(90deg, var(--color-shadow), transparent);
+ }
+
+ &::after {
+ left: calc(100% - var(--space-m));
+ background: linear-gradient(270deg, var(--color-shadow), transparent);
+ }
+ }
}
diff --git a/src/module/scrollarea/module-scrollarea.ts b/src/module/scrollarea/module-scrollarea.ts
index 2bcd754..83ee9ad 100644
--- a/src/module/scrollarea/module-scrollarea.ts
+++ b/src/module/scrollarea/module-scrollarea.ts
@@ -1,19 +1,11 @@
-import {
- batch,
- type Component,
- type ComponentProps,
- createState,
- defineComponent,
- on,
- toggleClass,
-} from "@zeix/le-truc";
+import { batch, bindClass, createState, defineComponent } from "@zeix/le-truc";
const MIN_INTERSECTION_RATIO = 0;
const MAX_INTERSECTION_RATIO = 0.99; // ignore rounding errors of fraction pixels
declare global {
interface HTMLElementTagNameMap {
- "module-scrollarea": Component;
+ "module-scrollarea": HTMLElement;
}
}
@@ -45,52 +37,50 @@ const observeOverflow =
};
};
-export default defineComponent(
- "module-scrollarea",
- undefined,
- undefined,
- ({ host }) => {
- const child = host.firstElementChild;
- if (!child) return {};
+export default defineComponent("module-scrollarea", ({ host, on, watch }) => {
+ const child = host.firstElementChild;
+ if (!child) return [];
- const overflowStart = createState(false);
- const overflowEnd = createState(false);
- const hasOverflow = () => overflowStart.get() || overflowEnd.get();
+ const overflowStart = createState(false);
+ const overflowEnd = createState(false);
+ const hasOverflow = () => overflowStart.get() || overflowEnd.get();
- const scrollCallback =
- host.getAttribute("orientation") === "horizontal"
- ? () => {
- overflowStart.set(host.scrollLeft > 0);
- overflowEnd.set(
- host.scrollLeft < host.scrollWidth - host.offsetWidth,
- );
- }
- : () => {
- overflowStart.set(host.scrollTop > 0);
- overflowEnd.set(
- host.scrollTop < host.scrollHeight - host.offsetHeight,
- );
- };
+ const scrollCallback =
+ host.getAttribute("orientation") === "horizontal"
+ ? () => {
+ overflowStart.set(host.scrollLeft > 0);
+ overflowEnd.set(
+ host.scrollLeft < host.scrollWidth - host.offsetWidth,
+ );
+ }
+ : () => {
+ overflowStart.set(host.scrollTop > 0);
+ overflowEnd.set(
+ host.scrollTop < host.scrollHeight - host.offsetHeight,
+ );
+ };
- return {
- host: [
- toggleClass("overflow", hasOverflow),
- toggleClass("overflow-start", overflowStart),
- toggleClass("overflow-end", overflowEnd),
- observeOverflow(
- child,
- () => {
- overflowEnd.set(true);
- },
- () => {
- overflowStart.set(false);
- overflowEnd.set(false);
- },
- ),
- on("scroll", () => {
- if (hasOverflow()) batch(scrollCallback);
- }),
- ],
- };
- },
-);
+ return [
+ on(host, "scroll", () => {
+ if (hasOverflow()) batch(scrollCallback);
+ }),
+
+ watch(
+ () => overflowStart.get() || overflowEnd.get(),
+ bindClass(host, "overflow"),
+ ),
+ watch(overflowStart, bindClass(host, "overflow-start")),
+ watch(overflowEnd, bindClass(host, "overflow-end")),
+ () =>
+ observeOverflow(
+ child,
+ () => {
+ overflowEnd.set(true);
+ },
+ () => {
+ overflowStart.set(false);
+ overflowEnd.set(false);
+ },
+ )(host),
+ ];
+});
diff --git a/src/module/splitview/module-splitview.css b/src/module/splitview/module-splitview.css
new file mode 100644
index 0000000..2c9dcc8
--- /dev/null
+++ b/src/module/splitview/module-splitview.css
@@ -0,0 +1,56 @@
+module-splitview {
+ display: grid;
+ grid-template-columns: var(--split, 50%) var(--space-xs) 1fr;
+ overflow: hidden;
+
+ & .panel {
+ overflow: auto;
+ min-width: 0;
+ min-height: 0;
+ }
+
+ & .divider {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 0;
+ background: var(--color-border-soft);
+ cursor: col-resize;
+ touch-action: none;
+ transition: background-color var(--transition-short) var(--easing-inout);
+
+ &::after {
+ content: '';
+ display: block;
+ width: 2px;
+ height: var(--space-l);
+ border-radius: 1px;
+ background: var(--color-text);
+ opacity: var(--opacity-translucent);
+ }
+
+ &:hover {
+ background: var(--color-border);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-selection);
+ outline-offset: -2px;
+ }
+ }
+
+ &[orientation='vertical'] {
+ grid-template-columns: 1fr;
+ grid-template-rows: var(--split, 50%) var(--space-xs) 1fr;
+
+ & .divider {
+ cursor: row-resize;
+
+ &::after {
+ width: var(--space-l);
+ height: 2px;
+ }
+ }
+ }
+}
diff --git a/src/module/splitview/module-splitview.html b/src/module/splitview/module-splitview.html
new file mode 100644
index 0000000..278331e
--- /dev/null
+++ b/src/module/splitview/module-splitview.html
@@ -0,0 +1,65 @@
+
+
+
+
Left panel
+
Drag the handle or focus it and use arrow keys to resize.
+
+
+
+
Right panel
+
The proportions are kept when the container is resized.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/module/splitview/module-splitview.mdx b/src/module/splitview/module-splitview.mdx
new file mode 100644
index 0000000..c14a9f9
--- /dev/null
+++ b/src/module/splitview/module-splitview.mdx
@@ -0,0 +1,83 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as ModuleSplitviewStories from './module-splitview.stories';
+
+
+
+### Module Splitview
+
+A resizable split-pane component controlled by dragging a divider button or using keyboard arrow keys. Demonstrates `asNumber()` for a float attribute, pointer capture for drag handling, and a `watch('split', ...)` custom handler that sets `--split` as a CSS custom property and updates `aria-valuenow` on the divider. Orientation (horizontal or vertical) is determined once from the `orientation` attribute at connect time.
+
+#### Tag Name
+
+`module-splitview`
+
+#### Preview
+
+
+
+#### Controls
+
+
+
+#### Reactive Properties
+
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+ split
+ number (float)
+ 0.5
+ Split ratio between 0.1 and 0.9; drives the --split CSS custom property and aria-valuenow
+
+
+
+
+#### Attributes
+
+
+
+
+ Name
+ Description
+
+
+
+
+ split
+ Initial split ratio as a float; default 0.5
+
+
+ orientation
+ One of "horizontal" (default) or "vertical"; read once at connect time to determine drag axis
+
+
+
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('button.divider')
+ HTMLButtonElement
+ required
+ Drag handle and keyboard resize control with role="separator"; receives aria-valuenow and pointer/keyboard events
+
+
+
diff --git a/src/module/splitview/module-splitview.stories.ts b/src/module/splitview/module-splitview.stories.ts
new file mode 100644
index 0000000..6a7db00
--- /dev/null
+++ b/src/module/splitview/module-splitview.stories.ts
@@ -0,0 +1,110 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html, nothing } from "lit";
+import { expect, userEvent, within } from "storybook/test";
+import "./module-splitview.ts";
+import "./module-splitview.css";
+import type { ModuleSplitviewProps } from "./module-splitview.ts";
+
+type ModuleSplitviewArgs = {
+ split: number;
+ orientation: "horizontal" | "vertical";
+};
+
+const render = ({ split, orientation }: ModuleSplitviewArgs) => html`
+
+
+
${orientation === "vertical" ? "Top" : "Left"} panel
+
Drag the handle or focus it and use arrow keys to resize.
+
+
+
+
${orientation === "vertical" ? "Bottom" : "Right"} panel
+
The proportions are kept when the container is resized.
+
+
+`;
+
+const meta: Meta = {
+ title: "Module/Splitview",
+ render,
+ argTypes: {
+ split: {
+ control: { type: "range", min: 0.1, max: 0.9, step: 0.05 },
+ table: {
+ defaultValue: { summary: "0.5" },
+ category: "Reactive Properties",
+ },
+ },
+ orientation: {
+ control: { type: "select" },
+ options: ["horizontal", "vertical"],
+ table: {
+ defaultValue: { summary: "horizontal" },
+ category: "Attributes",
+ },
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ split: 0.5,
+ orientation: "horizontal",
+ },
+};
+
+export const PresetSplit: Story = {
+ args: {
+ split: 0.3,
+ orientation: "horizontal",
+ },
+};
+
+export const Vertical: Story = {
+ args: {
+ split: 0.5,
+ orientation: "vertical",
+ },
+};
+
+export const KeyboardResize: Story = {
+ args: {
+ split: 0.5,
+ orientation: "horizontal",
+ },
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("module-splitview");
+ const canvas = within(canvasElement);
+ const el = canvasElement.querySelector(
+ "module-splitview",
+ ) as HTMLElement & ModuleSplitviewProps;
+ const divider = canvas.getByRole("separator");
+
+ await expect(el.split).toBeCloseTo(0.5, 1);
+
+ divider.focus();
+ await userEvent.keyboard("{ArrowRight}");
+ await expect(el.split).toBeCloseTo(0.55, 1);
+
+ await userEvent.keyboard("{End}");
+ await expect(el.split).toBeCloseTo(0.9, 1);
+
+ await userEvent.keyboard("{Home}");
+ await expect(el.split).toBeCloseTo(0.1, 1);
+ },
+};
diff --git a/src/module/splitview/module-splitview.ts b/src/module/splitview/module-splitview.ts
new file mode 100644
index 0000000..5affa57
--- /dev/null
+++ b/src/module/splitview/module-splitview.ts
@@ -0,0 +1,69 @@
+import { asNumber, defineComponent } from "@zeix/le-truc";
+
+export type ModuleSplitviewProps = {
+ split: number;
+};
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "module-splitview": HTMLElement & ModuleSplitviewProps;
+ }
+}
+
+const MIN_SPLIT = 0.1;
+const MAX_SPLIT = 0.9;
+const STEP = 0.05;
+
+export default defineComponent(
+ "module-splitview",
+ ({ expose, first, host, on, watch }) => {
+ const divider = first(
+ "button.divider",
+ "Add a button.divider resize handle.",
+ );
+ const isVertical = host.getAttribute("orientation") === "vertical";
+
+ let dragging = false;
+
+ expose({ split: asNumber(0.5) });
+
+ return [
+ on(divider, "pointerdown", (event) => {
+ dragging = true;
+ (event.target as Element).setPointerCapture(event.pointerId);
+ }),
+ on(divider, "pointermove", (event) => {
+ if (!dragging) return;
+ const rect = host.getBoundingClientRect();
+ const ratio = isVertical
+ ? (event.clientY - rect.top) / rect.height
+ : (event.clientX - rect.left) / rect.width;
+ return { split: Math.max(MIN_SPLIT, Math.min(MAX_SPLIT, ratio)) };
+ }),
+ on(divider, "pointerup", () => {
+ dragging = false;
+ }),
+ on(divider, "lostpointercapture", () => {
+ dragging = false;
+ }),
+ on(divider, "keydown", (event) => {
+ const { key } = event;
+ const decrement = isVertical ? key === "ArrowUp" : key === "ArrowLeft";
+ const increment = isVertical
+ ? key === "ArrowDown"
+ : key === "ArrowRight";
+ if (decrement || increment || key === "Home" || key === "End") {
+ event.preventDefault();
+ }
+ if (decrement) return { split: Math.max(MIN_SPLIT, host.split - STEP) };
+ if (increment) return { split: Math.min(MAX_SPLIT, host.split + STEP) };
+ if (key === "Home") return { split: MIN_SPLIT };
+ if (key === "End") return { split: MAX_SPLIT };
+ }),
+ watch("split", (split) => {
+ host.style.setProperty("--split", `${(split * 100).toFixed(2)}%`);
+ divider.setAttribute("aria-valuenow", String(Math.round(split * 100)));
+ }),
+ ];
+ },
+);
diff --git a/src/module/tabgroup/module-tabgroup.css b/src/module/tabgroup/module-tabgroup.css
index 133f0fc..a3c1bf4 100644
--- a/src/module/tabgroup/module-tabgroup.css
+++ b/src/module/tabgroup/module-tabgroup.css
@@ -1,54 +1,54 @@
module-tabgroup {
- display: block;
- margin-bottom: var(--space-l);
+ display: block;
+ margin-bottom: var(--space-l);
- > [role="tablist"] {
- display: flex;
- border-bottom: 1px solid var(--color-border);
- padding: 0;
- margin-bottom: 0;
+ > [role="tablist"] {
+ display: flex;
+ border-bottom: 1px solid var(--color-border);
+ padding: 0;
+ margin-bottom: 0;
- > [role="tab"] {
- border: 0;
- border-top: 2px solid transparent;
- border-bottom-width: 0;
- border-radius: var(--space-xs) var(--space-xs) 0 0;
- font-family: var(--font-family-sans);
- font-size: var(--font-size-s);
- font-weight: var(--font-weight-bold);
- padding: var(--space-s) var(--space-m);
- color: var(--color-text-soft);
- background-color: var(--color-secondary);
- cursor: pointer;
- transition: all var(--transition-short) var(--easing-inout);
+ > [role="tab"] {
+ border: 0;
+ border-top: 2px solid transparent;
+ border-bottom-width: 0;
+ border-radius: var(--space-xs) var(--space-xs) 0 0;
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-s);
+ font-weight: var(--font-weight-bold);
+ padding: var(--space-s) var(--space-m);
+ color: var(--color-text-soft);
+ background-color: var(--color-secondary);
+ cursor: pointer;
+ transition: all var(--transition-short) var(--easing-inout);
- &:hover,
- &:focus {
- color: var(--color-text);
- background-color: var(--color-secondary-hover);
- }
+ &:hover,
+ &:focus {
+ color: var(--color-text);
+ background-color: var(--color-secondary-hover);
+ }
- &:focus {
- z-index: 1;
- }
+ &:focus {
+ z-index: 1;
+ }
- &:active {
- color: var(--color-text);
- background-color: var(--color-secondary-active);
- }
+ &:active {
+ color: var(--color-text);
+ background-color: var(--color-secondary-active);
+ }
- &[aria-selected="true"] {
- color: var(--color-primary-active);
- border-top: 3px solid var(--color-primary);
- background-color: var(--color-background);
- margin-bottom: -1px;
- }
- }
- }
+ &[aria-selected="true"] {
+ color: var(--color-primary-active);
+ border-top: 3px solid var(--color-primary);
+ background-color: var(--color-background);
+ margin-bottom: -1px;
+ }
+ }
+ }
- > [role="tabpanel"] {
- font-size: var(--font-size-m);
- background: var(--color-background);
- padding: var(--space-l) var(--space-m);
- }
+ > [role="tabpanel"] {
+ font-size: var(--font-size-m);
+ background: var(--color-background);
+ margin-block: var(--space-l);
+ }
}
diff --git a/src/module/tabgroup/module-tabgroup.mdx b/src/module/tabgroup/module-tabgroup.mdx
index 6409ffd..785e436 100644
--- a/src/module/tabgroup/module-tabgroup.mdx
+++ b/src/module/tabgroup/module-tabgroup.mdx
@@ -5,7 +5,7 @@ import * as ModuleTabgroupStories from './module-tabgroup.stories';
### Module Tabgroup
-An accessible tab group with keyboard navigation. Uses `createEventsSensor` to derive `selected` from both click events and keyboard events (ArrowLeft/Right/Up/Down, Home, End) on the tab buttons. The initial selection is read from the DOM via `read()`. Demonstrates `setProperty('ariaSelected')` and `setProperty('tabIndex')` applied to `all('button[role="tab"]')`, and `show()` applied to `all('[role="tabpanel"]')`.
+An accessible tab group with keyboard navigation. Demonstrates `createState` for a read-only `selected` property (exposed via `state.get`) whose initial value is derived from `ariaSelected` on the tab buttons at connect time. A single `watch('selected', ...)` handler drives `ariaSelected`, `tabIndex`, and `hidden` on all tabs and panels. Click and keyboard events (ArrowLeft/Right/Up/Down, Home, End) update `selectedState` directly.
#### Tag Name
diff --git a/src/module/tabgroup/module-tabgroup.stories.ts b/src/module/tabgroup/module-tabgroup.stories.ts
index 5966bc4..bbf1bf3 100644
--- a/src/module/tabgroup/module-tabgroup.stories.ts
+++ b/src/module/tabgroup/module-tabgroup.stories.ts
@@ -3,7 +3,6 @@ import { html } from "lit";
import { expect, userEvent, within } from "storybook/test";
import "./module-tabgroup.ts";
import "./module-tabgroup.css";
-import type { Component } from "@zeix/le-truc";
import type { ModuleTabgroupProps } from "./module-tabgroup.ts";
const meta: Meta = {
@@ -16,9 +15,33 @@ export const Default: Story = {
render: () => html`
- Tab 1
- Tab 2
- Tab 3
+
+ Tab 1
+
+
+ Tab 2
+
+
+ Tab 3
+
Tab 1 content
Tab 2 content
@@ -27,9 +50,8 @@ export const Default: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-tabgroup");
- const el = canvasElement.querySelector(
- "module-tabgroup",
- ) as Component;
+ const el = canvasElement.querySelector("module-tabgroup") as HTMLElement &
+ ModuleTabgroupProps;
await expect(el.selected).toBe("panel1");
},
@@ -39,9 +61,33 @@ export const TabNavigation: Story = {
render: () => html`
- Settings
- Profile
- Security
+
+ Settings
+
+
+ Profile
+
+
+ Security
+
Settings
@@ -60,9 +106,8 @@ export const TabNavigation: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-tabgroup");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "module-tabgroup",
- ) as Component
;
+ const el = canvasElement.querySelector("module-tabgroup") as HTMLElement &
+ ModuleTabgroupProps;
await expect(el.selected).toBe("nav-panel1");
@@ -78,9 +123,33 @@ export const SecondTabInitial: Story = {
render: () => html`
- Home
- About
- Contact
+
+ Home
+
+
+ About
+
+
+ Contact
+
Home content
About content
@@ -89,9 +158,8 @@ export const SecondTabInitial: Story = {
`,
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-tabgroup");
- const el = canvasElement.querySelector(
- "module-tabgroup",
- ) as Component;
+ const el = canvasElement.querySelector("module-tabgroup") as HTMLElement &
+ ModuleTabgroupProps;
await expect(el.selected).toBe("init-panel2");
},
@@ -101,9 +169,33 @@ export const KeyboardNavigation: Story = {
render: () => html`
- First
- Second
- Third
+
+ First
+
+
+ Second
+
+
+ Third
+
First panel content
Second panel content
@@ -113,9 +205,8 @@ export const KeyboardNavigation: Story = {
play: async ({ canvasElement }) => {
await customElements.whenDefined("module-tabgroup");
const canvas = within(canvasElement);
- const el = canvasElement.querySelector(
- "module-tabgroup",
- ) as Component;
+ const el = canvasElement.querySelector("module-tabgroup") as HTMLElement &
+ ModuleTabgroupProps;
const firstTab = canvas.getByRole("tab", { name: "First" });
await expect(el.selected).toBe("key-panel1");
diff --git a/src/module/tabgroup/module-tabgroup.ts b/src/module/tabgroup/module-tabgroup.ts
index 9c2f720..0747fff 100644
--- a/src/module/tabgroup/module-tabgroup.ts
+++ b/src/module/tabgroup/module-tabgroup.ts
@@ -1,109 +1,92 @@
-import {
- type Component,
- createEventsSensor,
- defineComponent,
- type Memo,
- read,
- setProperty,
- show,
-} from "@zeix/le-truc";
+import { createState, defineComponent } from "@zeix/le-truc";
export type ModuleTabgroupProps = {
readonly selected: string;
};
-type ModuleTabgroupUI = {
- tabs: Memo;
- panels: Memo;
-};
-
declare global {
interface HTMLElementTagNameMap {
- "module-tabgroup": Component;
+ "module-tabgroup": HTMLElement & ModuleTabgroupProps;
}
}
-const getAriaControls = (element: HTMLElement) =>
- element.getAttribute("aria-controls") ?? "";
+const getAriaControls = (element: HTMLElement | undefined) =>
+ element?.getAttribute("aria-controls") ?? "";
const getSelected = (
tabs: HTMLElement[],
isCurrent: (element: HTMLElement) => boolean,
offset = 0,
-) => {
- const currentIndex = tabs.findIndex(isCurrent);
- const newIndex = (currentIndex + offset + tabs.length) % tabs.length;
- const tab = tabs[newIndex];
- return tab ? getAriaControls(tab) : "";
-};
+) =>
+ getAriaControls(
+ tabs[(tabs.findIndex(isCurrent) + offset + tabs.length) % tabs.length],
+ );
-export default defineComponent(
+export default defineComponent(
"module-tabgroup",
- {
- selected: createEventsSensor(
- read(
- (ui) =>
- getSelected(ui.tabs.get(), (tab) => tab.ariaSelected === "true"),
- "",
- ),
- "tabs",
- {
- click: ({ target }) => getAriaControls(target),
- keyup: ({ event, ui, target }) => {
- const key = event.key;
- if (
- [
- "ArrowLeft",
- "ArrowRight",
- "ArrowUp",
- "ArrowDown",
- "Home",
- "End",
- ].includes(key)
- ) {
- event.preventDefault();
- event.stopPropagation();
- const tabs = ui.tabs.get();
- const first = tabs[0];
- const last = tabs[tabs.length - 1];
- if (!first || !last) return;
- const next =
- key === "Home"
- ? getAriaControls(first)
- : key === "End"
- ? getAriaControls(last)
- : getSelected(
- tabs,
- (tab) => tab === target,
- key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1,
- );
- tabs.filter((tab) => getAriaControls(tab) === next)[0]?.focus();
- return next;
- }
- },
- },
- ),
- },
- ({ all }) => ({
- tabs: all(
+ ({ all, expose, host, on, watch }) => {
+ const tabs = all(
'button[role="tab"]',
'At least 2 tabs as children of a <[role="tablist"]> element are needed. Each tab must reference a unique id of a <[role="tabpanel"]> element.',
- ),
- panels: all(
+ );
+ const panels = all(
'[role="tabpanel"]',
"At least 2 tabpanels are needed. Each tabpanel must have a unique id.",
- ),
- }),
- ({ host }) => {
+ );
+
const isCurrentTab = (tab: HTMLButtonElement) =>
- host.selected === getAriaControls(tab);
+ host.selected === tab.getAttribute("aria-controls");
+
+ // Private mutable state; expose as read-only via Memo so external code can't set it
+ const selectedState = createState(
+ getSelected(tabs.get(), (tab) => tab.ariaSelected === "true"),
+ );
+
+ expose({ selected: selectedState.get });
+
+ return [
+ on(tabs, "click", (_e, target) => {
+ selectedState.set(getAriaControls(target));
+ }),
+ on(tabs, "keyup", (e, target) => {
+ const key = e.key;
+ if (
+ [
+ "ArrowLeft",
+ "ArrowRight",
+ "ArrowUp",
+ "ArrowDown",
+ "Home",
+ "End",
+ ].includes(key)
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
+ const tabsList = tabs.get();
+ const next =
+ key === "Home"
+ ? getAriaControls(tabsList[0])
+ : key === "End"
+ ? getAriaControls(tabsList[tabsList.length - 1])
+ : getSelected(
+ tabsList,
+ (tab) => tab === target,
+ key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1,
+ );
+ tabsList.filter((tab) => getAriaControls(tab) === next)[0]?.focus();
+ selectedState.set(next);
+ }
+ }),
- return {
- tabs: [
- setProperty("ariaSelected", (target) => String(isCurrentTab(target))),
- setProperty("tabIndex", (target) => (isCurrentTab(target) ? 0 : -1)),
- ],
- panels: show((target) => host.selected === target.id),
- };
+ watch("selected", () => {
+ for (const tab of tabs.get()) {
+ tab.ariaSelected = String(isCurrentTab(tab));
+ tab.tabIndex = isCurrentTab(tab) ? 0 : -1;
+ }
+ for (const panel of panels.get()) {
+ panel.hidden = host.selected !== panel.id;
+ }
+ }),
+ ];
},
);
diff --git a/src/module/ticker/module-ticker.css b/src/module/ticker/module-ticker.css
new file mode 100644
index 0000000..c4ad313
--- /dev/null
+++ b/src/module/ticker/module-ticker.css
@@ -0,0 +1,53 @@
+module-ticker {
+ display: block;
+
+ > .controls {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-m);
+ margin-block-end: var(--space-s);
+ }
+
+ & table {
+ width: 100%;
+ border-collapse: collapse;
+ font-variant-numeric: tabular-nums;
+ }
+
+ & th,
+ & td {
+ padding-block: var(--space-xs);
+ padding-inline: var(--space-s);
+ border-block-end: 1px solid var(--color-border-soft, currentColor);
+ text-align: end;
+ }
+
+ & th[scope="col"] {
+ text-align: end;
+ font-weight: normal;
+ color: var(--color-text-soft, inherit);
+
+ &:first-child {
+ text-align: start;
+ }
+ }
+
+ & th[scope="row"] {
+ text-align: start;
+ font-family: var(--font-mono, monospace);
+ font-weight: bold;
+ }
+
+ /* Direction indicators on the change cell */
+ & tr[data-direction="up"] .change {
+ color: var(--color-positive, var(--color-green-60));
+ }
+
+ & tr[data-direction="down"] .change {
+ color: var(--color-negative, var(--color-pink-60));
+ }
+
+ & tr[data-direction="flat"] .change {
+ color: var(--color-text-soft, inherit);
+ }
+}
diff --git a/src/module/ticker/module-ticker.mdx b/src/module/ticker/module-ticker.mdx
new file mode 100644
index 0000000..62dbaf6
--- /dev/null
+++ b/src/module/ticker/module-ticker.mdx
@@ -0,0 +1,105 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as ModuleTickerStories from './module-ticker.stories';
+
+
+
+### Module Ticker
+
+A high-throughput live data table demonstrating virtualization with `IntersectionObserver` and fine-grained reactivity with `createList`. Each row's price, change, and volume are independent `State` signals — only materialized rows have active `watch()` effects, so updating off-screen rows is cheap. `each(rows, ...)` wires and tears down per-row effects automatically as rows materialize and virtualize.
+
+#### Tag Name
+
+`module-ticker`
+
+#### Preview
+
+
+
+#### Reactive Properties
+
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+ running
+ boolean
+ true
+ Whether the ticker simulation is running; toggled by the pause/resume button
+
+
+ fraction
+ number (float)
+ 0.1
+ Fraction of rows updated per tick interval (0–1); higher values increase CPU usage
+
+
+
+
+#### Attributes
+
+
+
+
+ Name
+ Description
+
+
+
+
+ fraction
+ Initial update fraction as a float; default 0.1
+
+
+
+
+#### Descendant Elements
+
+
+
+
+ Selector
+ Type
+ Required
+ Description
+
+
+
+
+ first('basic-button.toggle')
+ HTMLElement
+ optional
+ Pause/Resume toggle button; receives a dynamic label via pass()
+
+
+ first('basic-button.add-rows')
+ HTMLElement
+ optional
+ Adds a new block of 100 rows to the table on click
+
+
+ first('template')
+ HTMLTemplateElement
+ required
+ Row template cloned when materializing a block; must contain a <tr> with .price, .change, and .volume cells
+
+
+ host.querySelector('table')
+ HTMLTableElement
+ required
+ Table element; new <tbody> blocks are appended here when rows are added
+
+
+ all('tr[data-symbol]')
+ Memo<HTMLTableRowElement[]>
+ required
+ Materialized data rows tracked via MutationObserver; each() wires per-row price/change/volume effects
+
+
+
diff --git a/src/module/ticker/module-ticker.stories.ts b/src/module/ticker/module-ticker.stories.ts
new file mode 100644
index 0000000..5096538
--- /dev/null
+++ b/src/module/ticker/module-ticker.stories.ts
@@ -0,0 +1,132 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+import { expect, userEvent, within } from "storybook/test";
+import "./module-ticker.ts";
+import "./module-ticker.css";
+import "../../basic/button/basic-button.ts";
+import "../../basic/button/basic-button.css";
+import type { ModuleTickerProps } from "./module-ticker.ts";
+
+const tickerTemplate = html`
+
+
+
+
+ ⏸️ Pause
+
+
+
+ ➕ Add 100 rows
+
+
+
+
+
+ Symbol
+ Price (USD)
+ Change
+ Volume
+
+
+
+
+ AAPL
+ 189.30
+ +0.00%
+ 0
+
+
+ MSFT
+ 417.50
+ +0.00%
+ 0
+
+
+ NVDA
+ 875.40
+ +0.00%
+ 0
+
+
+ AMZN
+ 183.20
+ +0.00%
+ 0
+
+
+ GOOGL
+ 162.60
+ +0.00%
+ 0
+
+
+ META
+ 494.80
+ +0.00%
+ 0
+
+
+ TSLA
+ 238.10
+ +0.00%
+ 0
+
+
+ BRK.B
+ 406.70
+ +0.00%
+ 0
+
+
+ JPM
+ 197.40
+ +0.00%
+ 0
+
+
+ V
+ 277.90
+ +0.00%
+ 0
+
+
+
+
+
+
+ 0.00
+ +0.00%
+ 0
+
+
+
+`;
+
+const meta: Meta = {
+ title: "Module/Ticker",
+ render: () => tickerTemplate,
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const PauseResume: Story = {
+ play: async ({ canvasElement }) => {
+ await customElements.whenDefined("module-ticker");
+ const canvas = within(canvasElement);
+ const el = canvasElement.querySelector(
+ "module-ticker",
+ ) as HTMLElement & ModuleTickerProps;
+
+ await expect(el.running).toBe(true);
+
+ const pauseButton = canvas.getByRole("button", { name: /Pause/i });
+ await userEvent.click(pauseButton);
+ await expect(el.running).toBe(false);
+
+ const resumeButton = canvas.getByRole("button", { name: /Resume/i });
+ await userEvent.click(resumeButton);
+ await expect(el.running).toBe(true);
+ },
+};
diff --git a/src/module/ticker/module-ticker.ts b/src/module/ticker/module-ticker.ts
new file mode 100644
index 0000000..238f580
--- /dev/null
+++ b/src/module/ticker/module-ticker.ts
@@ -0,0 +1,284 @@
+import {
+ asNumber,
+ bindProperty,
+ bindText,
+ createList,
+ createMemo,
+ defineComponent,
+ each,
+} from "@zeix/le-truc";
+
+/* === Fantasy symbol generator === */
+
+// Bijective 3-char base-26 counter → 17,576 unique symbols (AAA…ZZZ)
+const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+let _symIdx = 0;
+const _usedSymbols = new Set();
+
+function nextSymbol(): string {
+ while (true) {
+ const n = _symIdx++;
+ const sym =
+ (ALPHA[Math.floor(n / 676) % 26] ?? "A") +
+ (ALPHA[Math.floor(n / 26) % 26] ?? "A") +
+ (ALPHA[n % 26] ?? "A");
+ if (!_usedSymbols.has(sym)) {
+ _usedSymbols.add(sym);
+ return sym;
+ }
+ }
+}
+
+/* === Types === */
+
+type TickerItem = {
+ symbol: string;
+ open: number; // reference price, never mutated
+ price: number; // current price, random-walks from open
+ volume: number; // cumulative volume from open
+};
+
+export type ModuleTickerProps = {
+ running: boolean;
+ fraction: number;
+};
+
+/* === Global Declaration === */
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "module-ticker": HTMLElement & ModuleTickerProps;
+ }
+}
+
+const priceFormat = new Intl.NumberFormat("en-US", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+const changeFormat = new Intl.NumberFormat("en-US", {
+ style: "percent",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ signDisplay: "always",
+});
+const volumeFormat = new Intl.NumberFormat("en-US", { notation: "compact" });
+
+const BLOCK_SIZE = 100;
+
+/* === Component === */
+
+export default defineComponent(
+ "module-ticker",
+ ({ all, expose, first, host, on, pass, watch }) => {
+ const toggleBtn = first("basic-button.toggle");
+ const addRowsBtn = first("basic-button.add-rows");
+ const template = first("template") as HTMLTemplateElement | null;
+ const table = host.querySelector("table");
+ const rows = all("tr[data-symbol]");
+
+ // Read initial state from server-rendered HTML rows
+ const initial: TickerItem[] = Array.from(
+ host.querySelectorAll("tr[data-symbol]"),
+ ).map((row) => {
+ const symbol = row.dataset.symbol ?? "";
+ // Seed usedSymbols so generated symbols never collide with static ones
+ _usedSymbols.add(symbol);
+ const price = parseFloat(
+ (row.querySelector(".price")?.textContent ?? "0").replace(/,/g, ""),
+ );
+ return { symbol, open: price, price, volume: 0 };
+ });
+
+ // Closure-held reactive list, keyed by symbol.
+ // Each item is an independent State; update() on a key
+ // only re-runs effects that depend on that item's signal.
+ const tickers = createList(initial, {
+ keyConfig: (item) => item.symbol,
+ });
+
+ // Block registry: each holds BLOCK_SIZE rows (materialized) or
+ // a single placeholder (virtualized). The Map tracks which symbols
+ // belong to each block so we can re-clone rows on materialization.
+ const blockSymbols = new Map();
+ const block0 = host.querySelector(
+ "tbody",
+ ) as HTMLTableSectionElement | null;
+ if (block0)
+ blockSymbols.set(
+ block0,
+ initial.map((i) => i.symbol),
+ );
+
+ // Materialize: remove placeholder, re-clone rows from template with
+ // current price from tickers list (which kept ticking while off-screen).
+ function materializeBlock(tbody: HTMLTableSectionElement): void {
+ const symbols = blockSymbols.get(tbody);
+ if (!symbols || !template) return;
+ tbody.innerHTML = "";
+ const fragment = document.createDocumentFragment();
+ for (const symbol of symbols) {
+ const price = tickers.byKey(symbol)?.get().price ?? 0;
+ const clone = template.content.cloneNode(true) as DocumentFragment;
+ const tr = clone.firstElementChild as HTMLTableRowElement;
+ tr.dataset.symbol = symbol;
+ const th = tr.querySelector("th");
+ const priceEl = tr.querySelector(".price");
+ if (th) th.textContent = symbol;
+ if (priceEl) priceEl.textContent = priceFormat.format(price);
+ fragment.append(tr);
+ }
+ tbody.append(fragment);
+ // each(rows, …) picks up the new tr[data-symbol] rows via
+ // MutationObserver and wires watch effects for them automatically.
+ }
+
+ // Virtualize: measure block height, replace all rows with a single
+ // height-matched placeholder so the scrollbar stays accurate.
+ function virtualizeBlock(tbody: HTMLTableSectionElement): void {
+ const height = tbody.offsetHeight;
+ tbody.innerHTML = ` `;
+ // Removing tr[data-symbol] rows triggers the MutationObserver;
+ // each(rows, …) tears down their watch effects automatically.
+ }
+
+ // One shared IntersectionObserver for all blocks. rootMargin pre-loads
+ // blocks just before they scroll into view, hiding the swap latency.
+ const blockObserver = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ const tbody = entry.target as HTMLTableSectionElement;
+ const isVirtualized = !tbody.querySelector("tr[data-symbol]");
+ if (entry.isIntersecting && isVirtualized) materializeBlock(tbody);
+ else if (!entry.isIntersecting && !isVirtualized)
+ virtualizeBlock(tbody);
+ }
+ },
+ { rootMargin: "400px" },
+ );
+
+ if (block0) blockObserver.observe(block0);
+
+ // Random-walk tick: updates a random subset of symbols each interval.
+ // Virtualized rows have no active watch effects, so signal updates for
+ // them are cheap (no downstream DOM work until the block re-materializes).
+ const tick = () => {
+ for (const key of tickers.keys()) {
+ if (Math.random() >= host.fraction) continue;
+ tickers.byKey(key)?.update((prev) => ({
+ ...prev,
+ price: Math.max(
+ 0.01,
+ prev.price + (Math.random() - 0.5) * prev.price * 0.004,
+ ),
+ volume: Math.max(0, prev.volume + Math.round(Math.random() * 50_000)),
+ }));
+ }
+ };
+
+ expose({
+ running: true,
+ fraction: asNumber(0.1),
+ });
+
+ return [
+ // Disconnect the block observer when the component disconnects.
+ () => () => blockObserver.disconnect(),
+
+ on(toggleBtn, "click", () => ({ running: !host.running })),
+ pass(toggleBtn, {
+ label: () => (host.running ? "⏸️ Pause" : "▶️ Resume"),
+ }),
+
+ // Intentionally stupid: signal updates every 10ms, far faster than
+ // any display can show.
+ watch("running", (v) => {
+ if (!v) return;
+ const id = setInterval(tick, 10);
+ return () => clearInterval(id);
+ }),
+
+ // Per-row effects: only wired for materialized rows (tr[data-symbol]
+ // in DOM). each() auto-tears-down when a row is virtualized and
+ // auto-sets-up when it re-materializes. The row.isConnected guard
+ // covers the brief window between DOM removal and MutationObserver
+ // firing where the row is detached but the effect hasn't cleaned up.
+ each(rows, (row) => {
+ const symbol = row.dataset.symbol ?? "";
+ const item = tickers.byKey(symbol);
+ if (!item || !row.isConnected) return;
+
+ const priceEl = row.querySelector(".price");
+ const changeEl = row.querySelector(".change");
+ const volumeEl = row.querySelector(".volume");
+ if (!priceEl || !changeEl || !volumeEl) return;
+
+ const changeMemo = createMemo(() => {
+ const { open, price } = item.get();
+ return (price - open) / open;
+ });
+
+ return [
+ watch(
+ () => priceFormat.format(item.get().price),
+ bindText(priceEl, true),
+ ),
+ watch(
+ () => changeFormat.format(changeMemo.get()),
+ bindText(changeEl, true),
+ ),
+ watch(
+ () => {
+ const change = changeMemo.get();
+ return change > 0 ? "up" : change < 0 ? "down" : "flat";
+ },
+ bindProperty(row.dataset, "direction"),
+ ),
+ watch(
+ () => volumeFormat.format(item.get().volume),
+ bindText(volumeEl, true),
+ ),
+ ];
+ }),
+
+ // Batch-add one full block: update tickers list first so each() finds
+ // the new State signals when the MutationObserver fires for the
+ // newly appended template clones. Each click creates its own
+ // so the IntersectionObserver can virtualize it independently.
+ on(addRowsBtn, "click", () => {
+ if (!template || !table) return;
+ const newItems: TickerItem[] = Array.from(
+ { length: BLOCK_SIZE },
+ () => {
+ const symbol = nextSymbol();
+ const price = Math.round((10 + Math.random() * 1000) * 100) / 100;
+ return { symbol, open: price, price, volume: 0 };
+ },
+ );
+ // Batch data update first — each() will find byKey() populated
+ // when the MutationObserver fires for the new rows.
+ tickers.splice(tickers.length, 0, ...newItems);
+ const newTbody = document.createElement("tbody");
+ blockSymbols.set(
+ newTbody,
+ newItems.map((i) => i.symbol),
+ );
+ const fragment = document.createDocumentFragment();
+ for (const { symbol, price } of newItems) {
+ const clone = template.content.cloneNode(true) as DocumentFragment;
+ const tr = clone.firstElementChild as HTMLTableRowElement;
+ tr.dataset.symbol = symbol;
+ const th = tr.querySelector("th");
+ const priceEl = tr.querySelector(".price");
+ if (th) th.textContent = symbol;
+ if (priceEl) priceEl.textContent = priceFormat.format(price);
+ fragment.append(tr);
+ }
+ newTbody.append(fragment);
+ table.append(newTbody);
+ // Start observing — if the new block is off-screen the observer
+ // will fire immediately and virtualize it.
+ blockObserver.observe(newTbody);
+ }),
+ ];
+ },
+);
diff --git a/src/module/todo/module-todo.css b/src/module/todo/module-todo.css
index a5063e2..4067e68 100644
--- a/src/module/todo/module-todo.css
+++ b/src/module/todo/module-todo.css
@@ -4,6 +4,14 @@ module-todo {
gap: var(--space-l);
container-type: inline-size;
+ &[filter="completed"] [data-container] li:not(:has([checked])) {
+ display: none;
+ }
+
+ &[filter="active"] [data-container] li:has([checked]) {
+ display: none;
+ }
+
> form {
display: flex;
flex-direction: column;
@@ -12,20 +20,6 @@ module-todo {
justify-content: space-between;
}
- > module-list {
- &[filter='completed'] li:not(:has([checked])) {
- display: none;
- }
-
- &[filter='active'] li:has([checked]) {
- display: none;
- }
-
- & li {
- border-bottom: 0;
- }
- }
-
& ol {
display: flex;
flex-direction: column;
@@ -34,19 +28,74 @@ module-todo {
margin: 0;
padding: 0;
+ &:empty {
+ display: none;
+ }
+
& li {
display: flex;
+ align-items: center;
justify-content: space-between;
gap: var(--space-m);
margin: 0;
padding: 0;
+
+ &.dragging {
+ position: fixed;
+ z-index: 100;
+ box-sizing: border-box;
+ background-color: var(--color-background);
+ opacity: var(--opacity-dimmed);
+ box-shadow: 0 var(--space-xxs) var(--space-s) var(--color-shadow);
+ padding-inline: var(--space-s);
+
+ & button.reorder:not(:disabled) {
+ cursor: grabbing;
+ }
+ }
+
+ &.drop-marker {
+ border: 2px dashed var(--color-selection);
+ border-radius: var(--space-xs);
+ background-color: var(--color-selection-hover);
+ padding: 0;
+ }
+ }
+ }
+
+ & button.reorder {
+ height: var(--input-height);
+ min-inline-size: var(--input-height);
+ border-radius: var(--space-xs);
+ background: none;
+ border: none;
+ padding: 0;
+
+ &:disabled {
+ opacity: var(--opacity-translucent);
+ }
+
+ &:not(:disabled) {
+ cursor: grab;
+ opacity: var(--opacity-solid);
+ color: var(--color-border);
+
+ &:hover {
+ background-color: var(--color-overlay-hover);
+ color: var(--color-text-soft);
+ }
+
+ &:active {
+ background-color: var(--color-overlay-active);
+ color: var(--color-text-soft);
+ }
}
}
> footer {
display: grid;
grid-template-columns: 1fr 1fr;
- grid-template-areas: 'filter filter' 'count clear';
+ grid-template-areas: "filter filter" "count clear";
align-items: center;
gap: var(--space-m);
margin: 0;
@@ -82,7 +131,7 @@ module-todo {
& footer {
grid-template-columns: 1fr 1fr 1fr;
- grid-template-areas: 'count filter clear';
+ grid-template-areas: "count filter clear";
}
}
}
diff --git a/src/module/todo/module-todo.mdx b/src/module/todo/module-todo.mdx
index 592d48e..7fabce8 100644
--- a/src/module/todo/module-todo.mdx
+++ b/src/module/todo/module-todo.mdx
@@ -5,7 +5,7 @@ import * as ModuleTodoStories from './module-todo.stories';
### Module Todo
-A full-featured to-do list orchestrator. Uses `createElementsMemo` to derive reactive `active` and `completed` item counts from live DOM queries. Demonstrates `pass()` to push `disabled` and `badge` into child components, `setAttribute('filter')` on `module-list` to drive CSS-based filtering, and event delegation on the form submit. Relies on `module-list`, `form-textbox`, `basic-button`, `basic-pluralize`, and `form-radiogroup` as descendant components.
+A full-featured to-do list orchestrator. Uses `createList` with `createStore` for reactive item data and `createMemo` to derive active and completed counts. Demonstrates `pass()` to push `disabled`, `badge`, `count`, and per-item `checked`/`value` signals into child components, `bindAttribute(host, 'filter')` to drive CSS-based filtering, drag-and-drop reordering via pointer events, and keyboard reordering via a live region.
#### Tag Name
@@ -39,39 +39,69 @@ None. This component orchestrates child component properties through effects.
first('form-textbox')
- Component<FormTextboxProps>
+ HTMLElement & FormTextboxProps
required
Text input for the new todo; cleared after each add; its length drives the submit button's disabled state
first('basic-button.submit')
- Component<BasicButtonProps>
+ HTMLElement & BasicButtonProps
required
Submit button; disabled when textbox is empty via pass()
-
- first('module-list')
- Component<ModuleListProps>
- required
- List of todo items; receives the current filter value as a filter attribute
-
first('basic-pluralize')
- Component<BasicPluralizeProps>
+ HTMLElement & BasicPluralizeProps
required
Displays the count of active (incomplete) items via pass({ count })
first('form-radiogroup')
- Component<FormRadiogroupProps>
+ HTMLElement & FormRadiogroupProps
required
Filter control (All / Active / Completed); its value is passed as the filter attribute to the list
first('basic-button.clear-completed')
- Component<BasicButtonProps>
+ HTMLElement & BasicButtonProps
required
Removes all completed items on click; disabled when there are none; shows completed count as badge
+
+ first('[data-container]')
+ HTMLElement
+ required
+ Container element for todo item children; items are inserted, reordered, and removed here
+
+
+ first('template')
+ HTMLTemplateElement
+ required
+ Template cloned to create each new todo item DOM element
+
+
+ first('[role="status"]')
+ HTMLElement
+ required
+ ARIA live region; announces drag/keyboard reorder position changes to assistive technology
+
+
+ all('button.reorder')
+ Memo<HTMLButtonElement[]>
+ required
+ Drag handle and keyboard reorder buttons; disabled via each() + pass() when only one item remains
+
+
+ all('form-checkbox')
+ Memo<HTMLElement[]>
+ required
+ Per-item checkbox components; each receives its item's completed state signal directly via pass()
+
+
+ all('form-inplace-edit')
+ Memo<HTMLElement[]>
+ required
+ Per-item inline editors; each receives its item's label state signal directly via pass()
+
diff --git a/src/module/todo/module-todo.stories.ts b/src/module/todo/module-todo.stories.ts
index 7698a8c..60af882 100644
--- a/src/module/todo/module-todo.stories.ts
+++ b/src/module/todo/module-todo.stories.ts
@@ -8,21 +8,23 @@ import "../../basic/button/basic-button.css";
import "../../basic/pluralize/basic-pluralize.ts";
import "../../form/checkbox/form-checkbox.ts";
import "../../form/checkbox/form-checkbox.css";
+import "../../form/inplace-edit/form-inplace-edit.ts";
+import "../../form/inplace-edit/form-inplace-edit.css";
import "../../form/radiogroup/form-radiogroup.ts";
import "../../form/radiogroup/form-radiogroup.css";
import "../../form/textbox/form-textbox.ts";
import "../../form/textbox/form-textbox.css";
-import "../../module/list/module-list.ts";
-import "../../module/list/module-list.css";
const todoTemplate = html`
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- ✕
-
-
-
-
-
+
+
+
+
+
+ ≡
+
+
+
+
+
+ ✎
+
+
+
+
+ ✕
+
+
+
+
Well done, all done!
@@ -63,15 +77,31 @@ const todoTemplate = html`
Filter
-
+
All
-
+
Active
-
+
Completed
diff --git a/src/module/todo/module-todo.ts b/src/module/todo/module-todo.ts
index cf9cc79..a3d609e 100644
--- a/src/module/todo/module-todo.ts
+++ b/src/module/todo/module-todo.ts
@@ -1,93 +1,345 @@
import {
- type Component,
- type ComponentProps,
- createElementsMemo,
+ bindAttribute,
+ bindProperty,
+ bindText,
+ createList,
+ createMemo,
+ createState,
+ createStore,
defineComponent,
- on,
- pass,
- setAttribute,
+ each,
+ type Store,
} from "@zeix/le-truc";
-import type { BasicButtonProps } from "../../basic/button/basic-button";
-import type { BasicPluralizeProps } from "../../basic/pluralize/basic-pluralize";
-import type { FormRadiogroupProps } from "../../form/radiogroup/form-radiogroup";
-import type { FormTextboxProps } from "../../form/textbox/form-textbox";
-import type { ModuleListProps } from "../list/module-list";
-
-type ModuleTodoUI = {
- form: HTMLFormElement;
- textbox: Component;
- submit: Component;
- list: Component;
- count: Component;
- filter: Component;
- clearCompleted: Component;
+
+export type TodoItem = {
+ id: string;
+ label: string;
+ createdAt: Date;
+ completed: boolean;
};
declare global {
interface HTMLElementTagNameMap {
- "module-todo": Component;
+ "module-todo": HTMLElement;
}
}
-export default defineComponent(
+const DRAG_THRESHOLD = 5;
+const REORDER_CLASS = "reorder";
+const REORDER_SELECTOR = `button.${REORDER_CLASS}`;
+const DRAGGING_CLASS = "dragging";
+
+let idCounter = 0;
+
+export default defineComponent(
"module-todo",
- {},
- ({ first }) => ({
- form: first("form", "Add a form element to enter a new todo item."),
- textbox: first(
+ ({ all, first, host, on, pass, watch }) => {
+ const form = first("form", "Add a form element to enter a new todo item.");
+ const textbox = first(
"form-textbox",
"Add component to enter a new todo item.",
- ),
- submit: first(
+ );
+ const submit = first(
"basic-button.submit",
"Add component to submit the form.",
- ),
- list: first(
- "module-list",
- "Add component to display a list of todo items.",
- ),
- count: first(
+ );
+ const container = first(
+ "[data-container]",
+ "Add a container element for items.",
+ );
+ const template = first("template", "Add a template element for items.");
+ const liveRegion = first(
+ '[role="status"]',
+ "Add a live region for status messages.",
+ );
+ const count = first(
"basic-pluralize",
"Add component to display the number of todo items.",
- ),
- filter: first(
+ );
+ const filter = first(
"form-radiogroup",
"Add component to filter todo items.",
- ),
- clearCompleted: first(
+ );
+ const clearCompleted = first(
"basic-button.clear-completed",
"Add component to clear completed todo items.",
- ),
- }),
- ({ textbox, list, filter }) => {
- const active = createElementsMemo(list, "form-checkbox:not([checked])");
- const completed = createElementsMemo(list, "form-checkbox[checked]");
-
- return {
- form: on("submit", (e) => {
+ );
+ const reorderButtons = all(REORDER_SELECTOR);
+ const checkboxComponents = all("form-checkbox");
+ const editComponents = all("form-inplace-edit");
+
+ const list = createList>([], {
+ keyConfig: (item) => item.id,
+ createItem: createStore,
+ });
+
+ const completedCount = createMemo(
+ () => list.get().filter((item) => item.completed).length,
+ );
+ const activeCount = createMemo(() => list.length - completedCount.get());
+ const status = createState(liveRegion.textContent);
+
+ let selectedItem: HTMLElement | null = null;
+ let dragItem: HTMLElement | null = null;
+ let marker: HTMLElement | null = null;
+ let dragOffsetY = 0;
+ let pendingDragHandle: HTMLElement | null = null;
+ let pointerStartY = 0;
+ let pointerStartX = 0;
+ let suppressNextClick = false;
+
+ const getItemText = (item: HTMLElement): string =>
+ item.querySelector("label.text")?.textContent?.trim() ?? "item";
+
+ const selectItem = (item: HTMLElement | null) => {
+ selectedItem = item;
+ if (item)
+ status.set(
+ `${getItemText(item)} selected, position ${Array.from(container.children).indexOf(item) + 1} of ${list.length}. Press Up or Down arrow to move.`,
+ );
+ };
+
+ const moveItem = (item: HTMLElement, direction: -1 | 1) => {
+ const items = Array.from(container.children);
+ const newIdx = items.indexOf(item) + direction;
+ if (newIdx < 0 || newIdx >= items.length) return;
+ if (direction === 1) items[newIdx]?.after(item);
+ else items[newIdx]?.before(item);
+ const newPos = Array.from(container.children).indexOf(item) + 1;
+ status.set(
+ `${getItemText(item)} moved to position ${newPos} of ${list.length}.`,
+ );
+ item.querySelector(REORDER_SELECTOR)?.focus();
+ reorderList();
+ };
+
+ const updateMarkerPosition = (clientY: number) => {
+ if (!marker || !dragItem) return;
+ const items = Array.from(container.children).filter(
+ (c) => c !== marker && c !== dragItem,
+ ) as HTMLElement[];
+ let insertBefore: Element | null = null;
+ for (const child of items) {
+ const rect = child.getBoundingClientRect();
+ if (clientY < rect.top + rect.height / 2) {
+ insertBefore = child;
+ break;
+ }
+ }
+ if (insertBefore) container.insertBefore(marker, insertBefore);
+ else container.appendChild(marker);
+ };
+
+ const reorderList = () => {
+ const keys = Array.from(container.children)
+ .filter((el) => el instanceof HTMLElement && el.dataset.key)
+ .map((el) => (el as HTMLElement).dataset.key);
+ list.update((prev) => {
+ const byKey = new Map(prev.map((item, i) => [list.keyAt(i), item]));
+ return keys.map((k) => byKey.get(k)).filter(Boolean) as TodoItem[];
+ });
+ };
+
+ return [
+ pass(submit, { disabled: () => !textbox.length }),
+ pass(count, { count: () => activeCount.get() }),
+ pass(clearCompleted, {
+ disabled: () => !completedCount.get(),
+ badge: () => (completedCount.get() ? String(completedCount.get()) : ""),
+ }),
+
+ each(reorderButtons, (button) => {
+ return watch(() => list.length === 1, bindProperty(button, "disabled"));
+ }),
+
+ each(checkboxComponents, (checkbox) => {
+ const key = checkbox.closest("[data-key]")?.dataset.key;
+ if (!key || !checkbox.isConnected) return;
+ return pass(checkbox, {
+ checked: {
+ get: () => list.byKey(key)?.completed.get() ?? false,
+ set: (checked: boolean) => list.byKey(key)?.completed.set(checked),
+ },
+ });
+ }),
+
+ each(editComponents, (editEl) => {
+ const key = editEl.closest("[data-key]")?.dataset.key;
+ if (!key || !editEl.isConnected) return;
+ return pass(editEl, {
+ value: {
+ get: () => list.byKey(key)?.label.get() ?? "",
+ set: (value: string) => list.byKey(key)?.label.set(value),
+ },
+ });
+ }),
+
+ watch(
+ () => Array.from(list.keys()),
+ (keys) => {
+ const current = new Map();
+ for (const child of container.children) {
+ const el = child as HTMLElement;
+ if (el.dataset.key) current.set(el.dataset.key, el);
+ }
+
+ const keysSet = new Set(keys);
+
+ for (const [key, el] of current) {
+ if (!keysSet.has(key)) el.remove();
+ }
+
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i];
+ let el = key && current.get(key);
+ if (key && !el) {
+ const fragment = template.content.cloneNode(
+ true,
+ ) as DocumentFragment;
+ el = fragment.firstElementChild as HTMLElement;
+ el.dataset.key = key;
+ const id = `${key}-checkbox`;
+ const checkbox = el.querySelector("input");
+ if (checkbox) checkbox.id = id;
+ const label = el.querySelector("label");
+ if (label) label.htmlFor = id;
+ const text = el.querySelector("slot");
+ if (text)
+ text.replaceWith(
+ document.createTextNode(list.byKey(key)?.label.get() ?? ""),
+ );
+ }
+ const currentAtI = container.children[i];
+ if (el && currentAtI !== el)
+ container.insertBefore(el, currentAtI ?? null);
+ }
+ },
+ ),
+
+ on(form, "submit", (e) => {
e.preventDefault();
- const value = textbox.value.trim();
- if (!value) return;
- list.add((item) => {
- item.querySelector("slot")?.replaceWith(value);
+ const label = textbox.value.trim();
+ if (!label) return;
+ list.add({
+ id: `todo${++idCounter}`,
+ label,
+ createdAt: new Date(),
+ completed: false,
});
textbox.clear();
}),
- submit: pass({ disabled: () => !textbox.length }),
- list: setAttribute("filter", () => filter?.value || "all"),
- count: pass({ count: () => active.get().length }),
- clearCompleted: [
- pass({
- disabled: () => !completed.get().length,
- badge: () =>
- completed.get().length ? String(completed.get().length) : "",
- }),
- on("click", () => {
- const items = completed.get();
- for (let i = items.length - 1; i >= 0; i--)
- items[i]?.closest("li")?.remove();
- }),
- ],
- };
+
+ on(host, "click", (e) => {
+ if (suppressNextClick) {
+ suppressNextClick = false;
+ return;
+ }
+ const target = e.target as HTMLElement;
+ const item = target.closest("[data-key]");
+ if (!(item instanceof HTMLElement)) return;
+
+ if (target.closest("basic-button.remove")) {
+ e.stopPropagation();
+ if (item === selectedItem) selectItem(null);
+ const key = item.dataset.key;
+ if (key) list.remove(key);
+ } else if (target.closest(REORDER_SELECTOR)) {
+ selectItem(item);
+ }
+ }),
+
+ on(host, "keydown", (e) => {
+ if (!selectedItem) return;
+ const target = e.target as HTMLElement;
+ if (!target.classList.contains(REORDER_CLASS)) return;
+ if (e.key === "Escape") {
+ selectItem(null);
+ return;
+ }
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
+ e.preventDefault();
+ if (e.key === "ArrowUp") moveItem(selectedItem, -1);
+ else moveItem(selectedItem, 1);
+ }),
+
+ on(host, "pointerdown", (e) => {
+ const handle = (e.target as HTMLElement).closest(REORDER_SELECTOR);
+ if (!(handle instanceof HTMLElement)) return;
+ const item = handle.closest("[data-key]");
+ if (!(item instanceof HTMLElement)) return;
+ e.preventDefault();
+ pendingDragHandle = handle;
+ pointerStartY = e.clientY;
+ pointerStartX = e.clientX;
+ suppressNextClick = false;
+ handle.setPointerCapture(e.pointerId);
+ handle.focus();
+ }),
+
+ on(host, "pointermove", (e) => {
+ if (!pendingDragHandle) return;
+ const dy = Math.abs(e.clientY - pointerStartY);
+ const dx = Math.abs(e.clientX - pointerStartX);
+
+ if (!dragItem && (dy > DRAG_THRESHOLD || dx > DRAG_THRESHOLD)) {
+ const item = pendingDragHandle.closest("[data-key]");
+ if (!(item instanceof HTMLElement)) return;
+
+ dragItem = item;
+ const rect = item.getBoundingClientRect();
+ dragOffsetY = pointerStartY - rect.top;
+
+ marker = document.createElement("li");
+ marker.className = "drop-marker";
+ marker.style.height = `${rect.height - 4}px`;
+ container.insertBefore(marker, item);
+
+ item.style.top = `${rect.top}px`;
+ item.style.left = `${rect.left}px`;
+ item.style.width = `${rect.width}px`;
+ item.classList.add(DRAGGING_CLASS);
+ }
+
+ if (dragItem) {
+ dragItem.style.top = `${e.clientY - dragOffsetY}px`;
+ updateMarkerPosition(e.clientY);
+ }
+ }),
+
+ on(host, "pointerup", () => {
+ if (dragItem && marker) {
+ marker.replaceWith(dragItem);
+ dragItem.style.cssText = "";
+ dragItem.classList.remove(DRAGGING_CLASS);
+ dragItem = null;
+ marker = null;
+ suppressNextClick = true;
+ reorderList();
+ }
+ pendingDragHandle = null;
+ }),
+
+ on(host, "pointercancel", () => {
+ if (dragItem && marker) {
+ marker.remove();
+ dragItem.style.cssText = "";
+ dragItem.classList.remove(DRAGGING_CLASS);
+ dragItem = null;
+ marker = null;
+ }
+ pendingDragHandle = null;
+ suppressNextClick = false;
+ }),
+
+ on(clearCompleted, "click", () => {
+ for (let i = list.length - 1; i >= 0; i--) {
+ const key = list.keyAt(i);
+ if (key && list.byKey(key)?.completed.get()) list.remove(key);
+ }
+ }),
+
+ watch(() => filter.value || "all", bindAttribute(host, "filter")),
+ watch(status, bindText(liveRegion, true)),
+ ];
},
);