diff --git a/apps/site/src/content/en/changelog.mdx b/apps/site/src/content/en/changelog.mdx
index e5a63d778..b33a29d79 100644
--- a/apps/site/src/content/en/changelog.mdx
+++ b/apps/site/src/content/en/changelog.mdx
@@ -16,6 +16,7 @@ Significant changes may continue until v1.0 is released, so all notable changes
- **Breaking:** Removed the deprecated component aliases `SideMain` / `FluidCols` / `SwitchCols` and the `Dummy` component from `lism-css/react` and `lism-css/astro`. Migrate to `WithSide` / `AutoColumns` / `SwitchColumns` and `DummyText` from `@lism-css/ui`, respectively (#646).
- **Breaking:** Removed `_propConfig`, which overrode the Lism Props conversion settings per prop, from `getLismProps` and component props. To change the conversion settings, use [`props` in `lism.config.js`](/en/docs/customize/config/) instead (#225).
- `@lism-css/ui`: Fixed `lism-base` styles taking priority over `b--*` styles when component CSS is output before `lism-css/main.css` (for example, a `Button` rendered as an `a` element kept its underline and link color). Each component's CSS and `style.css` now declare the order of `lism-base` and `lism-block` at the top (#644).
+- `@lism-css/ui`: Fixed the Astro `Tabs` / `Accordion` / `Modal` not working on pages reached through `ClientRouter` navigation. They are now initialized on `astro:page-load` as well as on load, and events are not registered twice on the same element (#640).
diff --git a/apps/site/src/content/ja/changelog.mdx b/apps/site/src/content/ja/changelog.mdx
index 983298186..3c1529489 100644
--- a/apps/site/src/content/ja/changelog.mdx
+++ b/apps/site/src/content/ja/changelog.mdx
@@ -16,6 +16,7 @@ v1.0のリリースまでは大きな変更が続く可能性があるため、
- **破壊的変更:** 非推奨だったコンポーネントエイリアス`SideMain` / `FluidCols` / `SwitchCols`と`Dummy`コンポーネントを、`lism-css/react`と`lism-css/astro`から削除しました。それぞれ`WithSide` / `AutoColumns` / `SwitchColumns`と`@lism-css/ui`の`DummyText`へ移行してください(#646)。
- **破壊的変更:** Lism Propsの変換設定をprop単位で上書きする`_propConfig`を、`getLismProps`と各コンポーネントのpropsから削除しました。変換設定を変えたい場合は、[`lism.config.js`の`props`](/docs/customize/config/)で設定してください(#225)。
- `@lism-css/ui`: コンポーネントのCSSが`lism-css/main.css`より前に出力されると、`lism-base`のスタイルが`b--*`のスタイルより優先される不具合を修正しました(`a`要素の`Button`に下線とリンク色が残るなど)。各コンポーネントのCSSと`style.css`の先頭で、`lism-base`と`lism-block`の順序を宣言します(#644)。
+- `@lism-css/ui`: Astro版の`Tabs` / `Accordion` / `Modal`が、`ClientRouter`で遷移した先のページで動作しない不具合を修正しました。読み込み時に加えて`astro:page-load`でも初期化し、同じ要素にはイベントを重ねて登録しません(#640)。
diff --git a/packages/lism-ui/src/components/Accordion/astro/Root.astro b/packages/lism-ui/src/components/Accordion/astro/Root.astro
index 116dfa321..7c68af045 100644
--- a/packages/lism-ui/src/components/Accordion/astro/Root.astro
+++ b/packages/lism-ui/src/components/Accordion/astro/Root.astro
@@ -18,6 +18,6 @@ const { class: className, as = 'div', allowMultiple, ...props } = Astro.props;
diff --git a/packages/lism-ui/src/components/Accordion/setAccordion.test.ts b/packages/lism-ui/src/components/Accordion/setAccordion.test.ts
index a1ce41b4a..6f1d1b7b2 100644
--- a/packages/lism-ui/src/components/Accordion/setAccordion.test.ts
+++ b/packages/lism-ui/src/components/Accordion/setAccordion.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import setAccordion, { setEvent } from './setAccordion';
+import setAccordion, { setEvent, setAccordionForAstro } from './setAccordion';
vi.mock('../../helper/animation', () => ({
waitFrame: vi.fn(() => Promise.resolve(0)),
@@ -9,19 +9,21 @@ vi.mock('../../helper/animation', () => ({
import { waitAnimation } from '../../helper/animation';
-beforeEach(() => {
- document.body.innerHTML = `
-
-
-
-
-
-
+const FIXTURE = `
+
+`;
+
+beforeEach(() => {
+ document.body.innerHTML = FIXTURE;
vi.mocked(waitAnimation).mockResolvedValue('finished');
});
@@ -296,3 +298,75 @@ describe('setAccordion (default export)', () => {
});
});
});
+
+// 1回の click でハンドラが動いた回数を、preventDefault の呼び出し回数で数える
+const countClickHandlerCalls = (button: HTMLElement): number => {
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ const preventDefault = vi.spyOn(event, 'preventDefault');
+ button.dispatchEvent(event);
+ return preventDefault.mock.calls.length;
+};
+
+describe('二重登録の防止', () => {
+ it('同じ item を2回初期化しても、1回の click でハンドラは1回だけ動く', () => {
+ const item = document.querySelector
('.b--accordion_item')!;
+ const button = item.querySelector('.b--accordion_button')!;
+ setEvent(item);
+ setEvent(item);
+
+ expect(countClickHandlerCalls(button)).toBe(1);
+ });
+
+ it('クリーンアップ後の再登録は弾かれない(StrictMode の「登録→解除→登録」)', () => {
+ const item = document.querySelector('.b--accordion_item')!;
+ const button = item.querySelector('.b--accordion_button')!;
+
+ setEvent(item)();
+ const cleanup = setEvent(item);
+
+ expect(countClickHandlerCalls(button)).toBe(1);
+
+ cleanup();
+ expect(countClickHandlerCalls(button)).toBe(0);
+ });
+
+ it('弾かれた2回目の戻り値を呼んでも、1回目の登録は解除されない', () => {
+ const item = document.querySelector('.b--accordion_item')!;
+ const button = item.querySelector('.b--accordion_button')!;
+ setEvent(item);
+
+ setEvent(item)();
+
+ expect(countClickHandlerCalls(button)).toBe(1);
+ });
+});
+
+describe('setAccordionForAstro', () => {
+ const getButton = () => document.querySelector('.b--accordion_button')!;
+
+ it('読み込み時点の要素をすぐ初期化する', () => {
+ setAccordionForAstro();
+
+ expect(countClickHandlerCalls(getButton())).toBe(1);
+ });
+
+ it('body を差し替えて astro:page-load を発火させると、新しい要素が動く', async () => {
+ setAccordionForAstro();
+
+ document.body.innerHTML = FIXTURE;
+ document.dispatchEvent(new Event('astro:page-load'));
+
+ const item = document.querySelector('.b--accordion_item')!;
+ getButton().click();
+ await vi.waitFor(() => {
+ expect(item).toHaveAttribute('data-opened');
+ });
+ });
+
+ it('astro:page-load で再初期化しても、1回の click でハンドラは1回だけ動く', () => {
+ setAccordionForAstro();
+ document.dispatchEvent(new Event('astro:page-load'));
+
+ expect(countClickHandlerCalls(getButton())).toBe(1);
+ });
+});
diff --git a/packages/lism-ui/src/components/Accordion/setAccordion.ts b/packages/lism-ui/src/components/Accordion/setAccordion.ts
index 76852f644..d93c04bf8 100644
--- a/packages/lism-ui/src/components/Accordion/setAccordion.ts
+++ b/packages/lism-ui/src/components/Accordion/setAccordion.ts
@@ -79,11 +79,17 @@ function toggleAccordion(accordionItem: HTMLElement): void {
}
}
+// イベント登録済みのアイテム。再初期化で重ねて登録しないよう記録する。
+const registeredItems = new WeakSet();
+
/**
* 個別のアコーディオンにイベントをセット(React用にクリーンアップ関数を返す)
*/
export const setEvent = (accordionItem: HTMLElement): (() => void) => {
+ if (registeredItems.has(accordionItem)) return () => undefined;
+
const { button, panel } = getAccordionElements(accordionItem);
+ registeredItems.add(accordionItem);
if (panel.hasAttribute('hidden')) {
ACCORDION_HIDDEN_VALUE = panel.getAttribute('hidden') ?? 'until-found';
@@ -105,11 +111,13 @@ export const setEvent = (accordionItem: HTMLElement): (() => void) => {
return () => {
button.removeEventListener('click', _clickEvent);
panel.removeEventListener('beforematch', _beforematchEvent);
+ // 解除後は再登録できるよう記録も消す(ReactのStrictModeは「登録→解除→登録」の順に呼ぶ)
+ registeredItems.delete(accordionItem);
};
};
/**
- * ページ内の全アコーディオンにイベントをセット(Astro用)
+ * ページ内の全アコーディオンにイベントをセット
*/
const setAccordion = (): void => {
const accordionAll = document.querySelectorAll('.b--accordion_item');
@@ -117,4 +125,15 @@ const setAccordion = (): void => {
setEvent(accordionItem);
});
};
+
+/**
+ * ページ内の全アコーディオンを初期化する(Astro用)
+ * Point: ClientRouterでの遷移後は script が再実行されないため、astro:page-load でも初期化する。
+ * ClientRouterが無いページでは astro:page-load が発火しないため、すぐ初期化する処理も必要。
+ */
+export const setAccordionForAstro = (): void => {
+ setAccordion();
+ document.addEventListener('astro:page-load', setAccordion);
+};
+
export default setAccordion;
diff --git a/packages/lism-ui/src/components/Modal/astro/Modal.astro b/packages/lism-ui/src/components/Modal/astro/Modal.astro
index ffaa34d83..98a09f6b8 100644
--- a/packages/lism-ui/src/components/Modal/astro/Modal.astro
+++ b/packages/lism-ui/src/components/Modal/astro/Modal.astro
@@ -17,6 +17,6 @@ const mergedStyle = duration ? { ...(style as object), '--duration': duration }
diff --git a/packages/lism-ui/src/components/Modal/setModal.test.ts b/packages/lism-ui/src/components/Modal/setModal.test.ts
index b461dcd47..76c702c16 100644
--- a/packages/lism-ui/src/components/Modal/setModal.test.ts
+++ b/packages/lism-ui/src/components/Modal/setModal.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import setModal, { setEvent } from './setModal';
+import setModal, { setEvent, setModalForAstro } from './setModal';
vi.mock('../../helper/animation', () => ({
waitAnimation: vi.fn(() => Promise.resolve('finished' as const)),
@@ -7,13 +7,15 @@ vi.mock('../../helper/animation', () => ({
import { waitAnimation } from '../../helper/animation';
+const FIXTURE = `
+
+
+
+
+`;
+
beforeEach(() => {
- document.body.innerHTML = `
-
-
-
-
- `;
+ document.body.innerHTML = FIXTURE;
vi.mocked(waitAnimation).mockResolvedValue('finished');
});
@@ -342,3 +344,97 @@ describe('setModal (default export)', () => {
});
});
});
+
+describe('二重登録の防止', () => {
+ it('同じ modal を2回初期化しても、イベントを重ねて登録しない', () => {
+ const modal = document.querySelector('#m1')!;
+ const trigger = document.querySelector('[data-modal-open="m1"]')!;
+ const closeTrigger = document.querySelector('[data-modal-close="m1"]')!;
+ const spies = [modal, trigger, closeTrigger].map((el) => vi.spyOn(el, 'addEventListener'));
+
+ setEvent(modal);
+ const counts = spies.map((spy) => spy.mock.calls.length);
+ setEvent(modal);
+
+ expect(counts.every((count) => count > 0)).toBe(true);
+ expect(spies.map((spy) => spy.mock.calls.length)).toEqual(counts);
+ });
+
+ it('modal だけが残った場合(transition:persist)、新しいトリガーを登録する', async () => {
+ const modal = document.querySelector('#m1')!;
+ setEvent(modal);
+
+ document.querySelector('[data-modal-open="m1"]')!.remove();
+ document.body.insertAdjacentHTML('beforeend', ' ');
+ const newTrigger = document.querySelector('[data-modal-open="m1"]')!;
+ setEvent(modal);
+
+ newTrigger.click();
+ await vi.waitFor(() => {
+ expect(modal.dataset.isOpen).toBe('1');
+ expect(newTrigger.dataset.targetOpened).toBe('1');
+ });
+
+ modal.close();
+ expect(newTrigger.dataset.targetOpened).toBeUndefined();
+ });
+
+ it('トリガーだけが残った場合(transition:persist)、新しい modal を開く', async () => {
+ const oldModal = document.querySelector('#m1')!;
+ const trigger = document.querySelector('[data-modal-open="m1"]')!;
+ const oldShowModalSpy = vi.spyOn(oldModal, 'showModal');
+ setEvent(oldModal);
+
+ oldModal.remove();
+ document.body.insertAdjacentHTML('beforeend', ' ');
+ const newModal = document.querySelector('#m1')!;
+ setEvent(newModal);
+
+ trigger.click();
+ await vi.waitFor(() => {
+ expect(newModal.dataset.isOpen).toBe('1');
+ });
+ expect(oldShowModalSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe('setModalForAstro', () => {
+ it('読み込み時点の要素をすぐ初期化する', async () => {
+ setModalForAstro();
+
+ document.querySelector('[data-modal-open="m1"]')!.click();
+ await vi.waitFor(() => {
+ expect(document.querySelector('#m1')).toHaveAttribute('open');
+ });
+ });
+
+ it('body を差し替えて astro:page-load を発火させると、新しい要素が動く', async () => {
+ setModalForAstro();
+
+ document.body.innerHTML = FIXTURE;
+ document.dispatchEvent(new Event('astro:page-load'));
+
+ document.querySelector('[data-modal-open="m1"]')!.click();
+ await vi.waitFor(() => {
+ expect(document.querySelector('#m1')).toHaveAttribute('open');
+ });
+ });
+
+ it('modal を開いたまま遷移しても、遷移先で scrollbar-gutter を予約できる', () => {
+ const root = document.documentElement;
+ // jsdom は clientWidth が 0 のため、スクロールバーが実幅を持つ環境として扱われる
+ setModalForAstro();
+ document.querySelector('[data-modal-open="m1"]')!.click();
+ expect(root.style.scrollbarGutter).toBe('stable');
+
+ // ClientRouter の遷移を模す: close イベントを経ずに body が入れ替わり、html の属性も遷移先のものになる
+ document.body.innerHTML = FIXTURE;
+ root.setAttribute('style', 'scrollbar-gutter: stable both-edges');
+ document.dispatchEvent(new Event('astro:page-load'));
+ // 遷移元の値で上書きしない
+ expect(root.style.scrollbarGutter).toBe('stable both-edges');
+
+ document.querySelector('[data-modal-open="m1"]')!.click();
+ expect(root.style.scrollbarGutter).toBe('stable');
+ });
+});
diff --git a/packages/lism-ui/src/components/Modal/setModal.ts b/packages/lism-ui/src/components/Modal/setModal.ts
index d5c7aa725..5cfe9e0ba 100644
--- a/packages/lism-ui/src/components/Modal/setModal.ts
+++ b/packages/lism-ui/src/components/Modal/setModal.ts
@@ -41,6 +41,21 @@ const unlockScrollbarGutter = (): void => {
const isInPageLink = (link: HTMLAnchorElement): boolean =>
link.href.includes('#') && link.origin === location.origin && link.pathname === location.pathname && link.search === location.search;
+/*
+ * 再初期化で重ねて登録しないよう、登録済みの要素を記録する。
+ * modal外にある開くトリガーは、modalと別に記録する。
+ * transition:persist で片方だけが残っても、遷移先の新しい要素を登録するため。
+ */
+const modalOpeners = new WeakMap void>();
+const registeredOpenTriggers = new WeakSet();
+
+// 片方だけが入れ替わっても新しいmodalを開けるよう、対象のmodalはクリック時に探す。
+function onOpenTriggerClick(e: Event): void {
+ const trigger = e.currentTarget as HTMLElement;
+ const modal = document.getElementById(trigger.getAttribute('data-modal-open') ?? '');
+ if (modal instanceof HTMLDialogElement) modalOpeners.get(modal)?.(trigger);
+}
+
/** dialogの開閉、トリガー状態の復元、背景クリック、ページ内リンク、Esc操作を設定する。 */
export function setEvent(target: HTMLElement): void {
// 対象がない、またはidがない場合は処理を終了
@@ -51,11 +66,19 @@ export function setEvent(target: HTMLElement): void {
const modal = target;
+ // openボタンにイベント登録
+ document.querySelectorAll(`[data-modal-open="${modal.id}"]`).forEach((trigger) => {
+ if (registeredOpenTriggers.has(trigger)) return;
+ registeredOpenTriggers.add(trigger);
+ trigger.addEventListener('click', onOpenTriggerClick);
+ });
+
+ if (modalOpeners.has(modal)) return;
+
// オープンした時のトリガー要素を記憶する(data属性を戻すため)
let theTrigger: HTMLElement | null = null;
- // モーダルを開くトリガーと閉じるトリガーを取得
- const openTriggers = document.querySelectorAll(`[data-modal-open="${modal.id}"]`);
+ // モーダルを閉じるトリガーを取得
const closeTriggers = modal.querySelectorAll(`[data-modal-close="${modal.id}"]`);
// scrollbar幅を固定してdialogを開き、次フレームで開始属性を付ける。
@@ -92,16 +115,14 @@ export function setEvent(target: HTMLElement): void {
unlockScrollbarGutter();
};
- // openボタンにイベント登録
- openTriggers.forEach((trigger) => {
- trigger?.addEventListener('click', () => {
- // button側にもdata属性付与
- trigger.dataset.targetOpened = '1';
- theTrigger = trigger; // close() 時にdata属性削除するために記憶
+ // openボタンのクリックで呼ばれる処理
+ modalOpeners.set(modal, (trigger) => {
+ // button側にもdata属性付与
+ trigger.dataset.targetOpened = '1';
+ theTrigger = trigger; // close() 時にdata属性削除するために記憶
- // モーダルを開く
- openDialog();
- });
+ // モーダルを開く
+ openDialog();
});
// closeボタンにイベント登録
@@ -156,9 +177,24 @@ export function setEvent(target: HTMLElement): void {
}
const setModal = () => {
+ // modalを開いたままClientRouterで遷移すると、closeイベントが発火せず予約状態だけが残る。
+ // htmlの属性は遷移先の値に置き換わっているため、遷移元の値を書き戻さずに破棄する。
+ if (!document.querySelector('.b--modal[open]')) savedScrollbarGutter = null;
+
const modals = document.querySelectorAll('.b--modal');
modals?.forEach((target) => {
setEvent(target as HTMLElement);
});
};
+
+/**
+ * ページ内の全モーダルを初期化する(Astro用)
+ * Point: ClientRouterでの遷移後は script が再実行されないため、astro:page-load でも初期化する。
+ * ClientRouterが無いページでは astro:page-load が発火しないため、すぐ初期化する処理も必要。
+ */
+export const setModalForAstro = (): void => {
+ setModal();
+ document.addEventListener('astro:page-load', setModal);
+};
+
export default setModal;
diff --git a/packages/lism-ui/src/components/Tabs/astro/Root.astro b/packages/lism-ui/src/components/Tabs/astro/Root.astro
index 9a2b880d8..7f7b5efa6 100644
--- a/packages/lism-ui/src/components/Tabs/astro/Root.astro
+++ b/packages/lism-ui/src/components/Tabs/astro/Root.astro
@@ -50,9 +50,6 @@ const hasItems = btns.length > 0;
diff --git a/packages/lism-ui/src/components/Tabs/setTabs.test.ts b/packages/lism-ui/src/components/Tabs/setTabs.test.ts
index 23634a34b..72c5cc6e1 100644
--- a/packages/lism-ui/src/components/Tabs/setTabs.test.ts
+++ b/packages/lism-ui/src/components/Tabs/setTabs.test.ts
@@ -1,20 +1,22 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import setTabs from './setTabs';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import setTabs, { setTabsForAstro } from './setTabs';
// Tab.astro / Panel.astro が出力するマークアップを模したフィクスチャ
-beforeEach(() => {
- document.body.innerHTML = `
-
-
-
-
-
-
-
-
-
+const FIXTURE = `
+
+`;
+
+beforeEach(() => {
+ document.body.innerHTML = FIXTURE;
history.replaceState({}, '', '/');
});
@@ -163,3 +165,60 @@ describe('setTabs / ディープリンク', () => {
expectSelected(0);
});
});
+
+describe('setTabs / 二重初期化の防止', () => {
+ it('同じ要素を2回初期化しても、1回の click でハンドラは1回だけ動く', () => {
+ const { tabs, tabBtns } = getEls();
+ setTabs(tabs);
+ setTabs(tabs);
+
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ const preventDefault = vi.spyOn(event, 'preventDefault');
+ tabBtns[1].dispatchEvent(event);
+
+ expect(preventDefault).toHaveBeenCalledTimes(1);
+ expectSelected(1);
+ });
+
+ it('初期化済みの要素を再初期化しても、ディープリンクで選択を戻さない', () => {
+ const { tabs, tabBtns } = getEls();
+
+ history.replaceState({}, '', '/?lism-tab=panel2');
+ setTabs(tabs);
+ tabBtns[2].click();
+ setTabs(tabs);
+
+ expectSelected(2);
+ });
+});
+
+describe('setTabsForAstro', () => {
+ it('読み込み時点の要素をすぐ初期化する', () => {
+ setTabsForAstro();
+
+ getEls().tabBtns[1].click();
+
+ expectSelected(1);
+ });
+
+ it('body を差し替えて astro:page-load を発火させると、新しい要素が動く', () => {
+ setTabsForAstro();
+
+ document.body.innerHTML = FIXTURE;
+ document.dispatchEvent(new Event('astro:page-load'));
+ getEls().tabBtns[1].click();
+
+ expectSelected(1);
+ });
+
+ it('astro:page-load で再初期化しても、1回の click でハンドラは1回だけ動く', () => {
+ setTabsForAstro();
+ document.dispatchEvent(new Event('astro:page-load'));
+
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ const preventDefault = vi.spyOn(event, 'preventDefault');
+ getEls().tabBtns[1].dispatchEvent(event);
+
+ expect(preventDefault).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/lism-ui/src/components/Tabs/setTabs.ts b/packages/lism-ui/src/components/Tabs/setTabs.ts
index f35758cae..10e8a4d30 100644
--- a/packages/lism-ui/src/components/Tabs/setTabs.ts
+++ b/packages/lism-ui/src/components/Tabs/setTabs.ts
@@ -57,7 +57,13 @@ function tabKeyControl(e: KeyboardEvent): void {
nextBtn.focus();
}
+// 初期化済みのタブ。再初期化でディープリンクが選択を戻さないよう記録する。
+const initializedTabs = new WeakSet
();
+
function setTabs(tabs: HTMLElement): void {
+ if (initializedTabs.has(tabs)) return;
+ initializedTabs.add(tabs);
+
const tabBtns = getTabBtns(tabs);
tabBtns.forEach((tabBtn) => {
tabBtn.addEventListener('click', tabControl);
@@ -75,4 +81,20 @@ function setTabs(tabs: HTMLElement): void {
if (target) selectTab(target);
}
+const setTabsAll = (): void => {
+ document.querySelectorAll('.b--tabs').forEach((tabs) => {
+ setTabs(tabs);
+ });
+};
+
+/**
+ * ページ内の全タブを初期化する(Astro用)
+ * Point: ClientRouterでの遷移後は script が再実行されないため、astro:page-load でも初期化する。
+ * ClientRouterが無いページでは astro:page-load が発火しないため、すぐ初期化する処理も必要。
+ */
+export const setTabsForAstro = (): void => {
+ setTabsAll();
+ document.addEventListener('astro:page-load', setTabsAll);
+};
+
export default setTabs;