🛝

Svelte5で汎用コンポーネントを作成したメモ

2024/09/12に公開


Svelte5を本格的に使用するにあたり、プロジェクト間で使いまわせる汎用コンポーネントがやはり欲しくなりました。既にSvelte5でも動作するライブラリはあるようですが、後述する理由により自分で作ることにしました。後からどういう思想でなぜ作ったのか等思い出すための備忘メモです。自分の思考を文字にしながら整理している節があるので、ちょっと関係ない部分もあります。フロントエンドは古の基礎知識が多少あったとはいえ、現代の知識ほぼ0から2-3ヶ月程度で試作アプリを作りながら作ったにしてはまだマシなものができたと思います。

前提

個人的な思想

  1. システム・プログラム・コードには環境変化に適応するための保守が必須
  2. 学習コストをかけた知識等はできるだけ長く実用的に使いたい
    • 長く使用できる物への初期コストは惜しまないが、時間投資のリターンは最大化したい
    • (実用不能になっても抽象的知識は使いまわせるが、コスパが良いと考えていない)
  3. サードパーティーのフレームワークやライブラリは極力使いたくない
    • 基本的に保守が長期間維持されるかどうかが運
    • 技術トレンドや組織の技術力の話もあるが、焦点は組織の経済力
      • 経済力のない組織が保守を維持し続けることは基本的に不可能
        • 善意により成り立っている物も多くあるが、それを当然とは考えたくない
    • 採用する場合は以下を覚悟し念頭に置き続ける
      • 更新情報を追い続ける維持コスト
      • 保守されなくなった場合の置換コスト
      • 置換コストに見合わないこれからの成果物を将来切り捨てる可能性
    • (そういう意味ではRustのCrates選定も怖い)
  4. 必須事項以外は極力学習コストをかけたくない
    • 本当はHTML,CSS,JS以外は使いたくない
      • TSは今やほぼ型機能だけのような気がするので型推論JSとして使用する (JSにほしいな)
    • 今の成果物に必要なレベルをそれらだけで実現するのは時間と保守を考えると非現実的
      • これを解決するためにSvelte5を導入
      • HTML,CSS,JSに似ており独自事項が比較的少なめで実行時コスト(仮想DOM)もない
  5. 基本的にSSGを用いる (SSRは使用しない)
    • 鮮度が重要なコンテンツは設計段階で切り分けて対応する
    • クライアントとサーバーは役割が全く違うと認識している
      • クライアントはそのサービスにおける究極に使いやすいUIを備えたリクエスター
      • サーバーはcurl等を含む全てのリクエストを適切に処理するレスポンダー
    • 役割が全く違うので完全に切り分けて思考したい
      • また、役割が違うので最適な言語も異なると思っている
        • サーバーにはグリーンスレッドを持つGoやRust等が適している気がする

自分で作る理由

  1. Svelte5,TS(JS)等の学習を深めるため
  2. 使い方や設計思想を追加的に学習する必要があるライブラリ導入は避けたいため
  3. 抽象化レイヤーが同じ層のブラックボックスを増やさないため
    • バージョンアップの影響など保守コストが増える
    • 初期コストは容認し、維持コストは極力抑えたい
      • ただし汎用コンポーネントのため次回以降初期コストは激減する(投資的である)前提
  4. ちょっと変えたい時に変更可能にするため
    • 一度作ったUIライブラリが100%どんな時でも再利用可能だと思えない

方針

簡単な要件

  • プロジェクト内容に応じて柔軟にスタイルを変化可能
  • 簡単・気軽に使用できる
    • 標準で欲しいような内容は機能に盛り込んでおく
  • ほとんどの場合で汎用コンポーネント内部を変更しなくて済む
  • 汎用コンポーネント自体の保守性もある程度担保する

要件のために実現する内容

  1. 共通処理以外はimportしない
    • 共通の処理はtsファイルとして抽出する
    • 依存性など考えずに使用できるようにする
      • アトミックデザインでいうAtomsのようなイメージ
      • Atoms+独自機能のMoleculesのような汎用コンポーネントは別途作成する
        • 命名規則で明確に分離する
        • この記事では取り扱わない
  2. コンポーネントの構成部位を明確にする
    • 操作と結果をできる限り簡単に予測可能にする
    • 構成部位に一貫性のある名称を付ける
    • 構成部位が何のタグでどういう関係性か簡潔に図でも表現する
    • 構成部位にコンテンツを注入する
  3. スタイルの事前定義はしない
    • 後からスタイルを注入できるようにする
      • 構成部位にそれぞれ適用可能にする
    • プロジェクト毎に標準とするスタイルを定義し、自動で適用する
      • 別スタイルを適用したい場所では個別に適用可能とする
      • 重複したスタイル定義はできるだけ省略可能にする
    • 標準とするスタイルはできるだけ1か所で定義する
    • ダーク/ライトテーマ等の色切り替えを可能にする
  4. ステータスを定義する
    • コンポーネント状態によってステータスを自動遷移させる
      • 簡単に使用するために自動遷移は複雑になりすぎないようにする
      • 自動遷移はコンポーネントにより異なる
      • 把握しやすいように状態遷移図でも表現する
    • ステータスに応じてスタイルを切り替える
      • 気軽に使えるよう値検証前後でのスタイル変更のコード等を不要にする
    • 外からステータスを完全に制御できるようにする
      • 自動遷移は補助的なものという位置付け
        • 忘れても使えるようにする
      • 別コンポーネントの一部としても使用可能にする
  5. 値入力が可能な場合、自身で値検証させる
    • 入力後に自律的に値を検証する
      • 検証結果に応じてステータスを変化させる
    • 外部から任意タイミングで検証をトリガすることも可能とする
    • 値の検証内容は関数として注入できるようにする
      • 自律検証時,外部トリガ時で検証内容を切り替え可能とする
  6. 透過的にタグ属性やイベントを定義可能とする
    • メインとなるタグには透過的にタグ属性・イベントを適用可能にする
      • 例: TextFieldコンポーネントの<input type="text" />タグ等
    • 明確なメインとなるタグがない場合は省略

少し詳細な実現内容

TextFieldコンポーネントとAccordionコンポーネントを例示。

コンポーネント間の共通処理

  • 共通のものは以下として抽出する
    • 固定値: $lib/const.ts
    • 共通処理: $lib/util.ts
    • 標準スタイル定義: $lib/style.ts
    • 上記で使用する型: $lib/type.d.ts
  • Atomsとしての汎用コンポーネントファイル名は"_"で開始させる
    • 例: _TextField.svelte

構成部位とコンテンツ注入

  • 以下の構成部位名称を定義し、各汎用コンポーネントで必要な部位を選択的に使用する
    1. whole: コンポーネントの一番外側の枠組み
    2. middle: コンポーネントの中間の枠組み
    3. main: コンポーネントのメインタグ,メインの枠組み
    4. top: コンポーネントの上側の部位
    5. left: コンポーネントの左側の部位
    6. right: コンポーネントの右側の部位
    7. bottom: コンポーネントの下側の部位
    8. label: コンポーネントのラベル表示部位
    9. req: コンポーネントの条件表示部位
    10. aux: コンポーネントの補助的な部位
    • 実際の定義
    code
    const.ts
    export const PART_TUPLE = [
      "whole",
      "middle",
      "main",
      "top",
      "left",
      "right",
      "bottom",
      "label",
      "req",
      "aux",
    ] as const;
    
  • コンポーネント構成図
    img_structure_diagram
  • Propsに上記構成部位名を定義し、その部位にコンテンツを注入可能とする
    • ただし簡単に使用するため一部部位をchildrenとして受ける場合もある
    • 気軽に使用するために必須のPropsは極力使用しない
    • 簡単に使用するために必要に応じてstring | Snippet型を使用する
    • 実際の定義例
    sample code
    _TextField.svelte
    export type Props = {
      label?: string,
      req?: string | Snippet,
      aux?: string | Snippet,  // bindable
      left?: string | Snippet,
      right?: string | Snippet,
      bottom?: string,  // bindable
      status?: State,  // bindable, [STATE.DEFAULT]
      value?: string,  // bindable, [""]
      type?: "email" | "password" | "search" | "tel" | "text" | "url" | "number" | "area",  // bindable (to switch password / text), ["text"]
      placeholder?: string,
      options?: string[],  // bindable
      test?: () => boolean,  // bindable
      validation?: (value: string, auto?: boolean) => [boolean, string?, (string | Snippet)?],
      style?: DefineStateStyle | DefineStyle,
      action?: Action,
      events?: EventSet,
      attributes?: HTMLAttributes<HTMLInputElement> | HTMLAttributes<HTMLTextAreaElement>;
      element?: HTMLInputElement | HTMLTextAreaElement,  // bindable
    };
    
    _Accordion.svelte
    export type Props = {
      label: string | Snippet,
      children: Snippet,
      status?: State,  // bindable, [STATE.DEFAULT]
      open?: boolean,  // bindable, [false]
      group?: boolean[],  // bindable (to work accordions together), [[]]
      duration?: number,  // [400]
      style?: DefineStateStyle | DefineStyle,
    };
    

ステータス定義

  • ステータスは重ね合わせが発生しない状態をそれぞれ定義する
    • 例: hover状態やfocus状態はステータスとして取り扱わない
  • 以下のステータスを定義し、各汎用コンポーネントは必ず以下ステータスを持つと定義する
    • 括弧内は状態の目安
    1. DEFAULT: 標準状態 (コンポーネント利用可能, 値入力無し)
    2. ACTIVE: 有効状態 (コンポーネント利用可能, 値入力有り, 値条件適合)
    3. INVALID: 無効状態 (コンポーネント利用可能, 値入力有り, 値条件不適合)
    4. DISABLE: 不能状態 (コンポーネント利用不可 自動遷移しない)
    5. CUSTOM1: カスタム1状態 (各プロジェクトで必要に応じて定義 自動遷移しない)
    6. CUSTOM2: カスタム2状態 (各プロジェクトで必要に応じて定義 自動遷移しない)
    7. CUSTOM3: カスタム3状態 (各プロジェクトで必要に応じて定義 自動遷移しない)
    8. CUSTOM4: カスタム4状態 (各プロジェクトで必要に応じて定義 自動遷移しない)
  • 各ステータスは数値(ビットフラグ)で定義する
    • 実際のステータス定義
    code
    const.ts
    export const STATE = {
      NONE:    0b00000000,
      DEFAULT: 0b00000001,
      ACTIVE:  0b00000010,
      INVALID: 0b00000100,
      DISABLE: 0b00001000,
      CUSTOM1: 0b00010000,
      CUSTOM2: 0b00100000,
      CUSTOM3: 0b01000000,
      CUSTOM4: 0b10000000,
    } as const;
    
  • 状態遷移図でステータスの自動遷移を定義する
    • 実際の状態遷移図 (各図形の使い方は正しくありません)
    state diagram
    • _TextField.svelte
    • _Accordion.svelte

スタイルの構成

スタイリングのベース

TailwindCSSを使用する。理由は以下。

  1. 文字列をCSSに変換するトランスパイラとして利用するため
    • JSの文字列処理とタグに文字列を埋め込むSvelteの機能だけで完結させる
      • コーディング時にCSSの世界を排除して単純化する
    • 単純に文字列処理だけで良くなり手軽さが得られる
    • 適度なプロパティ値の制約と、時には値指定できる柔軟性のバランス
    • しかしライブラリであり将来性不明のため、廃止のための備えは行っておく
  2. 効率的なCSS学習のため
    • flexやgridなど古にはなかった新しい概念が多くあり、ほぼ0から学習が必要だった
    • しかし各CSSプロパティを淡々と学習する気になれなかった
      • 実用上でどういうことがしたくなるのか、それはどう書くのかを同時に学びたかった
    • TailwindCSSは以下の点で学習が捗ると判断した
      • ドキュメント上でプロパティがほぼ網羅されている
      • プロパティが関連性でカテゴライズされている
      • 以下のような落とし穴が既にある程度塞がれている
        • text-overflowoverflowwhite-spaceと同時に使って制御する場合がある 等
        • このような知識と用途をドキュメントを見るだけで得られる
        • より詳しく知りたいときにだけ各プロパティをMDNで確認できる

スタイリング制御

  • CSS設定をオブジェクト構造で表現する (以下StyleRawと記載)
    • 基本的にプロパティ名にはCSSプロパティ名を用いる
      • -はそのまま使用できないため_に変換して使用
    • プロパティ値にTailwindCSSの文字列(w-full等)を設定する
      • TailwindCSSを廃止する際はここを置き換える
      • 同時にスタイリングの制御プログラムを一部変更する
  • 以下を実現するためにスタイルの重ね合わせを実現する
    1. 各ステータス毎のスタイル定義
    2. 別スタイルを適用したい場所では個別に定義する
    3. 重複したスタイル定義の記述を省略する
  • スタイルの重ね合わせのため以下を実装する
    • 重ね合わせはStyleRawの上書きで表現する
      • 例: mergedStyle: StyleRaw = { ...stdStyleRaw, ...specialStyleRaw };
  • DEFAULTステータスのスタイルをベースとする
    • 以下のスタイルはDEFAULTステータスのスタイルからの差異のみを定義し重ね合わせる
      • ステータス遷移時
      • 個別定義時
    • 個別で完全な別定義をしたい場合は完全な別定義を作成して重ね合わせる事になる
    • ステータス変化に応じて$derivedでスタイルを変化させる
  • 定義されていないステータスはDEFAULTを適用する
    • DEFAULTステータスのStyleRaw{}を上書きするイメージ
  • 構成部位・ステータス毎のスタイル定義が必要
    • コンポーネントとしては構成部位毎にステータス毎のスタイルが必要
      • Record<Part, Record<State, Style>>のようなイメージ (以下StyleSetと記載)
    • 人間としてはステータス毎に構成部位毎のスタイルを定義したい
      • Record<State, Record<Part, Style>>のようなイメージ (以下DefineStateStyleと記載)
    • 以上により人間はDefineStateStyleを定義し、内部ではStyleSetとして制御する
  • 汎用コンポーネントの標準スタイルは1か所で統一的に定義する
    • 具体的には$lib/style.tsファイルで定義
    • 実際の定義例
    code
    style.ts
    const stdTextField = new StyleSet({
      [STATE.DEFAULT]: {
        whole: {
          margin: "mb-2.5",
        },
        middle: {
          margin: "-my-1",
          padding: "px-0.5",
          display: "flex",
          border_width: "border-b",
          border_color: "border-charline",
        },
        main: {
          padding: "pl-1",
          flex_grow: "grow",
          background_color: "bg-inherit",
          color: "text-charline placeholder:text-inactive",
          outline_style: "focus:outline-none",
        },
        label: {
          font_family: "font-nsbold",
          color: "text-charline",
        },
        req: {
          margin: "ml-1",
          font_size: "text-sm",
          color: "text-inactive",
        },
        bottom: {
          font_size: "text-sm",
          color: "text-charline",
        },
        left: {
          width: "w-fit",
          color: "text-inactive"
        },
      },
      [STATE.INVALID]: {
        label: {
          color: "text-invalid",
        },
        middle: {
          border_color: "border-invalid",
        },
        bottom: {
          color: "text-invalid",
        },
      },
    });
    const stdAccordion = new StyleSet({
      [STATE.DEFAULT]: {
        whole: {
          width: "w-full",
          margin: "mt-5",
          border_radius: "rounded-lg",
          border_width: "border",
          border_color: "border-charline",
          overflow: "overflow-hidden",
          transition_duration: "duration-75",
        },
        label: {
          min_height: "min-h-8",
          display: "flex",
          flex_wrap: "flex-nowrap",
          justify_content: "justify-center",
          align_items: "items-center",
          font_family: "font-nsbold",
          color: "text-canvas",
          background_color: "bg-charline",
          list_style_type: "list-none",
          cursor: "cursor-pointer",
          user_select: "select-none",
          others: "[&::-webkit-details-marker]:hidden"
        },
        main: {
          display: "flex",
          padding: "py-2 px-3",
          flex_direction: "flex-col",
          gap: "gap-2",
          divide_width: "divide-y",
          divide_color: "divide-inactive",
          color: "text-charline",
        }
      },
    });
    
  • これらのスタイリング制御は以下3つのクラス定義で実現する
    • StyleSet
    • StateStyle
    • Style
    code
    util.ts
    class StyleSet {
      static EMPTY_ARG = {[STATE.DEFAULT]: {}};
    
      #part: Partial<Record<StylePart, StateStyle>> = {};
    
      constructor(definition: DefineStateStyle) {
        this.#setPartOfDefault(definition);
        (Object.keys(this.#part) as StylePart[]).forEach(part => {
          this.#setStyleOfState(part, definition);
        });
      }
      #setPartOfDefault(definition: DefineStateStyle) {
        for (const part of StyleSet.getPartFromDefinition(definition, STATE.DEFAULT)) {
          if (definition[STATE.DEFAULT]?.[part] !== undefined) {
            this.#part[part] = new StateStyle(new Style(definition[STATE.DEFAULT][part]!));
          }
        }
      }
      #setStyleOfState(part: StylePart, definition: DefineStateStyle) {
        for (const state of StyleSet.getStateFromDefinition(definition).filter(x => x !== STATE.DEFAULT)) {
          if (definition[state]?.[part] !== undefined) {
            this.#part[part]?.setStyle(state, new Style(definition[state][part]));
          }
        }
      }
      #mergeStyleSet(target: StyleSet): StyleSet {
        const clone = this.clone();
        for (const part of target.getPartArray()) {
          if (this.hasPart(part)) {
            clone.setPart(part, this.#part[part]!.toMerge(target.getPart(part)));
          } else {
            clone.setPart(part, target.getPart(part));
          }
        }
        return clone;
      }
      #mergeDefineStateStyle(target: DefineStateStyle): StyleSet {
        return this.#mergeStyleSet(new StyleSet(target));
      }
      #mergeDefineStyle(target: DefineStyle): StyleSet {
        return this.#mergeStyleSet(new StyleSet({[STATE.DEFAULT]: target}));
      }
      hasPart(part: StylePart): boolean {
        return Object.hasOwn(this.#part, part);
      }
      setPart(part: StylePart, StateStyle?: StateStyle): StyleSet {
        if (StateStyle === undefined) { return this; }
        this.#part[part] = StateStyle;
        return this;
      }
      getPart(part: StylePart): StateStyle | undefined {
        return this.#part[part];
      }
      getPartArray(): StylePart[] {
        return Object.keys(this.#part) as StylePart[]
      }
      clone(): StyleSet {
        const clone = new StyleSet(StyleSet.EMPTY_ARG);
        this.getPartArray().forEach(part => {
          clone.setPart(part, this.#part[part]!.clone());
        });
        return clone;
      }
      toMerge(target: StyleSet | DefineStateStyle | DefineStyle): StyleSet {
        if (target instanceof StyleSet) {
          return this.#mergeStyleSet(target);
        } else if (StyleSet.isDefineStateStyle(target)) {
          return this.#mergeDefineStateStyle(target as DefineStateStyle);
        } else {
          return this.#mergeDefineStyle(target as DefineStyle);
        }
      }
    
      static getPartFromDefinition(definition: DefineStateStyle, state: State): StylePart[] {
        if (!Object.hasOwn(definition, state)) { return []; }
        return Object.keys(definition[state]!) as StylePart[];
      }
      static getStateFromDefinition(definition: DefineStateStyle): State[] {
        return Object.keys(definition).map(key => Number(key) as State);
      }
      static isDefineStateStyle(definition: DefineStateStyle | DefineStyle): boolean {
        return Object.keys(definition).every(key => Object.values(STATE).includes(Number(key) as State));
      }
    }
    
    class StateStyle {
      #raw: Partial<Record<State, Style>> = {};
      #static: Partial<Record<State, string>> = {};
    
      constructor(style: Style) {
        this.#raw[STATE.DEFAULT] = style;
        this.#static[STATE.DEFAULT] = style.style;
      }
      #setStatic(state: State) {
        this.#static[state] = this.#raw[STATE.DEFAULT]!.toMerge(this.#raw[state]).style;
      }
      hasState(state: State): boolean {
        return Object.hasOwn(this.#raw, state);
      }
      setStyle(state: State, style?: Style): StateStyle {
        if (style === undefined) { return this; }
        this.#raw[state] = style;
        return this;
      }
      mergeStyle(state: State, style?: Style): StateStyle {
        if (style === undefined) { return this; }
        if (this.hasState(state)) {
          this.#raw[state] = this.#raw[state]!.toMerge(style);
          delete this.#static[state];
        } else {
          this.#raw[state] = style;
        }
        return this;
      }
      getStyle(state: State): string {
        if (this.hasState(state)) {
          if (!Object.hasOwn(this.#static, state)) { this.#setStatic(state); }
          return this.#static[state]!;
        } else {
          return this.#static[STATE.DEFAULT]!;
        }
      }
      getRawStyle(state: State): Style {
        if (!this.hasState(state)) { return new Style({}); }
        return this.#raw[state]!.clone();
      }
      getStateArray(): State[] {
        return Object.keys(this.#raw).map(key => Number(key) as State);
      }
      clone(): StateStyle {
        const clone = new StateStyle(this.#raw[STATE.DEFAULT]!.clone());
        for (const state of this.getStateArray().filter(key => key !== STATE.DEFAULT)) {
          clone.setStyle(state, this.#raw[state]!.clone());
        }
        return clone;
      }
      toMerge(target?: StateStyle): StateStyle {
        const clone = this.clone();
        if (target === undefined) { return clone; }
        for (const state of target.getStateArray()) {
          clone.mergeStyle(state, target.getRawStyle(state));
        }
        return clone;
      }
      getLayeredStyle(combState: number): string {
        if (combState <= MASK_STATE.NONE || combState > MASK_STATE.ALL) { return ""; }
        let style = new Style({});
        StateStyle.getCombStateArray(combState).forEach(x => style = style.toMerge(this.#raw[x]));
        return style.style;
      }
    
      static getCombStateArray(combState: number): State[] {
        const ary: State[] = [];
        let mask = MASK_STATE.BIT1;
        while (mask <= combState) {
          const state: State = (combState & mask) as State;
          if (state !== 0) { ary.push(state); }
          mask = mask << 1;
        }
        return ary;
      }
    }
    
    class Style {
      #raw: StyleRaw;
      #static?: string;
    
      constructor(style: StyleRaw) {
        this.#raw = style;
      }
      #setStatic() { this.#static = Object.values(this.#raw).filter(x => x).join(" "); }
      has(key: StyleKey): boolean {
        return Object.hasOwn(this.#raw, key);
      }
      merge(target?: Style): Style {
        if (target === undefined) { return this; }
        const ow = target.raw;
        (Object.keys(ow) as StyleKey[]).forEach(key => this.#raw[key] = ow[key]);
        this.#static = undefined;
        return this;
      }
      clone(): Style {
        return new Style({...this.#raw});
      }
      toMerge(target?: Style): Style {
        if (target === undefined) { return this.clone(); }
        return this.clone().merge(target);
      }
    
      get raw() { return this.#raw; }
      get style(): string {
        if (this.#static === undefined) { this.#setStatic(); }
        return this.#static!;
      }
    }
    

テーマ色変更

  • TailwindCSSを用いてCSSの世界で切り替える
    • TailwindCSSでカスタム色設定する
    • TailwindCSSのカスタム色をCSSカスタムプロパティで定義する
    • CSSカスタムプロパティ値を変更することで色変更を実施する
  • JSの世界から簡単に切り替え可能にするためThemeColorクラスを定義する
    • 実際の定義例
    code
    tailwind.config.js
    export default {
      // ...
      theme: {
        extend: {
          colors: {
            canvas: "var(--color-canvas)",
            active: "var(--color-active)",
            inactive: "var(--color-inactive)",
            charline: "var(--color-charline)",
            invalid: "var(--color-invalid)",
          },
      // ...
    }
    
    app.css
    :root {
      --color-canvas: #000;
      --color-active: #000;
      --color-inactive: #000;
      --color-charline: #000;
      --color-invalid: #000;
    }
    
    +layout.svelte
    <script module lang="ts">
      import "../app.css";
    </script>
    
    const.ts
    export const THEME = {
      LIGHT: "l",
      DARK: "d",
    } as const;
    
    util.ts
    import { browser } from '$app/environment';
    class ThemeColor {
      static VAR_PREFIX = "--color-";
    
      #themeset: DefineTheme;
      #theme: Theme;
    
      constructor(initTheme: Theme, definition: DefineTheme) {
        this.#themeset = definition;
        this.#theme = initTheme;
        this.#switch();
      }
    
      #switch() {
        if (!browser) { return; }
        for (const [varname, color] of Object.entries(this.#themeset[this.#theme])) {
          document.documentElement.style.setProperty(ThemeColor.VAR_PREFIX+varname, color);
        }
      }
      toLight() { this.theme = THEME.LIGHT; }
      toDark()  { this.theme = THEME.DARK; }
    
      get theme() { return this.#theme; }
      set theme(value: Theme) {
        this.#theme = value;
        this.#switch();
      }
    }
    
    style.ts
    const themeColor = new ThemeColor(
      THEME.LIGHT, {
      [THEME.LIGHT]: {
        canvas: "#e2e8ef",
        active: "#03ab99",
        inactive: "#7fa091",
        charline: "#01413a",
        invalid: "#ab0315",
      },
      [THEME.DARK]: {
        canvas: "#1a1f24",
        active: "#04d6c1",
        inactive: "#5a7268",
        charline: "#a0f0e6",
        invalid: "#ff3b4e",
      },
    });
    

値の検証

  • 値の検証は以下のような関数型で実施する
(
  value: string,  // value of the component
  auto?: boolean  // triggered by self or not
) => [
  boolean,  // result of the validation
  string?,  // replace contents of "bottom" if necessary
  (string | Snippet)?  // replace contents of "aux" if necessary
]
  • 検証結果に応じて任意の応答をさせる機能は標準で欲しいため返り値に盛り込む
    • ここでは"bottom"又は"aux"を変化させる機能
    • これらが特に定義されない結果の場合、propsで渡されたデフォルト値に自動で戻す
  • 外からの検証トリガーは以下の関数型で実施する
    • 親がこの関数を実行することで子が値検証を実行するように構成されている
() => boolean,
  • 自律的な値検証は状態遷移図で表現された検証タイミングで実行される
    • _TextField.svelteではonchangeイベント発生時 等
  • 値検証結果に応じて状態遷移図のステータス遷移が実行される

透過的な取り扱い

  • 透過的にタグ属性やイベントを定義可能にする

    • メインとなるタグには透過的にタグ属性・イベントを適用可能にする
      • 例: TextFieldコンポーネントの<input type="text" />タグ等
    • 明確なメインとなるタグがない場合は省略
  • 汎用コンポーネント内部を変更しないため、極力指定可能にする

    • 幸いSvelte5では全ての属性・イベントを手動で定義しなくても良いようになっている
      • タグへの適用時もスプレッド構文により一つ一つの記載は不要で保守性が良い
    • 属性・イベント以外にマウント時や破棄時の挙動を定義したい場合にも備える
      • useディレクティブの引数Action型をpropsで受け取り可能にしておく
    • これらでもどうしようもない場合に備えて、メインタグはバインド可能にしておく
      • メインタグのbind:thispropsで親に伝播可能にしておく
    • 実際のprops型定義とHTMLタグの定義
    code
    _TextField.svelte
    <script module lang="ts">
      export type Props = {
        label?: string,
        req?: string | Snippet,
        aux?: string | Snippet,  // bindable
        left?: string | Snippet,
        right?: string | Snippet,
        bottom?: string,  // bindable
        status?: State,  // bindable, [STATE.DEFAULT]
        value?: string,  // bindable, [""]
        type?: "email" | "password" | "search" | "tel" | "text" | "url" | "number" | "area",  // bindable (to switch password / text), ["text"]
        placeholder?: string,
        options?: string[],  // bindable
        test?: () => boolean,  // bindable
        validation?: (value: string, auto?: boolean) => [boolean, string?, (string | Snippet)?],
        style?: DefineStateStyle | DefineStyle,
        action?: Action,
        events?: EventSet,
        attributes?: HTMLAttributes<HTMLInputElement> | HTMLAttributes<HTMLTextAreaElement>;
        element?: HTMLInputElement | HTMLTextAreaElement,  // bindable
      };
    </script>
    <script lang="ts">
      let { label, req, aux = $bindable(), left, right, bottom = $bindable(), status = $bindable(STATE.DEFAULT), type = $bindable("text"), value = $bindable(""), placeholder, options = $bindable(), test = $bindable(), validation, style, action, events, attributes, element = $bindable()}: Props = $props();
    
      /*** Initialize ***/
      // ...
      const id = attributes?.id ?? htmlId.get();  // get unique random id
      const list = options === undefined ? undefined : htmlId.get();  // get unique random id
      const attr = omit({...attributes}, ["id", "disabled", "type", "value", "placeholder", "list"]);
      const ev = omit({...events}, ["onchange"]);
    </script>
    
    {#if type === "area"}
      {#if typeof action === "function"}
        <textarea bind:value bind:this={element} class={myStyle[PART.MAIN]} {id} {placeholder} {disabled} {onchange} {...attr} {...ev} use:action></textarea>
      {:else}
        <textarea bind:value bind:this={element} class={myStyle[PART.MAIN]} {id} {placeholder} {disabled} {onchange} {...attr} {...ev}></textarea>
      {/if}
    {:else}
      {#if typeof action === "function"}
        <input bind:value bind:this={element} class={myStyle[PART.MAIN]} {id} {type} {placeholder} {list} {disabled} {onchange} {...attr} {...ev} use:action />
      {:else}
        <input bind:value bind:this={element} class={myStyle[PART.MAIN]} {id} {type} {placeholder} {list} {disabled} {onchange} {...attr} {...ev} />
      {/if}
      {#if typeof options !== "undefined"}
        <datalist id={list}>
          {#each options as option}
            <option value={option}></option>
          {/each}
        </datalist>
      {/if}
    {/if}
    
    • Accordionコンポーネントはメインタグがないためpropsから省略
    _Accordion.svelte
    <script module lang="ts">
      export type Props = {
        label: string | Snippet,
        children: Snippet,
        status?: State,  // bindable, [STATE.DEFAULT]
        open?: boolean,  // bindable, [false]
        group?: boolean[],  // bindable (to work accordions together), [[]]
        duration?: number,  // [400]
        style?: DefineStateStyle | DefineStyle,
      };
    </script>
    

実装全体

TextField

code
_TextField.svelte
<script module lang="ts">
  /*** Export ***/
  export type Props = {
    label?: string,
    req?: string | Snippet,
    aux?: string | Snippet,  // bindable
    left?: string | Snippet,
    right?: string | Snippet,
    bottom?: string,  // bindable
    status?: State,  // bindable, [STATE.DEFAULT]
    value?: string,  // bindable, [""]
    type?: "email" | "password" | "search" | "tel" | "text" | "url" | "number" | "area",  // bindable (to switch password / text), ["text"]
    placeholder?: string,
    options?: string[],  // bindable
    test?: () => boolean,  // bindable
    validation?: (value: string, auto?: boolean) => [boolean, string?, (string | Snippet)?],
    style?: DefineStateStyle | DefineStyle,
    action?: Action,
    events?: EventSet,
    attributes?: HTMLAttributes<HTMLInputElement> | HTMLAttributes<HTMLTextAreaElement>;
    element?: HTMLInputElement | HTMLTextAreaElement,  // bindable
  };
  export type PartTextField = typeof PART_TEXT_FIELD[number];
  export const PART_TEXT_FIELD = [
    PART.WHOLE,
    PART.MIDDLE,
    PART.MAIN,
    PART.TOP,
    PART.LEFT,
    PART.RIGHT,
    PART.BOTTOM,
    PART.LABEL,
    PART.REQ,
    PART.AUX,
  ] as const;

  /*** Others ***/

  /*** import ***/
  import type { Action } from "svelte/action";
  import type { HTMLAttributes } from "svelte/elements";
  import type { Snippet } from "svelte";
  import { STATE, PART } from "$lib/const";
  import { htmlId, getApplyStyle, omit } from "$lib/util";
  import { stdTextField } from "$lib/style";
</script>

<!---------------------------------------->

<script lang="ts">
  let { label, req, aux = $bindable(), left, right, bottom = $bindable(), status = $bindable(STATE.DEFAULT), type = $bindable("text"), value = $bindable(""), placeholder, options = $bindable(), test = $bindable(), validation, style, action, events, attributes, element = $bindable()}: Props = $props();

  /*** Initialize ***/
  test = () => testValue();
  const id = attributes?.id ?? htmlId.get();
  const lid = label === undefined ? undefined : htmlId.get();
  const list = options === undefined ? undefined : htmlId.get();
  const attr = omit({...attributes}, ["id", "disabled", "type", "value", "placeholder", "list"]);
  const ev = omit({...events}, ["onchange"]);
  const partDefault = { bottom, aux };
  let disabled = $derived(status === STATE.DISABLE);

  /*** Sync with outside ***/

  /*** Styling ***/
  const myStyleSet = style === undefined ? stdTextField : stdTextField.toMerge(style);
  let myStyle = $derived(getApplyStyle(myStyleSet, PART_TEXT_FIELD as SubTuple<PartTuple>, status));

  /*** Status ***/
  function setDefault() {
    if (status === STATE.DEFAULT) { return; }
    status = STATE.DEFAULT;
    ({ bottom, aux } = partDefault);
  }
  function setValidateStatus(result: boolean, msg?: string, mark?: string | Snippet) {
    status = result ? STATE.ACTIVE : STATE.INVALID;
    bottom = (partDefault.bottom !== undefined && msg === undefined) ? partDefault.bottom : msg;
    aux = (partDefault.aux !== undefined && mark === undefined) ? partDefault.aux : mark;
  }

  /*** Validation ***/
  function testValue(auto: boolean = false): boolean {
    if (validation === undefined || status === STATE.DISABLE) { return true; }
    const [result, msg, mark] = validation(value, auto);
    setValidateStatus(result, msg, mark);
    return result;
  }

  /*** Others ***/

  /*** Handle events ***/
  function onchange(ev: Event) {
    if (events?.["onchange"] !== undefined) { events["onchange"](ev); }
    if (value === "") {
      setDefault();
    } else {
      testValue(true);
    }
  }
</script>

<!---------------------------------------->

<div class={myStyle[PART.WHOLE]} role="group" aria-labelledby={lid}>
  <div class={myStyle[PART.TOP]}>
    {#if typeof label === "string"}
      <label class={myStyle[PART.LABEL]} for={id} id={lid}>{label}</label>
    {/if}
    {#if typeof req === "string"}
      <span class={myStyle[PART.REQ]}>{req}</span>
    {:else if typeof req === "function"}
      <span class={myStyle[PART.REQ]}>{@render req()}</span>
    {/if}
    {#if typeof aux === "string"}
      <span class={myStyle[PART.AUX]}>{aux}</span>
    {:else if typeof aux === "function"}
      <span class={myStyle[PART.AUX]}>{@render aux()}</span>
    {/if}
  </div>
  <div class={myStyle[PART.MIDDLE]}>
    {#if typeof left === "string"}
      <span class={myStyle[PART.LEFT]}>{left}</span>
    {:else if typeof left === "function"}
      <span class={myStyle[PART.LEFT]}>{@render left()}</span>
    {/if}
    {#if type === "area"}
      {#if typeof action === "function"}
        <textarea bind:value bind:this={element} class={myStyle[PART.MAIN]} {id} {placeholder} {disabled} {onchange} {...attr} {...ev} use:action></textarea>
      {:else}
        <textarea bind:value bind:this={element} class={myStyle[PART.MAIN]} {id} {placeholder} {disabled} {onchange} {...attr} {...ev}></textarea>
      {/if}
    {:else}
      {#if typeof action === "function"}
        <input bind:value bind:this={element} class={myStyle[PART.MAIN]} {id} {type} {placeholder} {list} {disabled} {onchange} {...attr} {...ev} use:action />
      {:else}
        <input bind:value bind:this={element} class={myStyle[PART.MAIN]} {id} {type} {placeholder} {list} {disabled} {onchange} {...attr} {...ev} />
      {/if}
      {#if typeof options !== "undefined"}
        <datalist id={list}>
          {#each options as option}
            <option value={option}></option>
          {/each}
        </datalist>
      {/if}
    {/if}
    {#if typeof right === "string"}
      <span class={myStyle[PART.RIGHT]}>{right}</span>
    {:else if typeof right === "function"}
      <span class={myStyle[PART.RIGHT]}>{@render right()}</span>
    {/if}
  </div>
  {#if typeof bottom === "string"}
    <output class={myStyle[PART.BOTTOM]}>{bottom}</output>
  {/if}
</div>

Accordion

code
_Accordion.svelte
<script module lang="ts">
  /*** Export ***/
  export type Props = {
    label: string | Snippet,
    children: Snippet,
    status?: State,  // bindable, [STATE.DEFAULT]
    open?: boolean,  // bindable, [false]
    group?: boolean[],  // bindable (to work accordions together), [[]]
    duration?: number,  // [400]
    style?: DefineStateStyle | DefineStyle,
  };
  export type PartAccordion = typeof PART_ACCORDION[number];
  export const PART_ACCORDION = [
    PART.WHOLE,
    PART.MAIN,
    PART.LABEL
  ] as const;

  /*** Others ***/

  /*** import ***/
  import { type Snippet, untrack } from "svelte";
  import { slide } from "svelte/transition";
  import { STATE, PART } from "$lib/const";
  import { getApplyStyle, prevent, sleep } from "$lib/util";
  import { stdAccordion } from "$lib/style";
</script>

<!---------------------------------------->

<script lang="ts">
  let { label, children, status = $bindable(STATE.DEFAULT), open = $bindable(false), group = $bindable([]), duration = 400, style}: Props = $props();

  /*** Initialize ***/
  let actual = $state(open);
  let _guard = false;

  /*** Sync with outside ***/
  $effect.pre(() => { open;
    untrack(() => {
      setStatus();
      toggleOpen();
    });
  });

  /*** Styling ***/
  const myStyleSet = style === undefined ? stdAccordion : stdAccordion.toMerge(style);
  let myStyle = $derived(getApplyStyle(myStyleSet, PART_ACCORDION as SubTuple<PartTuple>, status));

  /*** Status ***/
  function setStatus() {
    status = open ? STATE.ACTIVE : STATE.DEFAULT;
  }

  /*** Validation ***/

  /*** Others ***/
  function toggleOpen(): void {
    if (_guard) { return; }
    _guard = true;
    if (open) {
      actual = true;
    } else {
      sleep(duration).then(() => actual = false);
    }
    sleep(duration).then(() => _guard = false);
  }

  /*** Handle events ***/
  function onclick(ev: Event) {
    if (status === STATE.DISABLE) { return; }
    const temp = !open;
    group.forEach((_,i) => group[i] = false);
    open = temp;
  }
</script>

<!---------------------------------------->

<details class={myStyle[PART.WHOLE]} open={actual}>
  <summary class={myStyle[PART.LABEL]} onclick={prevent(onclick)}>
    {#if typeof label === "string"}
      {label}
    {:else if typeof label === "function"}
      {@render label()}
    {/if}
  </summary>
  {#if open}
    <div class={myStyle[PART.MAIN]} transition:slide={{ duration: duration }}>
      {@render children()}
    </div>
  {/if}
</details>

その他のコンポーネント

記事作成時点では試作アプリで使用するコンポーネントのみ作成している状態のため種類は限定的かつREADME等も整備していない。

https://github.com/scirexs/z_svelte_example

課題

  • アクセシビリティの対応
  • キーボード操作への対応 等

雑記

2回ほど作り直してようやくマシなものができました。また知識が深まるにつれ改修したくなる気もしますが、しばらくこの実装で使い回す予定です。

参考文献

Discussion