Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/site/src/content/en/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<Divider bds="dashed" my="40" />

Expand Down
1 change: 1 addition & 0 deletions apps/site/src/content/ja/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

<Divider bds="dashed" my="40" />

Expand Down
4 changes: 2 additions & 2 deletions packages/lism-ui/src/components/Accordion/astro/Root.astro
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ const { class: className, as = 'div', allowMultiple, ...props } = Astro.props;
<Lism as={as} class={atts(className, 'b--accordion')} data-allow-multiple={allowMultiple ? '' : undefined} {...props}><slot /></Lism>

<script>
import setAccordion from '../setAccordion';
setAccordion();
import { setAccordionForAstro } from '../setAccordion';
setAccordionForAstro();
</script>
98 changes: 86 additions & 12 deletions packages/lism-ui/src/components/Accordion/setAccordion.test.ts
Original file line number Diff line number Diff line change
@@ -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)),
Expand All @@ -9,19 +9,21 @@ vi.mock('../../helper/animation', () => ({

import { waitAnimation } from '../../helper/animation';

beforeEach(() => {
document.body.innerHTML = `
<div>
<div class="b--accordion_item">
<div class="b--accordion_heading">
<button class="b--accordion_button" aria-expanded="false"></button>
</div>
<div class="b--accordion_panel" hidden="until-found">
<div class="b--accordion_content"></div>
</div>
const FIXTURE = `
<div>
<div class="b--accordion_item">
<div class="b--accordion_heading">
<button class="b--accordion_button" aria-expanded="false"></button>
</div>
<div class="b--accordion_panel" hidden="until-found">
<div class="b--accordion_content"></div>
</div>
</div>
`;
</div>
`;

beforeEach(() => {
document.body.innerHTML = FIXTURE;
vi.mocked(waitAnimation).mockResolvedValue('finished');
});

Expand Down Expand Up @@ -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<HTMLElement>('.b--accordion_item')!;
const button = item.querySelector<HTMLElement>('.b--accordion_button')!;
setEvent(item);
setEvent(item);

expect(countClickHandlerCalls(button)).toBe(1);
});

it('クリーンアップ後の再登録は弾かれない(StrictMode の「登録→解除→登録」)', () => {
const item = document.querySelector<HTMLElement>('.b--accordion_item')!;
const button = item.querySelector<HTMLElement>('.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<HTMLElement>('.b--accordion_item')!;
const button = item.querySelector<HTMLElement>('.b--accordion_button')!;
setEvent(item);

setEvent(item)();

expect(countClickHandlerCalls(button)).toBe(1);
});
});

describe('setAccordionForAstro', () => {
const getButton = () => document.querySelector<HTMLElement>('.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<HTMLElement>('.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);
});
});
21 changes: 20 additions & 1 deletion packages/lism-ui/src/components/Accordion/setAccordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,17 @@ function toggleAccordion(accordionItem: HTMLElement): void {
}
}

// イベント登録済みのアイテム。再初期化で重ねて登録しないよう記録する。
const registeredItems = new WeakSet<HTMLElement>();

/**
* 個別のアコーディオンにイベントをセット(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';
Expand All @@ -105,16 +111,29 @@ 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<HTMLElement>('.b--accordion_item');
accordionAll.forEach((accordionItem) => {
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;
4 changes: 2 additions & 2 deletions packages/lism-ui/src/components/Modal/astro/Modal.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ const mergedStyle = duration ? { ...(style as object), '--duration': duration }
<Lism as="dialog" class={atts(className, 'b--modal')} set="plain" style={mergedStyle} {...props}><slot /></Lism>

<script>
import setModal from '../setModal';
setModal();
import { setModalForAstro } from '../setModal';
setModalForAstro();
</script>
110 changes: 103 additions & 7 deletions packages/lism-ui/src/components/Modal/setModal.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
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)),
}));

import { waitAnimation } from '../../helper/animation';

const FIXTURE = `
<dialog id="m1" class="b--modal">
<button data-modal-close="m1"></button>
</dialog>
<button data-modal-open="m1"></button>
`;

beforeEach(() => {
document.body.innerHTML = `
<dialog id="m1" class="b--modal">
<button data-modal-close="m1"></button>
</dialog>
<button data-modal-open="m1"></button>
`;
document.body.innerHTML = FIXTURE;
vi.mocked(waitAnimation).mockResolvedValue('finished');
});

Expand Down Expand Up @@ -342,3 +344,97 @@ describe('setModal (default export)', () => {
});
});
});

describe('二重登録の防止', () => {
it('同じ modal を2回初期化しても、イベントを重ねて登録しない', () => {
const modal = document.querySelector<HTMLDialogElement>('#m1')!;
const trigger = document.querySelector<HTMLElement>('[data-modal-open="m1"]')!;
const closeTrigger = document.querySelector<HTMLElement>('[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<HTMLDialogElement>('#m1')!;
setEvent(modal);

document.querySelector('[data-modal-open="m1"]')!.remove();
document.body.insertAdjacentHTML('beforeend', '<button data-modal-open="m1"></button>');
const newTrigger = document.querySelector<HTMLElement>('[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<HTMLDialogElement>('#m1')!;
const trigger = document.querySelector<HTMLElement>('[data-modal-open="m1"]')!;
const oldShowModalSpy = vi.spyOn(oldModal, 'showModal');
setEvent(oldModal);

oldModal.remove();
document.body.insertAdjacentHTML('beforeend', '<dialog id="m1" class="b--modal"></dialog>');
const newModal = document.querySelector<HTMLDialogElement>('#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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-modal-open="m1"]')!.click();
expect(root.style.scrollbarGutter).toBe('stable');
});
});
Loading
Loading