Madogiwa Blog

主に技術系の学習メモに使っていきます。

個人的なCSS構成の考え方のメモ📝

世間ではVue.jsのSFC、Tailwind、CSS in JSのような技術を用いてコンポーネント内でカプセル化して統制をとっていくことが主流だと思いますが、なぜか最近ピュアなCSSを書くことが多いので自分なりに設計として考えていることを整理して共有しやすいようにメモしておく。

基本思想

Sassは採用せずにPostCSS + CSSでFLOCSSベースのディレクトリ構成にしつつBEM的な命名規則を採用してCSSを書く。

FLOCSS(フロックス) は、OOCSSやSMACSS、BEM、SuitCSSのコンセプトを取り入れた、モジュラーなアプローチのためのCSS構成案です。 https://github.com/hiloki/flocss

BEM — is a methodology that helps you to create reusable components and code sharing in front‑end development https://getbem.com/

具体的には以下のようなディレクトリ構成を採用し、block__element--modifierの形式で記述する。

またグローバルで利用するスタイルと各ページで利用するスタイルではエントリを分けてビルドし、各ページで利用するスタイルに関してはそれぞれのページで読み込むようにする。

.
├── src/
│   ├── entrypoints/
│   │   ├── application.ts
│   │   └── books/
│   │       ├── index.ts
│   │       └── show.ts
│   └── stylesheet/
│       ├── application.css
│       ├── foundation/
│       │   ├── reset.css
│       │   ├── base.css
│       │   ├── font.css
│       │   ├── variable.css
│       │   └── color.css
│       ├── layout/
│       │   ├── header.css
│       │   └── footer.css
│       └── object/
│           ├── component/
│           │   └── button.css
│           ├── project/
│           │   └── books/
│           │       ├── index.css
│           │       ├── show.css
│           │       └── card.css
│           └── util/
│               └── align.css
└── public/
    └── dist/
        └── stylesheets/
            ├── application.css
            └── books/
                ├── index.css
                └── show.css

ディレクトリの役割と基本ルール

Foundation

FLOCSSと同じ、デフォルトスタイルや変数管理等を行うディレクトリ。

Foundation Reset.cssやNormalize.cssなどを用いたブラウザのデフォルトスタイルの初期化や、プロジェクトにおける基本的なスタイルを定義します。 ページの下地としての全体の背景や、基本的なタイポグラフィなどが該当します。 https://github.com/hiloki/flocss?tab=readme-ov-file#foundation

Foundationで定義したスタイルはapplication.cssにインポートされグローバルに適用する。

/* application.css */

@import "./reset.css"
@import "./font.css"
@import "./color.css"
@import "./variable.css"
@import "./base.css"

Layout

基本はFLOCSSと同じ、ヘッダーやフッター、コンテンツエリアのような具体の要素を埋め込むテンプレート的な要素のスタイルを定義するディレクトリ。

ページを構成するヘッダーやメインのコンテンツエリア、サイドバーやフッターといったプロジェクト共通のコンテナーブロックのスタイルを定義します。 https://github.com/hiloki/flocss?tab=readme-ov-file#layout

あくまでグローバルに現れるテンプレート的な要素となるため、具体なコンテンツを持つ要素や特定の機能のためものは後述のObjectに定義する。

FLOCSSではプレフィクスとしてl-をつけるルールになっているがLayout - Object間の移動を想定して、そこまで厳密にプレフィクスはつけずサービス固有のプレフィクスservicename-を付ける程度にする。

.service-header { ... }
.service-footer { ... }

Object

基本はFLOCSSと同じ、具体なコンテンツを持つ要素や特定の機能のためのスタイルを定義するディレクトリ。

Object OOCSSのコンセプトを元に、プロジェクトにおける繰り返されるビジュアルパターンをすべてObjectと定義します。 https://github.com/hiloki/flocss?tab=readme-ov-file#object

FLOCSSと同様に以下の3つのディレクトリを配下に持つ。

  • Component
  • Project
  • Util

Component

基本はFLOCSSと同じ、グローバルに再利用される要素を管理するディレクトリ。必要な箇所で@importで読み込み利用します。

Component 再利用できるパターンとして、小さな単位のモジュールを定義します。 一般的によく使われるパターンであり、例えばBootstrapのComponentカテゴリなどに見られるbuttonなどが該当します。 https://github.com/hiloki/flocss?tab=readme-ov-file#1-component

Componentに配置するかどうかの目安として異なる2つ以上の機能で3回以上再利用が発生した、またはデザインシステム等で明確にグローバルな要素として定義されている場合にはComponentに配置する。

FLOCSSではプレフィクスとしてc-をつけるルールになっているがLayout - Object間の移動を想定して、そこまで厳密にプレフィクスはつけずサービス固有のプレフィクスservicename-を付ける程度にする。

.service-button { ... }

特定な機能内で再利用されるものはComponentではなく後述のProjectに定義する。

Project

基本はFLOCSSと同じ、特定の機能で利用することを想定した要素に対するスタイルを管理するディレクトリ。

Project プロジェクト固有のパターンであり、いくつかのComponentと、それに該当しない要素によって構成されるものを定義します。 https://github.com/hiloki/flocss?tab=readme-ov-file#2-project

基本的には、まず愚直にページ単位でCSSを作成しページ間で再利用が発生するものは別ファイルに切り出していく。

目安として3回以上の再利用が発生した場合に別ファイルに切り出すことを検討する。

FLOCSSではプレフィクスとしてp-をつけるルールになっているが、namespace classを採用しページ単位で作成したCSSは一意となるようにサービス名を表すプレフィクス+ディレクトリ構成のようなclassをトップレベルに記述することでページ内でCSSをスコーピングし、それにネストする形でスタイルを記述する。

「namespace class」は、1つViewに対して一意のルートクラスを設定する手法です、愚直ですね。Wordpressで似たような仕組みを見た方もいるかと思います。 Viewがapp/views/users/show.htmlのように配置されていますので、ここから.view-users-showのようなクラス名にすることで、1つのViewに一意なクラス名を付与することが出来ます。 https://qiita.com/hanakla/items/b96cdfabd93a762c3ec0#namespace-class

グローバルに影響しないスコープ内のCSSに関してはBEMライクの記法で記述するぐらいのゆるいルールで管理する。

/* project/books/index.css */
.service-books-index {
  .books-title { ... }
  .books-card { ... }
}
/* project/books/show.css */
.service-books-show {
  .books-card { ... }
}

機能内での再利用のために別ファイルに切り出す場合にはサービス名を表すプレフィクス+機能のトップレベルのディレクトリ構成+コンポーネント名のようなclassをトップレベルに記述し、それにネストする形でスタイルを記述してスコーピングを行う。

/* project/books/card.css */
.service-books-card { ... }

Util

基本はFLOCSSと同じ、グローバルに利用する便利系クラスを配置するディレクトリ。必要な箇所で@importで読み込み利用します。

Utility ComponentとProjectレイヤーのObjectのモディファイアで解決することが難しい・適切では無い、わずかなスタイルの調整のための便利クラスなどを定義します。 https://github.com/hiloki/flocss?tab=readme-ov-file#3-utility

FLOCSSではプレフィクスとしてu-をつけるルールになっているがUtility - Project等の移動を想定して、そこまで厳密にプレフィクスはつけずサービス固有のプレフィクスservicename-を付ける程度にする。

基本的にはCSSの単一のプロパティを操作するようなcssとなるため、操作するプロパティや意図に合わせて命名する。

.service-text-center { ... }
.sercice-is-hidden-mobile { ... }

命名規則・クラス設計

厳密性は求めないがFLOCSSと同様にBEM・MindBEMding的なblock__element--modifierの形式で書く。

BEMシステムのシンタックスである、Block、Element、Modifierに分類して構成される規則を採用します。 FLOCSSでは、オリジナルのBEMのシンタックスではなく、MindBEMding のアイデアを基本的にそのまま取り入れています。 https://github.com/hiloki/flocss?tab=readme-ov-file#mindbemding

BEM — is a methodology that helps you to create reusable components and code sharing in front‑end development https://getbem.com/

Blockは単体で意味のある要素のため、Block同士はネストさせない。

Block Encapsulates a standalone entity that is meaningful on its own. https://getbem.com/naming/#block

Element, ModifierはBlockを構成する要素のためBlockにネストするように記述する。

Element Parts of a block and have no standalone meaning. Any element is semantically tied to its block. https://getbem.com/naming/#element

Modifier Flags on blocks or elements. Use them to change appearance, behavior or state. https://getbem.com/naming/#modifier

例えばヘッダー(Element)・メイン(Element)・フッター(Element)を持つカード(Block)的な要素をCSSで表現する場合は以下のような書き方になる。

.service-card {
  .service-card__header { ... }
  .service-card__main { ... }
  .service-card__footer { ... }
  &.service-card--dark { ... }
}

エントリー設計

⚠️ MPAやダイナミックインポートを利用する場合には本章で記載した思想でエントリーを分離する設計を行う。

グローバルに適用するスタイルはentrypoints/application.tsにimportしグローバルに読み込まれるエントリーとして出力します。

// entrypoints/application.ts
import '@/stylesheet/application.css'

Projectで定義した各ページで適用するスタイルは他のページに影響を与えないようにentrypoints/books/index.tsのような各ページのエントリーのtsファイルでimportし各ページで読み込みます。

// entrypoints/books/index.ts
import '@/stylesheet/object/project/books/index.css'

以上のエントリー設計を行い以下のようにグローバルに適用するものと個別のページで利用するものを分けて出力して読み込みます。

└── public/
    └── dist/
        └── stylesheets/
            ├── application.css
            └── books/
                ├── index.css
                └── show.css

参考

Rollupで`import.meta.vitest`をundefinedに設定してVitestのIn-Source Testingを削除する方法メモ📝

VitestのIn-Source Testingは非常に便利ですが、本番ビルド時には削除しないとデッドコードが含まれてしまいbundle sizeの不要な肥大化につながってしまいます。

vitest.dev

Rollupで削除する方法にちょっとハマったのでメモ📝

結論としては見落としていたので公式ガイドに記載の通り、@rollup/plugin-replace - npmを使ってimport.meta.vitestundefinedに設定すれば良いだけだった💦

// rollup.config.js
import replace from '@rollup/plugin-replace'

export default {
  plugins: [
    replace({ 
      'import.meta.vitest': 'undefined', 
    }) 
  ],
  // other options
}

公式ガイドはちゃんと読まないとダメですね😇

Ruby on Rails: 本番イメージが起動することをGitHub Actionで確認するメモ📝

本番環境をDockerイメージを使ってデプロイするときに思わぬ変更で本番環境のRailsアプリケーションが起動できないことがあります。

それを事前に検知できたら便利ということで最低限Railsのサーバーが立ち上がりヘルスチェックのエンドポイントが正常なレスポンスを返すことを確認するGitHub Actionを作ったのでメモ📝

name: Docker build health check

on:
  pull_request:

jobs:
  build:
    timeout-minutes: 10
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: get Ruby version
        id: ruby-version
        run: echo "value=$(cat .ruby-version)" >> $GITHUB_OUTPUT
      - name: get Node version
        id: node-version
        run: echo "value=$(cat .node-version)" >> $GITHUB_OUTPUT
      - name: Build
        uses: docker/build-push-action@v6
        with:
          push: false
          tags: |
            sample-app:latest
          build-args: |
            RUBY_VERSION=${{ steps.ruby-version.outputs.value }}
            NODE_VERSION=${{ steps.node-version.outputs.value }}
            RAILS_ENV=production
      - name: run server and health check
        run: |
          docker run -d -p 3000:3000 
                --rm \
                --env SECRET_KEY_BASE="dummy" \
                --name sample-app sample-app
          sleep 5  # サーバーが完全に起動するのを待つ
          curl -sSf http://localhost:3000/up || exit 1
      - name: stop server
        run: docker stop sample-app

上記のように単純にdocker/build-push-actionを使ってbuildしたイメージをGitHub Actionに最初から入っているDockerで起動してRails標準のヘルスチェックのパスにcurlでリクエストを送信してるだけ📝

github.com

Rails v7.1から追加された標準のヘルスチェックのエンドポイント、こういうちょっとした起動確認に便利ですね✨

github.com

CSS: SafariでHTML標準のラジオボタン、チェックボックスの大きさをCSSで調整する方法メモ📝

SafariでHTML標準のラジオボタンチェックボックスの大きさをCSSで調整するのがSafariでだけ動かずハマったのでメモ📝

結論

heightwidthではなくtransform: scaleで拡大して調整する。

See the Pen Adjustment size for Radio and Checkbox. by madogiwa (@madogiwa0124) on CodePen.

説明

以下のようなCSSでHTML標準のラジオボタンチェックボックスの大きさを調整しようとするとChrome/Firefoxでは動作しますが、

input[type="radio"],input[type="checkbox"] {
  height: 32px;
  width: 32px; 
}

fieldset {
  display: flex;
  gap: 8px;
  
  label {
    display: flex;
    gap: 8px;
    align-items: center;
  }
}

Safariでは以下のようにサイズが反映されません。

そのため以下のようにtransform: scaleを使うと大きさの調整ができる。

input[type="radio"],input[type="checkbox"] {
  transform: scale(2)
}

ただし、これは単純に2倍に引き延ばしているだけなので画質が落ちて汚く見えてしまう場合があるので注意⚠️

とりあえず見た目はある程度で大きさだけ調整したいケースでは良いですが、ちゃんとブラウザで統一して綺麗に見せるならカスタムしたUIコンポーネントを作成した方が良いですね😓

参考

gist.github.com

chriscoyier.net

HTML: details/summary要素を使ってカスタムアコーディオンを作るメモ📝

details/summary要素を使うと

developer.mozilla.org

以下のようなアコーディオンのようなUIをブラウザ標準機能として簡単に実装できます。

<details>
  <summary >accordion</summary>
  <div >Hello!!</div>
</details>

以下のようなアイコン等をカスタムしたアコーディオンを作るときにちょっとハマりどころがあったのでメモ📝

結論は以下のようなコードになりました。

See the Pen custom-accordion-by-details-tag by madogiwa (@madogiwa0124) on CodePen.

<details class="accordion">
  <summary class="accordion-summary">accordion</summary>
  <div class="accordion-content">Hello!!</div>
</details>
.accordion {
  padding: 0;
  margin: 20px 0;
  border: 1px solid black;

  &[open] {
    .accordion-summary {
      &::after {
        content: 'Close';
      }
    }
  }
  
  .accordion-summary {
   cursor: pointer;
   background-color: grey;
   padding: 8px;
   
   /* デフォルトアイコンの削除 */ 
   list-style: none;
   &::-webkit-details-marker { /* for safari */ 
     display: none
   }

   &::after {
    content: 'Open'; 
    font-size: 16px;
    float: right; /* アイコンを右端に配置 */
   }
  }
  
  .accordion-content{
     padding: 8px;
   }
}

ポイントとしては、

おわり

参考

catnose.me

note.com

JavaScript: Intersection Observer APIを使って無限スクロール用のコンポーネントを作成するメモ📝

Vue.jsを利用している個人サービスで長らくvue-infinite-loadingを使っていたのですがstable版でVue.js v3の公式サポート周りがネックになっていたので、

github.com

Intersection Observer APIを使って独自の無限スクロールによるページネーションのコンポーネントを作成したので、その辺りをメモしておきます📝

developer.mozilla.org

Intersection Observer APIとは

交差オブザーバー API は、特定の要素が他の要素(またはビューポート)と交差に入ったり出たりしたとき、または 2 つの要素間の交差量が指定された量だけ変化したときに実行されるコールバック関数を、コードが登録できるようにします。 交差オブザーバー API - Web API | MDN

Intersection Observer API(交差オブザーバー API)は、上述のとおり特定の要素がビューポート(画面内)または他の要素と重なった場合に何かしらのコールバックを実行することができます。

詳細は公式ガイドを参照してください。

無限スクロールでページネーションを行うコンポーネントを作成する

これを使った無限スクロール用のコンポーネントが以下です。

<template>
  <div ref="root" class="common-scroll-pager">
    <span v-if="status === 'complete'" class="scroll-pager__loader-info">{{ completeText }}</span>
    <span v-else-if="status === 'no-result'" class="scroll-pager__loader-info">{{ noResultText }}</span>
    <span v-else-if="status === 'loading'" class="scroll-pager__loader-info">{{ loadingText }}</span>
    <span v-else-if="status === 'error'" class="scroll-pager__loader-info">{{ errorText }}</span>
  </div>
</template>
<script setup lang="ts">
import type { PagerStatus } from "@js/composables/Pager";
import { onMounted, useTemplateRef } from "vue";

type Props = {
  noResultText?: string;
  loadingText?: string;
  errorText?: string;
  completeText?: string;
  status: PagerStatus;
};

const {
  noResultText = "結果がありません",
  loadingText = "読み込み中です...",
  errorText = "エラーが発生しました",
  completeText = "全て読み込みました✨",
} = defineProps<Props>();

const emits = defineEmits<{
  (e: "load"): void;
}>();

const rootEl = useTemplateRef<HTMLDivElement>("root");

onMounted(() => {
  const observer = new IntersectionObserver((entries) => {
    if (entries.length === 0) return;
    if (entries[0]?.isIntersecting) emits("load");
  });
  if (rootEl.value) observer.observe(rootEl.value);
});
</script>
<style scoped>
.common-scroll-pager {
  text-align: center;
  margin-top: 16px;

  .scroll-pager__loader-info {
    color: var(--grey);
    font-size: var(--font-size-ms);
  }
}
</style>

useTemplateRefを使ってコンポーネントのroot要素を取得し、onMountedIntersectionObserverを生成しrootEl.value画面内に入ったらイベントloadを発火しています。利用コンポーネント側はこのloadイベントを利用してページャー用のロジックを呼び出してページネーションを実装することができます🌀

IntersectionObserverの生成時のオプションのデフォルトはビューポートとの重なりを検知する

root ターゲットが見えるかどうかを確認するためのビューポートとして使用される要素です。指定されなかった場合、または null の場合は既定でブラウザーのビューポートが使用されます。

交差オブザーバー API - Web API | MDN

無限スクロールによるページネーションのサンプル

以下のような感じで先ほど作成した無限スクロール用のコンポーネントを使って無限スクロールによるページネーションを実装することができます。無限スクロール用のコンポーネントv-forでのリストレンダリングの最後に置くことによってリストの終わり(≒ScrollPagerが画面内に入った状態)にサーバー側にリクエストを送信して次のページのコンテンツを取得することができます。

<template>
  <div class="entry-collection-container">
    <div class="entry-collection">
      <div v-for="entry in entries" :key="entry.id" class="entry">
          <div class="entry-title">{{ entry.title }}</div>
      </div>
    <ScrollPager :status="status" @load="entryPagerHandler" />
  </div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import  { getEntries } from "@js/services/EntryService";
import type { Entry } from "@js/types/types";
import ScrollPager from "@js/components/ui/ScrollPager.vue";
import { usePager } from "@js/composables/Pager";

const entries = ref<Entry[]>([]);
const { page, status, pagerHandler } = usePager();
const updateEntryList = (targets: Entry[]) => entries.value.push(...targets);
const entryPagerHandler = async () => {
  await pagerHandler(
    () => getEntries(location.search, { page: page.value }),
    (data: Entry[]) => updateEntryList(data),
  );
};
onMounted(() => entryPagerHandler());
</script>

以下が内部で呼び出しているページャー関連の処理をまとめたComposableです。 読み込み成功や完了といったステータスとページネーションのためのページ番号の管理を行ないつつ、callbackで渡された関数を実行している感じです。

import { computed, ref } from "vue";

export type PagerStatus = "initial" | "loading" | "loaded" | "complete" | "error" | "no-result";
export const usePager = () => {
  const page = ref<number>(1);
  const status = ref<PagerStatus>("initial");
  const isInitial = computed(() => status.value === "initial");
  const isLoading = computed(() => status.value === "loading");
  const isLoaded = computed(() => status.value === "loaded");
  const isNoResult = computed(() => status.value === "no-result");
  const isComplete = computed(() => status.value === "complete");
  const isError = computed(() => status.value === "error");
  const isNoNeededProcess = computed(() => isLoading.value || isComplete.value || isNoResult.value);

  const resetStatus = () => {
    page.value = 1;
    status.value = "initial";
  };

  const calcSuccessStatus = (resultLength: number, pageNumver: number): PagerStatus => {
    if (resultLength < 1 && pageNumver <= 1) return "no-result";
    if (resultLength < 1 && pageNumver > 1) return "complete";
    return "loaded";
  };

  const pagerHandler = async <T>(process: () => Promise<T[]>, callback: (data: T[]) => void) => {
    if (isNoNeededProcess.value) return;
    try {
      status.value = "loading";
      const result = await process();
      callback(result);
      status.value = calcSuccessStatus(result.length, page.value);
      if (isLoaded.value) page.value += 1;
    } catch (error) {
      status.value = "error";
      throw error;
    }
  };

  return {
    isInitial,
    isLoading,
    isLoaded,
    isComplete,
    isError,
    isNoResult,
    status,
    page,
    pagerHandler,
    resetStatus,
  };
};

参考

zenn.dev

reffect.co.jp

`actions/upload-artifact`で隠しファイル(`.`始まりファイル)をartifactに格納する方法メモ

actions/upload-artifactで存在するはずのファイルが以下のエラーが発生しファイルが見つからずにアップロードできない状態でハマったのでメモ📝

No files were found with the provided path: path/.file. No artifacts will be uploaded.

github.com

結論としてはinclude-hidden-files: trueを指定すればいいだけだった😭

以下のPRで導入され、不意に機密情報を格納されるようなファイルがアップロードされないようにデフォルトでは無効化されているようですね🙏

github.com

参考

qiita.com

zenn.dev