From 5c69f186667e19803555b503d5cd4f02976062db Mon Sep 17 00:00:00 2001 From: oki07 Date: Tue, 10 Dec 2024 09:20:38 +0900 Subject: [PATCH] =?UTF-8?q?dropdown-dialog=E3=82=B3=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dropdownDialog/dropdown-dialog.css | 27 ++ .../dropdownDialog/sp-dropdown-dialog.ts | 162 ++++++++++++ src/index.ts | 1 + .../sp-dropdown-dialog.story.ts | 67 +++++ .../dropdownDialog/sp-dropdown-dialog.test.ts | 245 ++++++++++++++++++ 5 files changed, 502 insertions(+) create mode 100644 src/components/dropdownDialog/dropdown-dialog.css create mode 100644 src/components/dropdownDialog/sp-dropdown-dialog.ts create mode 100644 stories/dropdownDialog/sp-dropdown-dialog.story.ts create mode 100644 tests/dropdownDialog/sp-dropdown-dialog.test.ts diff --git a/src/components/dropdownDialog/dropdown-dialog.css b/src/components/dropdownDialog/dropdown-dialog.css new file mode 100644 index 0000000..14b69e6 --- /dev/null +++ b/src/components/dropdownDialog/dropdown-dialog.css @@ -0,0 +1,27 @@ +.base { + position: relative; +} + +.dialog { + position: absolute; + z-index: 1; + min-width: 560px; + margin-block-start: 8px; + padding: 24px; + background: var(--color-semantic-surface-regular-1); + border: 1px solid var(--color-semantic-border-semi-weak); + border-radius: 5px; + box-shadow: 0 3px 12px 0 var(--color-semantic-elevation-regular); + font-size: 12px; + line-height: 1.6; +} + +.dialog.position__left { + left: 0; + right: auto; +} + +.dialog.position__right { + left: auto; + right: 0; +} diff --git a/src/components/dropdownDialog/sp-dropdown-dialog.ts b/src/components/dropdownDialog/sp-dropdown-dialog.ts new file mode 100644 index 0000000..f167207 --- /dev/null +++ b/src/components/dropdownDialog/sp-dropdown-dialog.ts @@ -0,0 +1,162 @@ +// @ts-ignore +import resetStyle from "@acab/reset.css?inline" assert { type: "css" }; +// @ts-ignore +import foundationStyle from "../foundation.css?inline" assert { type: "css" }; +// @ts-ignore +import dropdownDialogStyle from "./dropdown-dialog.css?inline" assert { type: "css" }; +import "../button/sp-button"; + +type Position = "left" | "right"; + +const positions: Position[] = ["left", "right"]; + +function isValidPosition(value: string): value is Position { + return positions.some((position) => position === value); +} + +const styles = new CSSStyleSheet(); +styles.replaceSync(`${resetStyle} ${foundationStyle} ${dropdownDialogStyle}`); + +export class SpDropdownDialog extends HTMLElement { + #baseElement = document.createElement("div"); + #buttonElement = document.createElement("sp-button"); + #dialogElement = document.createElement("div"); + #dialogSlotElement = document.createElement("slot"); + + #open: boolean = false; + #disabled: boolean = false; + #position: Position = "left"; + + set label(value: string) { + this.#buttonElement.text = value; + } + + get open() { + return this.#open; + } + set open(value: boolean) { + this.#open = value; + + if (value) { + this.#buttonElement.setAttribute("selected", ""); + } else { + this.#buttonElement.removeAttribute("selected"); + } + + this.#updateDialogDisplay(); + } + + get disabled() { + return this.#disabled; + } + set disabled(value: boolean) { + this.#disabled = value; + this.#buttonElement.disabled = value; + this.#updateDialogDisplay(); + } + + get position() { + return this.#position; + } + set position(value: Position) { + if (value === "left") { + this.#dialogElement.classList.add("position__left"); + this.#dialogElement.classList.remove("position__right"); + } else { + this.#dialogElement.classList.add("position__right"); + this.#dialogElement.classList.remove("position__left"); + } + + this.#position = value; + } + + static get observedAttributes() { + return ["label", "open", "disabled", "position"]; + } + + constructor() { + super(); + + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, styles]; + + this.open = false; + this.disabled = false; + this.position = "left"; + } + + connectedCallback() { + this.#buttonElement.setAttribute("part", "button"); + this.#buttonElement.addEventListener( + "click", + this.#handleClickButton.bind(this), + ); + + this.#baseElement.appendChild(this.#buttonElement); + + this.#dialogElement.classList.add("dialog"); + this.#dialogElement.role = "dialog"; + this.#dialogElement.appendChild(this.#dialogSlotElement); + + window.addEventListener("click", this.#handleClickOutside.bind(this)); + + this.#baseElement.appendChild(this.#dialogElement); + this.#baseElement.classList.add("base"); + + this.shadowRoot?.appendChild(this.#baseElement); + } + + disconnectedCallback() { + window.removeEventListener("click", this.#handleClickOutside.bind(this)); + } + + attributeChangedCallback(name: string, oldValue: string, newValue: string) { + if (oldValue === newValue) return; + switch (name) { + case "label": + this.label = newValue; + break; + case "open": + this.open = newValue === "true" || newValue === ""; + break; + case "disabled": + this.disabled = newValue === "true" || newValue === ""; + break; + case "position": + if (isValidPosition(newValue)) { + this.position = newValue; + } else { + console.warn(`${newValue}は無効なposition属性です。`); + this.position = "left"; + } + } + } + + #handleClickButton(event: MouseEvent) { + event.stopPropagation(); + + this.open = !this.open; + } + + #handleClickOutside(event: MouseEvent) { + event.stopPropagation(); + + if (!this.contains(event.target as Node)) { + this.open = false; + } + } + + #updateDialogDisplay() { + this.#dialogElement.style.display = + this.open && !this.disabled ? "block" : "none"; + } +} + +declare global { + interface HTMLElementTagNameMap { + "sp-dropdown-dialog": SpDropdownDialog; + } +} + +customElements.get("sp-dropdown-dialog") || + customElements.define("sp-dropdown-dialog", SpDropdownDialog); diff --git a/src/index.ts b/src/index.ts index 483aed7..f890209 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,4 @@ export { SpCheckboxText } from "./components/checkbox/sp-checkbox-text"; export { SpCheckboxList } from "./components/checkbox/sp-checkbox-list"; export { SpIcon } from "./components/icon/sp-icon"; export { SpRadioButtonTextGroup } from "./components/radio/sp-radio-button-text-group"; +export { SpDropdownDialog } from "./components/dropdownDialog/sp-dropdown-dialog"; diff --git a/stories/dropdownDialog/sp-dropdown-dialog.story.ts b/stories/dropdownDialog/sp-dropdown-dialog.story.ts new file mode 100644 index 0000000..cdd5af3 --- /dev/null +++ b/stories/dropdownDialog/sp-dropdown-dialog.story.ts @@ -0,0 +1,67 @@ +import "@sp-design/token/lib/speeda-tokens.css"; +import type { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; +import "../../src/components/dropdownDialog/sp-dropdown-dialog"; + +const meta: Meta = { + component: "sp-dropdown-dialog", + argTypes: {}, + args: {}, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => html` + +

ダイアログのタイトル

+ ダイアログの内容 +
+ `, +}; + +export const LongText: Story = { + render: () => html` + +

検索式

+
S001 FI:G08G1/16?
+
S002 FI:B60W30?+B60W40?+B60W50?
+
S003 FI:B60?+G01C21/?+G08G1/?+G05D1/?
+
+ S004 + 全文:?先進運転支援?+?高度運転支援?+?advanced?*?driver-assistance?*?systems? +
+
+ S005 + 名称+要約+請求項:?自動運転?+?自動走行?+?自律運転?+?自律走行?+?オートクルーズ?+?衝突被害軽減?+?車間距離制御?+?アダプティブクルーズコントロール?+?アダプティブフロントライティング?+?車線維持支援?+?車線逸脱防止?+?車線逸脱警告?+?死角検出?+?死角検知?+?死角モニタ?+?クロストラフィックアラート?+?駐車支援?+?パーキングアシスト?+?トラフィックジャムアシスト?+?渋滞運転支援?+?ナイトビジョン?+?暗視? +
+
S006 名称+要約+請求項:[?自動?*?ブレーキ?,?制動?]W3
+
+ S007 名称+要約+請求項:[?歩行者?,?標識?,?居眠?*?検知?,?検出?,?認識?]W3 +
+
S008 名称+要約+請求項:[?前方?*?衝突?]W3
+
+ S009 名称+要約+請求項:[?運転者?,?運転手?,?ドライバ?*?監視?,?モニタ?]W3 +
+
S010 論理式:S001+S002+S003*(S004+S005+S006+S007+S008+S009)
+
+ `, +}; + +export const RightPosition: Story = { + render: () => html` +
+ +

ダイアログのタイトル

+ ダイアログの内容 +
+
+ `, +}; + +export const Disabled: Story = { + render: () => html` + + `, +}; diff --git a/tests/dropdownDialog/sp-dropdown-dialog.test.ts b/tests/dropdownDialog/sp-dropdown-dialog.test.ts new file mode 100644 index 0000000..26b4e67 --- /dev/null +++ b/tests/dropdownDialog/sp-dropdown-dialog.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from "vitest"; +import { screen, getByShadowTestId } from "shadow-dom-testing-library"; +import userEvent from "@testing-library/user-event"; +import { SpDropdownDialog } from "../../src/components/dropdownDialog/sp-dropdown-dialog"; +import "../../src/components/dropdownDialog/sp-dropdown-dialog"; + +function getSpDropdownDialog() { + return document.querySelector("sp-dropdown-dialog") as SpDropdownDialog; +} + +function getButton(label: string): HTMLButtonElement { + return screen.getByShadowRole("button", { name: label }); +} + +function getDialog() { + return screen.getByShadowRole("dialog"); +} + +function queryDialog() { + return screen.queryByShadowRole("dialog"); +} + +describe("sp-dropdown-dialog", () => { + describe("label属性", () => { + test("label属性を設定すると、ボタンにその文字列が表示される", async () => { + document.body.innerHTML = ` + + `; + + const button = getButton("ダイアログを表示"); + expect(button).not.toBeNull(); + }); + + test("label属性に空文字を設定すると、ボタンに空文字が表示される", async () => { + document.body.innerHTML = ` + + `; + + const button = getButton(""); + expect(button).not.toBeNull(); + }); + + test("label属性を更新すると、ボタンに更新後の文字列が表示される", async () => { + const user = userEvent.setup(); + + document.body.innerHTML = ` + + `; + + const button = getButton("ダイアログを表示"); + await user.click(button); + + const spDropdownAction = getSpDropdownDialog(); + spDropdownAction.setAttribute("label", "ダッシュボード編集"); + + const newButton = getButton("ダッシュボード編集"); + expect(newButton).not.toBeNull(); + }); + }); + + describe("open属性", () => { + test("open属性にtrueを設定すると、ダイアログが表示される", async () => { + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const dialog = getDialog(); + expect(dialog).not.toBeNull(); + }); + + test("open属性に空文字を設定すると、ダイアログが表示される", async () => { + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const dialog = queryDialog(); + expect(dialog).not.toBeNull(); + }); + + test("open属性にfalseを設定すると、ダイアログが非表示になる", async () => { + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const dialog = queryDialog(); + expect(dialog).toBeNull(); + }); + + test("open属性を更新すると、ダイアログの表示状態が変わる", async () => { + const user = userEvent.setup(); + + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const button = getButton("ダイアログを表示"); + await user.click(button); + + const spDropdownAction = getSpDropdownDialog(); + spDropdownAction.setAttribute("open", "false"); + + const dialog = queryDialog(); + expect(dialog).toBeNull(); + }); + }); + + describe("disabled属性", () => { + test("disabled属性にtrueを設定すると、ボタンがdisabledになる", async () => { + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const button = getButton("ダイアログを表示"); + expect(button.disabled).toBe(true); + }); + + test("disabled属性に空文字を設定すると、ボタンがdisabledになる", async () => { + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const button = getButton("ダイアログを表示"); + expect(button.disabled).toBe(true); + }); + + test("disabled属性にfalseを設定すると、ボタンがdisabledにならない", async () => { + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const button = getButton("ダイアログを表示"); + expect(button.disabled).toBe(false); + }); + + test("disabled属性を更新すると、ボタンのdisabled状態が変わる", async () => { + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const spDropdownAction = getSpDropdownDialog(); + spDropdownAction.setAttribute("disabled", "false"); + + const button = getButton("ダイアログを表示"); + expect(button.disabled).toBe(false); + }); + }); + + describe("position属性", () => { + test.each([ + ["left", "position__left"], + ["right", "position__right"], + ])( + "position属性に%sを設定すると、ダイアログのクラスに%sが設定される", + async (position, className) => { + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const dialog = getDialog(); + expect(dialog.classList.contains(className)).toBe(true); + }, + ); + }); + + describe("ダイアログの表示", () => { + test("ボタンをクリックすると、ダイアログが表示される", async () => { + const user = userEvent.setup(); + + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const button = getButton("ダイアログを表示"); + await user.click(button); + + const dialog = queryDialog(); + expect(dialog).not.toBeNull(); + }); + + test("ダイアログが表示された状態でボタンをクリックすると、ダイアログが非表示になる", async () => { + const user = userEvent.setup(); + + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + const button = getButton("ダイアログを表示"); + await user.click(button); + + const dialog = queryDialog(); + expect(dialog).toBeNull(); + }); + + test("ダイアログが表示された状態でダイアログやボタン以外の要素をクリックすると、ダイアログが非表示になる", async () => { + const user = userEvent.setup(); + + document.body.innerHTML = ` + + ダイアログの内容 + + `; + + await user.click(document.body); + + const dialog = queryDialog(); + expect(dialog).toBeNull(); + }); + }); + + describe("ダイアログの内容の表示", () => { + test("子要素がダイアログの内容として表示される", async () => { + document.body.innerHTML = ` + +
ダイアログの内容
+
+ `; + + const dialog = getDialog(); + const content = getByShadowTestId(dialog, "content"); + expect(content.textContent).toBe("ダイアログの内容"); + }); + }); +});