Changes
9 changed files (+324/-40)
-
-
@@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-onlyexport interface UIComponent< TagName extends string, Element extends CustomElementConstructor, Element extends typeof YamoriComponent, > { readonly tagName: TagName; readonly constructor: Element;
-
@@ -15,10 +15,10 @@ * CustomElement を使いやすい I/F にラップする。* 渡された CustomElement は自動的に `nonUAMixin` が適用され、 * `:is(:state(nonua), :--nonua, [nonua])` でクエリできるようになる。 */ export function component< TagName extends string, Element extends CustomElementConstructor, >(tagName: TagName, constructor: Element): UIComponent<TagName, Element> { export function component<TagName extends string, Element extends typeof YamoriComponent>( tagName: TagName, constructor: Element, ): UIComponent<TagName, Element> { return { tagName, constructor,
-
@@ -27,14 +27,16 @@ if (customElements.get(tagName)) {return false; } customElements.define(tagName, nonUAMixin(constructor)); customElements.define(tagName, constructor); return true; }, }; } export const INTERNALS = Symbol("yamori.gui.components._utils.internals"); /** * Custom Element を "nonua" というキーワードで CSS からセレクトできるようにする mixin 。 * このプロジェクトで定義する全ての CustomElement の基底クラス。 * * CSS では未だにビルトインのタグとユーザ定義のタグをセレクトできず、また UA のスタイルを * 無効化する仕様も存在しない。そのため、UA が勝手に定義したスタイルを削除するためには、
-
@@ -48,7 +50,7 @@ * 付与するマーカーは以下の通り:* * A) CustomStateSet の `nonua` ステート (Evergreen Browser) * B) CustomStateSet の `--nonua` ステート (Chromium v90 <= x < v125) * C) `nonua` 属性 * C) `x--_nonua` 属性 * * 全てのマーカーに対応するには以下のようなセレクタが必要。 *
-
@@ -56,22 +58,43 @@ * ```css* :is(:state(nonua), :--nonua, [nonua]) * ``` */ function nonUAMixin<T extends CustomElementConstructor>(ctor: T): T { return class WithNonUA extends ctor { constructor(...params: any[]) { super(...params); export class YamoriComponent extends HTMLElement { protected [INTERNALS]: ElementInternals; protected setCustomState(name: string): void { const internals = this[INTERNALS]; if (internals && internals.states) { // https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet#compatibility_with_dashed-ident_syntax try { internals.states.add(name); } catch { internals.states.add(`--${name}`); } } else { this.setAttribute(`--_${name}`, ""); } } protected removeCustomState(name: string): void { const internals = this[INTERNALS]; const internals = this.attachInternals(); if (internals && internals.states) { // https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet#compatibility_with_dashed-ident_syntax try { internals.states.add("nonua"); } catch { internals.states.add("--nonua"); } } else { this.setAttribute("nonua", ""); if (internals && internals.states) { try { internals.states.delete(name); } catch { internals.states.delete(`--${name}`); } } else { this.removeAttribute(`--_${name}`); } }; } constructor() { super(); this[INTERNALS] = this.attachInternals(); this.setCustomState("nonua"); } }
-
-
-
@@ -3,21 +3,112 @@ * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com>* SPDX-License-Identifier: AGPL-3.0-only */ :not(:is(style, script)) { all: unset; box-sizing: border-box; } :host { display: flex; padding: var(--space-px-3); border: 1px solid oklch(var(--color-fg)); position: relative; padding: var(--space-px-2); border: 1px solid oklch(var(--color-border) / var(--alpha-border-medium)); background-color: oklch(var(--color-fg)); border-radius: 3px; color: oklch(var(--color-bg)); background-image: linear-gradient( to right bottom, oklch(100% 0% 0deg / 30%), oklch(0% 0% 0deg / 5%) ); background-color: oklch(var(--color-bg)); border-radius: 5px; color: oklch(var(--color-fg) / var(--alpha-fg-medium)); user-select: none; cursor: pointer; } :host([inline]) { display: inline-flex; } :host(:focus-visible) { border-color: tomato; :host(:is(:disabled, [aria-disabled="true"])) { color: oklch(var(--color-fg) / var(--alpha-fg-subtle)); cursor: not-allowed; text-decoration: line-through; } :host(:is(:state(pending), :--pending, [x--_pending])) { color: transparent; } @media (hover: hover) { :host(:not(:is(:disabled, [aria-disabled="true"], :state(pending), :--pending, [x--_pending])):hover) { border-color: oklch(var(--color-border) / var(--alpha-border-strong)); } } :host(:focus-visible:focus-visible), :host(:has(:focus-visible:focus-visible)) { border-color: oklch(var(--color-focus)); box-shadow: 0 0 0 var(--size-focus-ring) oklch(var(--color-focus) / var(--alpha-focus-ring)); outline: none; } :host(:enabled:not(:is([aria-disabled="true"], :state(pending), :--pending, [x--_pending])):is(:active, :state(pressed), :--pressed, [x--_pressed])) { background-image: linear-gradient( to left top, oklch(100% 0% 0deg / 30%), oklch(0% 0% 0deg / 5%) ); } .inner { flex: 1; padding: var(--space-px-3) var(--space-px-5); border: 1px solid oklch(var(--color-fg) / 5%); background-image: linear-gradient( to left top, oklch(100% 0% 0deg / 10%), oklch(0% 0% 0deg / 3%) ); background-color: inherit; border-radius: inherit; text-align: center; } :host(:not(:is(:disabled, [aria-disabled="true"], :state(pending), :--pending, [x--_pending])):is(:active, :state(pressed), :--pressed, [x--_pressed])) .inner { transform: translateY(1px); filter: brightness(0.99); } .spinner { display: none; position: absolute; top: 0; right: 0; bottom: 0; left: 0; margin: auto; width: var(--font-md); height: var(--font-md); border: 2px solid oklch(var(--color-fg) / var(--alpha-fg-medium)); border-top-color: transparent; border-radius: 50%; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } :host(:is(:state(pending), :--pending, [x--_pending])) .spinner { display: block; animation: 1.3s 0s forwards infinite ease-in-out spin; }
-
-
-
@@ -1,16 +1,59 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { component } from "../_utils"; import { component, INTERNALS, YamoriComponent } from "../_utils"; import css from "./button.css?inline"; type ObservedAttributes = readonly ["pending"]; /** * 表示サンプル用コンポーネント。 */ export const YamoriButton = component( "yamori-button", class extends HTMLElement { class extends YamoriComponent { static formAssociated = true; static get observedAttributes(): ObservedAttributes { return ["pending"]; } #label: HTMLSpanElement; #spinner: HTMLDivElement; #pending: boolean = this.hasAttribute("pending"); attributeChangedCallback( name: ObservedAttributes[number], oldValue: string | null, newValue: string | null, ) { if (oldValue === newValue) { return; } switch (name) { case "pending": this.setPending(typeof newValue === "string"); return; } } setPending(value: boolean) { this.#pending = value; if (value) { this.setCustomState("pending"); // NOTE: `aria-busy` は Live Region が更新中かどうかのフラグであり、 // ボタンといった内容の変わらないトリガーの状態表示としては不適切 // であるため弄らない。 this[INTERNALS].ariaDisabled = "true"; } else { this.removeCustomState("pending"); this[INTERNALS].ariaDisabled = "false"; } } constructor() { super();
-
@@ -22,13 +65,83 @@ const style = document.createElement("style");style.textContent = css; shadow.appendChild(style); const span = document.createElement("span"); shadow.appendChild(span); this.#label = document.createElement("span"); this.#label.classList.add("inner"); shadow.appendChild(this.#label); const slot = document.createElement("slot"); span.appendChild(slot); this.#label.appendChild(slot); this.#spinner = document.createElement("div"); this.#spinner.classList.add("spinner"); shadow.appendChild(this.#spinner); this.tabIndex = 0; this[INTERNALS].role = "button"; } connectedCallback() { this.addEventListener("click", this.#onClick, { capture: true, }); this.addEventListener("keydown", this.#onKeyDown); this.addEventListener("keyup", this.#onKeyUp); } disconnectedCallback() { this.removeEventListener("click", this.#onClick, { capture: true, }); this.removeEventListener("keydown", this.#onKeyDown); this.removeEventListener("keyup", this.#onKeyUp); } get disabled(): boolean { return this.hasAttribute("disabled"); } #isEnabled = (): boolean => { return ( !this.#pending && !this.hasAttribute("disabled") && this.ariaDisabled !== "true" ); }; #onClick = (event: MouseEvent): void => { if (!this.#isEnabled()) { event.preventDefault(); event.stopPropagation(); return; } }; #onKeyDown = (event: KeyboardEvent): void => { if (!this.#isEnabled() || event.key !== " ") { event.preventDefault(); return; } this.setCustomState("pressed"); }; #onKeyUp = (event: KeyboardEvent): void => { if (!this.#isEnabled() || event.key !== " ") { event.preventDefault(); return; } this.removeCustomState("pressed"); // `clientX/Y` は `getBoundingClientRect()` を使うことで計算できるが、 // レイアウトにアクセスするためコストがかかる。現状これらのプロパティは // 利用していないためスキップしている。 this.dispatchEvent( new MouseEvent("click", { view: event.view, bubbles: true, cancelable: true, }), ); }; }, );
-
-
-
@@ -1,7 +1,7 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { component } from "../../components/_utils.ts"; import { component, YamoriComponent } from "../../components/_utils.ts"; import base from "../../vars/base.css?inline"; import dark from "../../vars/dark.css?inline";
-
@@ -10,7 +10,7 @@ import darkContrast from "../../vars/dark-contrast.css?inline";export const DocsThemeOverride = component( "docs-theme-override", class DocsThemeOverride extends HTMLElement { class DocsThemeOverride extends YamoriComponent { static get observedAttributes() { return ["color-scheme", "contrast"] as const; }
-
-
-
@@ -8,6 +8,16 @@ import Preview from "../../components/Preview.astro";import ThemeMatrixPreview from "../../components/ThemeMatrixPreview.astro"; --- <script> const buttons = document.querySelectorAll("yamori-button"); buttons.forEach((button) => { button.addEventListener("click", (event) => { console.log(event); }); }); </script> <DocsLayout title="Button"> <Document> <p>
-
@@ -34,5 +44,35 @@ </Preview><Preview title="block (デフォルト)"> <yamori-button>Button</yamori-button> </Preview> <h2>Disabled</h2> <p> <code>yamori-button</code> は form-associated custom element であるため、 <code>disabled</code> 属性がそのまま使えます。 </p> <ThemeMatrixPreview> <yamori-button disabled>ボタン</yamori-button> </ThemeMatrixPreview> <p> ただ、<code>disabled</code> 属性はフォーカスができないというアクセシビリティにおける大きな欠陥仕様があります。 <code>aria-disabled</code> 属性を可能な限り使いましょう。 </p> <Preview title="aria-disabled"> <yamori-button aria-disabled="true">ボタン</yamori-button> </Preview> <h2>Pending</h2> <p> ボタンの押下に伴うアクションの実行中やアクションに必要な準備中でボタンが押せない場合は、 <code>pending</code> 属性を指定することで読込中表示にすることができます。 </p> <ThemeMatrixPreview> <yamori-button pending="">ボタン</yamori-button> </ThemeMatrixPreview> <p> ユーザインタラクションに起因する読込中表示の場合は明確ですが、そうでない場合は「どうして読込中表示なのか」「何を読み込んでいるのか」が分かりづらくなりがちです。可能な限り <code>title</code> 属性や補助テキストを使って状況を説明しましょう。 </p> </Document> </DocsLayout>
-
-
-
@@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-only*/ /* UA スタイルを全部消す */ :where(body, body :not(svg, svg *, :is([nonua], :--nonua, :state(nonua)))) { :where(body, body :not(svg, svg *, :is([x--_nonua], :--nonua, :state(nonua)))) { all: unset; box-sizing: border-box; }
-
-
-
@@ -12,8 +12,8 @@/* Plastic Ratio の近似値 */ --scale: calc(53 / 40); --hue: 260deg; --chroma: 3%; --hue: 30deg; --chroma: 0.5%; --color-bg-l: 99%; --color-bg: var(--color-bg-l) var(--chroma) var(--hue);
-
@@ -30,6 +30,12 @@ --alpha-border-strong: 50%;--alpha-border-medium: 25%; --alpha-border-subtle: 10%; --color-focus-l: 60%; --color-focus-c: 40%; --color-focus-h: 260deg; --color-focus: var(--color-focus-l) var(--color-focus-c) var(--color-focus-h); --alpha-focus-ring: 30%; /* pow() が広く実装されて1年程度しか経っていないため我慢 */ --space-px-1: 2px; --space-px-2: calc(var(--space-px-1) * var(--scale));
-
@@ -51,6 +57,8 @@ --space-px-17: calc(var(--space-px-16) * var(--scale));--space-px-18: calc(var(--space-px-17) * var(--scale)); --space-px-19: calc(var(--space-px-18) * var(--scale)); --space-px-20: calc(var(--space-px-19) * var(--scale)); --size-focus-ring: var(--space-px-4); --font-md: 1rem; --font-lg: calc(var(--font-md) * var(--scale));
-
-
-
@@ -16,4 +16,7 @@ --color-border-l: 3%;--alpha-border-strong: 90%; --alpha-border-medium: 50%; --alpha-border-subtle: 30%; --alpha-focus-ring: 100%; --size-focus-ring: var(--space-px-1); }
-
-
-
@@ -8,6 +8,12 @@:root, :host { --color-bg-l: 15%; --color-fg-l: 95%; --alpha-fg-medium: 92%; --color-border-l: 90%; --color-focus-l: 70%; --alpha-focus-ring: 70%; }
-