引き出し(drawer)はよく使われるUIです。ページ遷移をせずに詳細を表示するのに使います。モーダルと同様、裏の画面のステートを維持したい場合に使用します。
今回の特徴として、引き出しのステートだけでなく、離れたところにある開閉トリガーボタンとのステートの共有方法を考えます。Reactの場合はコンポーネント間で state を共有する問題として紹介されているものです。
なお今回のデモではアクセシビリティ等の考慮はあまり行っていません。
<!DOCTYPE html> <html> <!-- ... --> <body> <div data-controller="slide-drawer" data-slide-drawer-shown-value="false" class="group/slide-drawer" > <div class="w-full min-w-[768px]"> <nav class="flex h-16"> <!-- ... --> </nav> <div><%= content_for(:breadcrumbs) %></div> <%= yield %> </div> <div id="slide-drawer"> <div class="peer transition-all duration-500 ease-out fixed h-full w-[768px] z-20 bg-white top-0 right-0 overflow-auto aria-hidden:translate-x-[768px]" aria-hidden="true" data-slide-drawer-target="drawer"> <%= button_tag type: :button, data: { action: "click->slide-drawer#hide" }, class: "absolute top-2 right-2 h-14 w-14 p-1 bg-gray-500 text-white hover:bg-gray-400 active:bg-gray-600" do %> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-12"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/> </svg> <% end %> <%= turbo_frame_tag :slide_drawer %> </div> <div class="transition-all duration-500 fixed h-full w-full z-19 bg-black opacity-50 top-0 left-0 peer-aria-hidden:opacity-0 peer-aria-hidden:invisible " aria-hidden="true" data-action="click->slide-drawer#hide:prevent"> </div> </div> </div> </body> </html>
<div data-controller="slide-drawer" ...>の箇所でSlideDrawerController Stimulus controllerと接続しています。初期状態では引き出しは閉じていますので、data-slide-drawer-shown-value="false"としています。ここを"true"にすれば初期状態を開いた状態にできます。<%= yeild %>の箇所はタブボタン(開閉トリガーのボタン)がある画面(メイン画面)が挿入されます(下記)。<div id="slide-drawer">は引き出しのコードです。ここに記載しているのは引き出しの「枠」であり、引き出しの中身はサーバから取得され、<turbo-frame_tag id="slide_drawer">に挿入されます。button_tagは「閉じる」ボタンで、data-action="click->slide-drawer#hide"により、クリックするとSlideDrawerControllerのhide()メソッドを呼びます。<div ... data-action="click->slide-drawer#hide:prevent">の箇所は引き出しの背景の黒い幕です。ここをクリックしたときに引き出しを閉じる必要がありますので、SlideDrawerControllerのhide()メソッドを呼ぶようにしていますaria-hidden属性をCSS擬似セレクタで監視して実現しています(初期設定はaria-hidden="true")<div data-controller="carousel" class="relative"> <!-- ... --> </div> <div role="tablist" class="flex flex-row shadow-md border"> <%= link_to "部屋・プラン", "", data: { slide_drawer_target: "tab" }, aria: { selected: true }, class: "px-4 inline-block py-4 h-16 text-lg aria-selected:border-b-4 aria-selected:border-blue-500" %> <%= link_to "宿の紹介", hotel_features_path(@hotel), data: { action: "click->slide-drawer#show", slide_drawer_target: "tab", aria: { selected: false }, turbo_frame: :slide_drawer }, class: "px-4 inline-block py-4 h-16 text-lg aria-selected:border-b-4 aria-selected:border-blue-500" %> <!-- ... --> <%= link_to "---", "", data: { slide_drawer_target: "tab" }, aria: { selected: false }, class: "px-4 inline-block py-4 h-16 text-lg aria-selected:border-b-4 aria-selected:border-blue-500" %> </div> <main class="mt-8 container mx-auto"> <!-- ... --> </main>
data-action="click->slide-drawer#show"をクリックするとSlideDrawerControllerのshow()メソッドが呼ばれ、引き出しが表示されます。<a>タグにdata-turbo-frame="slide_drawer"属性をつけていますので、クリックすると自動的にTurbo Framesでリクエストが送信され、<turbo-frame id="slide_drawer">にレスポンスが挿入されますaria-selected属性(ERBではaria: { selected: true/false }と記載)も変更する必要があります(アクセシビリティの要件)。そのためdata-slide-drawer-target="tab"として、SlideDrawerControllerからtargetとして制御できるようにしなければなりませんaria-selectedをCSS擬似セレクタで監視することによって実装しています<%= turbo_frame_tag :slide_drawer do %> <section class="max-w-[768px] "> <header class="h-16 py-1 px-4 flex justify-between items-center"> <h2 class="text-3xl font-bold">宿の紹介</h2> </header> <div class="px-4"> <nav class="mt-8 h-16 bg-gray-100 flex justify-between items-center"> <%= link_to "トピックス", hotel_features_path(@hotel), class: "text-center py-5 flex-1 h-full block px-4 border-blue-500 border-b-4" %> <%= link_to "お部屋", room_hotel_features_path(@hotel), class: "text-center py-5 flex-1 h-full block px-4" %> <!-- ... --> </nav> <!-- ... --> </div> </section> <% end %>
<turbo-frame id="slide_drawer">に囲まれていますので、これが上記のTurbo Framesに挿入されます<turbo-frame id="slide_drawer">に囲まれていますので、デフォルトではここのTurbo Frameの中だけを置換します(つまりページ全体をナビゲーションするのではなく、Turbo Frameの中だけをナビゲーションします)import { Controller } from "@hotwired/stimulus" // Connects to data-controller="slide-drawer" export default class extends Controller { static values = { shown: {type: Boolean, default: false}, selectedTab: {type: Number, default: 0}, }; static targets = ["drawer", "tab"] connect() { } show(event) { this.shownValue = true this.selectedTabValue = this.tabTargets.indexOf(event.currentTarget) } hide() { this.shownValue = false this.selectedTabValue = 0 } shownValueChanged() { this.#render() } #render() { if (this.shownValue) { document.body.style.overflow = "hidden" this.drawerTarget.ariaHidden = false } else { document.body.style.overflow = "auto" this.drawerTarget.ariaHidden = true } this.tabTargets.forEach((target, i) => { target.ariaSelected = (i === this.selectedTabValue) }) } }
shownは引き出しの開閉状態、selectedTabは何番目のタブボタンが選択されているかを保管していますtargetsを定義しています。引き出しの"drawer"とタブボタンの"tab"を指定しています
data-slide-drawer-shown-value)aria-*属性を変更するには、JavaScriptでDOMを書き換える必要があります。このためにtargetsを使用していますshow(), hide()はタブボタンや「閉じる」ボタン、背景の黒い幕をクリックしたときに引き出しを開閉するアクションです。アクションの中ではValuesステートだけを変更して、ここではDOM操作を行いませんshowValueChanged()は、Valuesステートが変更された時に自動的に呼ばれるコールバックです。ここでは#render()メソッドを呼び出します。#render()でValuesステートに応じて、実際にDOMを変更します。眼にみえる表示状態はCSS擬似セレクタですでに制御されていますので、ここではaria-*属性のみを変更すれば十分です。aria-*属性設定箇所があるため、気をつけないとコードは複雑になりがちです。aria-*属性を変更する場合は、Stimulusのtargetを使って、JavaScriptでDOM操作をすることになります。<!DOCTYPE html> <html> <!-- ... --> <body> <div class="w-full min-w-[768px]"> <nav class="flex h-16"> <!-- ... --> </nav> <div><%= content_for(:breadcrumbs) %></div> <%= yield %> </div> <div data-controller="slide-drawer-zustand" data-slide-drawer-zustand-shown-value="false"> <div class="peer transition-all duration-500 ease-out fixed h-full w-[768px] z-20 bg-white top-0 right-0 overflow-auto aria-hidden:translate-x-[768px]" aria-hidden="true" data-slide-drawer-zustand-target="drawer"> <%= button_tag type: :button, data: { action: "click->slide-drawer-zustand#hide" }, class: "absolute top-2 right-2 h-14 w-14 p-1 bg-gray-500 text-white hover:bg-gray-400 active:bg-gray-600 cursor-pointer" do %> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-12"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/> </svg> <% end %> <%= turbo_frame_tag :slide_drawer %> </div> <div class="transition-all duration-500 fixed h-full w-full z-19 bg-black opacity-50 top-0 left-0 peer-aria-hidden:opacity-0 peer-aria-hidden:invisible " aria-hidden="true" data-action="click->slide-drawer-zustand#hide:prevent"> </div> </div> </body> </html>
SlideDrawerControllerではなく、SlideDrawerZustandControllerだという点だけが異なります。:app/views/hotels/show.html+zustand.erb <div data-controller="carousel" class="relative"> <!-- ... --> </div> <div role="tablist" class="flex flex-row shadow-md border"> <%= link_to "部屋・プラン", "", data: { slide_drawer_target: "tab" }, aria: { selected: true }, class: "px-4 inline-block py-4 h-16 text-lg aria-selected:border-b-4 aria-selected:border-blue-500" %> <%= link_to "宿の紹介", hotel_features_path(@hotel), data: { controller: "slide-drawer-trigger", action: "click->slide-drawer-trigger#show", slide_drawer_target: "tab", aria: { selected: false }, turbo_frame: :slide_drawer }, class: "px-4 inline-block py-4 h-16 text-lg aria-selected:border-b-4 aria-selected:border-blue-500" %> <!-- ... --> <%= link_to "---", "", data: { slide_drawer_target: "tab" }, aria: { selected: false }, class: "px-4 inline-block py-4 h-16 text-lg aria-selected:border-b-4 aria-selected:border-blue-500" %> </div> <main class="mt-8 container mx-auto"> <!-- ... --> </main>
SlideDrawerControllerではなく、SlideDrawerTriggerControllerだという点だけが異なります。SlideDrawerControllerが1つだけあったのが、SlideDrawerZustandControllerとSlideDrawerTriggerControllerの2つになったのが違いです。import { createStore } from "zustand/vanilla"; import { subscribeWithSelector } from "zustand/middleware"; type SlideDrawerState = { drawers: { [key: string]: { opened: boolean } } opened: (key: string) => boolean open: (key: string) => void close: (key: string) => void }; export const slideDrawerStore = createStore<SlideDrawerState>()( subscribeWithSelector( (set, get) => ({ drawers: {}, opened: (key) => get().drawers[key]?.opened ?? false, open: (key) => set(s => ({ drawers: { ...s.drawers, [key]: { opened: true } } })), close: (key) => set(s => ({ drawers: { ...s.drawers, [key]: { opened: false } } })), }), ) );
import { Controller } from "@hotwired/stimulus" import { slideDrawerStore } from "../stores/slide_drawer_store" // Connects to data-controller="slide-drawer" export default class extends Controller { static values = { shown: {type: Boolean, default: false}, selectedTab: {type: Number, default: 0}, } static targets = ["drawer"] connect() { this.storeKey = "hotel-slide-drawer" this.#setState() this.#render() this.unsubscribe = slideDrawerStore.subscribe( (s) => ({ drawerState: s.drawers[this.storeKey] }), (state) => { this.#render() } ) } disconnect() { this.unsubscribe && this.unsubscribe() } show(event) { slideDrawerStore.getState().open(this.storeKey) } hide() { slideDrawerStore.getState().close(this.storeKey) } #setState() { if (this.shownValue === true) { slideDrawerStore.getState().open(this.storeKey) } else { slideDrawerStore.getState().close(this.storeKey) } } #render() { if (slideDrawerStore.getState().opened(this.storeKey)) { document.body.style.overflow = "hidden" this.drawerTarget.ariaHidden = false } else { document.body.style.overflow = "auto" this.drawerTarget.ariaHidden = true } } }
connect()時にZustand storeに接続し、ステートの変更にsubscribeしています。
#render()を呼び出します。 import { Controller } from "@hotwired/stimulus" import { slideDrawerStore } from "../stores/slide_drawer_store" // Connects to data-controller="slide-drawer-trigger" export default class extends Controller { connect() { this.storeKey = "hotel-slide-drawer" this.unsubscribe = slideDrawerStore.subscribe( (s) => ({ drawerState: s.drawers[this.storeKey] }), (state) => { this.#render() } ) } disconnect() { this.unsubscribe && this.unsubscribe() } show() { slideDrawerStore.getState().open(this.storeKey) } #render() { const isOpened = slideDrawerStore.getState().opened(this.storeKey) const allTabs = Array.from(this.element.parentElement.children) const openedTab = isOpened ? this.element : allTabs[0] allTabs.forEach(e => { e.ariaSelected = (e === openedTab) ? 'true' : 'false' }) } }
connect()時にZustand storeに接続し、ステートの変更にsubscribeしています。
#render()を呼び出します。SlideDrawerControllerの制御範囲が大きかったため、SlideDrawerZustandControllerとSlideDrawerTriggerControllerに分けました。
useContext()もしくはグローバルステートを使用することになります。data-*-controllerを貼り付けてcontrollerをDOMにくっつけます。
HTMLElement.datasetを読み書きすればステートを共有できます。detailをやり取りできます。