From 8c14e544077105f8193e49cf0eb813c20fa4e92d Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 8 Oct 2026 12:58:34 +0900 Subject: [PATCH 1/3] feat(dds-solid): wrap interactive primitives with Ark UI --- .changeset/dds-internal-ark.md | 5 + .gitignore | 1 + brand/index.html | 1 + docs/ark-migration.ko.md | 42 + docs/ark-migration.md | 42 + docs/decisions.md | 8 + .../plans/2026-10-08-ark-migration.md | 23 + packages/dds-solid/README.ko.md | 13 +- packages/dds-solid/README.md | 13 +- packages/dds-solid/ark-preview/index.html | 2 + packages/dds-solid/ark-preview/main.tsx | 27 + packages/dds-solid/package.json | 3 +- .../dds-solid/src/__tests__/a11y.test.tsx | 3 +- .../src/__tests__/primitives.test.tsx | 150 ++- packages/dds-solid/src/__tests__/setup.ts | 18 + packages/dds-solid/src/__tests__/ssr.test.tsx | 3 +- packages/dds-solid/src/controls.tsx | 72 +- packages/dds-solid/src/dialog.tsx | 112 +- packages/dds-solid/src/direction.ts | 18 + packages/dds-solid/src/index.ts | 1 + packages/dds-solid/src/radio-group.tsx | 39 + packages/dds-solid/src/tabs.tsx | 136 +-- packages/dds-solid/src/toast.tsx | 99 +- packages/dds-solid/src/tooltip.tsx | 42 +- packages/dds-solid/styles.css | 16 + packages/dds-solid/vite.config.ts | 2 +- packages/dds-solid/vite.preview.config.ts | 7 + packages/dds-solid/vite.server.config.ts | 2 +- packages/dds-solid/vitest.config.ts | 4 +- packages/dds-solid/vitest.ssr.config.ts | 1 + pnpm-lock.yaml | 969 ++++++++++++++++++ preview/index.html | 1 + scripts/fixtures/solid-ark-consumer.mjs | 27 + scripts/verify-ark-browser.cjs | 49 + scripts/verify-solid-release.mjs | 60 +- tests/dds-solid-contracts.test.mjs | 22 +- 36 files changed, 1719 insertions(+), 314 deletions(-) create mode 100644 .changeset/dds-internal-ark.md create mode 100644 docs/ark-migration.ko.md create mode 100644 docs/ark-migration.md create mode 100644 docs/superpowers/plans/2026-10-08-ark-migration.md create mode 100644 packages/dds-solid/ark-preview/index.html create mode 100644 packages/dds-solid/ark-preview/main.tsx create mode 100644 packages/dds-solid/src/__tests__/setup.ts create mode 100644 packages/dds-solid/src/direction.ts create mode 100644 packages/dds-solid/src/radio-group.tsx create mode 100644 packages/dds-solid/vite.preview.config.ts create mode 100644 scripts/fixtures/solid-ark-consumer.mjs create mode 100644 scripts/verify-ark-browser.cjs diff --git a/.changeset/dds-internal-ark.md b/.changeset/dds-internal-ark.md new file mode 100644 index 0000000..b9eac99 --- /dev/null +++ b/.changeset/dds-internal-ark.md @@ -0,0 +1,5 @@ +--- +"@devslab/dds-solid": minor +--- + +Use Ark UI internally for accessible interactions while preserving DDS public APIs and visual tokens. Add a simple RadioGroup, retained tab panels with reactive disabled navigation, toast interaction pausing, and native form reset compatibility. Acknowledge Ark UI on DDS pages. diff --git a/.gitignore b/.gitignore index e6f65bd..de58041 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ packages/*/dist/ storybook-static/ test-results/ playwright-report/ +.playwright-cli/ .wrangler/ dist-worker/ *storybook.log diff --git a/brand/index.html b/brand/index.html index 8a9138c..d1b5595 100644 --- a/brand/index.html +++ b/brand/index.html @@ -495,6 +495,7 @@

버전이 고정된 원본 자산

diff --git a/docs/ark-migration.ko.md b/docs/ark-migration.ko.md new file mode 100644 index 0000000..21c487d --- /dev/null +++ b/docs/ark-migration.ko.md @@ -0,0 +1,42 @@ +# DDS의 Ark UI 내부 연결 + +DDS는 `@devslab/dds-solid`라는 기존 패키지에서 공개 API와 디자인 토큰을 유지한다. +Ark UI는 내부 접근성·상호작용 엔진이다. 별도 래퍼 패키지나 새로운 디자인 시스템은 만들지 않는다. + +| DDS API | 내부 구현 | 소비자 계약 | +| --- | --- | --- | +| Tabs / TabList / Tab / TabPanel | Ark Tabs | 문자열 콜백, 자동 키보드 선택, 비활성 패널 인스턴스 유지 | +| Dialog | Ark Dialog | boolean 콜백, 포커스 트랩·복귀, 외부 클릭·Escape 정책 | +| Tooltip | Ark Tooltip | 기존 render prop 사용; 전달한 속성을 트리거에 모두 spread | +| Checkbox / Switch | Ark의 headless hooks + DDS 네이티브 input | boolean 콜백, name/value/form/required, DDS CSS 유지 | +| Field | Ark Field + DDS control props | 기존 함수형 children, label/help/error 연결 | +| ToastProvider / useToast | Ark toaster | show/dismiss/clear, DDS tone·message, 현지화된 닫기 이름 | +| RadioGroup | Ark RadioGroup | options 배열, 문자열 선택, 폼 제출과 그룹 방향키 탐색 | +| Radio / Button / IconButton / Select | 기존 네이티브 요소 | 기존 API 유지 | + +## 소비 프로젝트 적용 + +애플리케이션 셸에서 `@devslab/dds-solid/styles.css`를 한 번 import한다. +Ark 패키지는 DDS의 런타임 의존성이므로 소비자가 별도로 Ark API를 사용하거나 설치 설정을 추가할 필요는 없다. +상태형 콜백은 Ark의 details 객체를 노출하지 않고 DDS의 boolean/string 값을 전달한다. +router, 권한, 서버 요청과 도메인 상태는 소비 프로젝트가 담당한다. + +단독 `Radio`의 checked API를 유지하기 위해 개별 라디오를 억지로 하나짜리 Ark 그룹으로 감싸지 않는다. +그룹 키보드 동작이 필요한 신규 코드에서는 `RadioGroup`을 사용한다. +`TooltipTriggerProps`는 실제 DOM 트리거 속성을 표현한다. `children`의 인자를 모두 spread하고, +기존 무인자 trigger 콜백도 유지한다. 프로그램에서 상태를 제어할 때는 `open`/`onOpenChange`를 권장한다. + +ToastProvider는 보통 앱 최상위에 두며, 여러 provider의 큐와 DOM ID도 각각 분리한다. `regionLabel`과 `dismissLabel`은 소비 로케일에 맞게 주입한다. +duration이 0 이하이면 알림을 유지하고, 포인터·포커스 상호작용 및 비활성 페이지에서는 타이머를 멈춘다. +Tabs의 hidden 패널은 인스턴스를 유지하지만 실행 중인 effect나 서버 요청을 자동 중단하지 않는다. + +## 검증 + +`pnpm --filter @devslab/dds-solid test`, `check`, `build`와 `node --test tests/dds-solid-contracts.test.mjs`를 실행한다. +실제 브라우저 픽스처는 패키지 디렉터리에서 `pnpm exec vite --config vite.preview.config.ts`로 실행한다. +Playwright CLI 세션을 해당 페이지에 연결하고 `run-code --filename scripts/verify-ark-browser.cjs`로 +탭 상태 유지·방향키·폼 제출·Dialog·Tooltip·Toast를 확인한다. 데스크톱과 모바일 크기를 각각 확인한다. +jsdom에는 ResizeObserver와 레이아웃이 없으므로 컴포넌트 테스트는 해당 환경의 한계를 명시하고 브라우저 검증을 병행한다. + +홈페이지 크레딧은 `Built with Ark UI · Designed by DevsLab`로 실제 의존성을 표시한다. +이 변경은 소스 변경이며 운영 사이트 배포나 npm 발행을 의미하지 않는다. diff --git a/docs/ark-migration.md b/docs/ark-migration.md new file mode 100644 index 0000000..0af5f81 --- /dev/null +++ b/docs/ark-migration.md @@ -0,0 +1,42 @@ +# DDS internal Ark UI integration + +DDS retains its public API and design tokens in the existing `@devslab/dds-solid` package. +Ark UI is the internal accessibility and interaction engine. No separate wrapper package or new design system is created. + +| DDS API | Implementation | Consumer contract | +| --- | --- | --- | +| Tabs / TabList / Tab / TabPanel | Ark Tabs | String callbacks, automatic keyboard selection, retained inactive panel instances | +| Dialog | Ark Dialog | Boolean callbacks, focus trap/return, outside-click and Escape policies | +| Tooltip | Ark Tooltip | Existing render prop; spread all supplied attributes onto the trigger | +| Checkbox / Switch | Ark headless hooks + DDS native input | Boolean callbacks, name/value/form/required, DDS CSS | +| Field | Ark Field + DDS control props | Existing function children and label/help/error associations | +| ToastProvider / useToast | Ark toaster | show/dismiss/clear, DDS tone/message, localized dismiss names | +| RadioGroup | Ark RadioGroup | Options array, string selection, native forms and grouped arrow navigation | +| Radio / Button / IconButton / Select | Existing native elements | Existing API | + +## Consumer integration + +Import `@devslab/dds-solid/styles.css` once in the application shell. +Ark is a DDS runtime dependency; consumers do not need to use Ark APIs or add separate installation configuration. +State callbacks return DDS boolean/string values rather than Ark details objects. +Routing, authorization, server requests and domain state remain consumer responsibilities. + +Standalone `Radio` keeps its checked API instead of becoming a one-item Ark group. +Use `RadioGroup` for new code requiring grouped keyboard navigation. +`TooltipTriggerProps` describes actual DOM trigger attributes. Spread the entire render-prop argument; +legacy zero-argument trigger callbacks remain supported. Prefer `open`/`onOpenChange` for programmatic state changes. + +Normally use an application-level ToastProvider; multiple providers keep independent queues and DOM IDs. Inject localized `regionLabel` and `dismissLabel` values. +Non-positive durations persist until dismissal; pointer/focus interaction and inactive pages pause expiry. +Hidden Tabs panels preserve instances but do not automatically suspend effects or server requests. + +## Verification + +Run `pnpm --filter @devslab/dds-solid test`, `check`, `build`, and `node --test tests/dds-solid-contracts.test.mjs`. +Start the browser fixture from the package directory with `pnpm exec vite --config vite.preview.config.ts`. +Connect a Playwright CLI session to the page and run `run-code --filename scripts/verify-ark-browser.cjs` +to verify retained tabs, arrows, native forms, Dialog, Tooltip and Toast at desktop and mobile sizes. +jsdom lacks ResizeObserver and layout; component tests document these limits and are supplemented by real-browser checks. + +Homepage credits use `Built with Ark UI · Designed by DevsLab` to acknowledge the actual dependency. +These are source changes; they do not imply production deployment or npm publication. diff --git a/docs/decisions.md b/docs/decisions.md index b57b418..b33e826 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,6 +5,14 @@ --- +## D-037 — DDS API 안에서 Ark UI 동작을 사용하고 의존성을 크레딧으로 명시 (2026-10-08) + +**결정.** DDS의 공개 컴포넌트 API와 시각 토큰은 유지하고, 복합 컴포넌트의 접근성·키보드·포커스 동작은 내부에서 Ark UI로 연결한다. 소비자는 DDS를 계속 import하고 Ark의 details 객체 대신 기존 DDS 콜백 값을 받는다. 네이티브 버튼과 select의 계약은 유지한다. 별도 Ark 래퍼 패키지는 만들지 않는다. + +**크레딧.** 사용자의 명시적 요청에 따라 DDS 페이지에 `Built with Ark UI · Designed by DevsLab`와 공식 링크를 추가한다. Ark UI는 headless 동작 라이브러리이므로 `inspired by Ark Design`보다 실제 의존성과 디자인 소유권을 정확히 설명한다. D-016의 외부 참고 크레딧 제한은 실제 사용 의존성의 크레딧에 한해 이 결정으로 대체한다. + +**검증 기준.** controlled/uncontrolled 상태, 동적 disabled, 탭 패널 인스턴스 유지, 네이티브 폼 제출, Dialog 포커스 복원과 외부 상호작용, 타이머 정리, SSR과 접근성을 확인한 후 소비 프로젝트에 적용한다. 이 결정은 npm 발행이나 운영 사이트 배포 승인을 포함하지 않는다. + ## D-036 — 헤더 가운데 정렬은 들어갈 때만: 양옆 칸은 내용 아래로 줄지 않는다 (2026-10-06) **결정.** `.site-header__inner`의 열 템플릿을 `minmax(0, 1fr) auto minmax(0, 1fr)`에서 `minmax(max-content, 1fr) auto minmax(max-content, 1fr)`로 바꾼다. 양옆(브랜드·액션)이 다 들어가면 두 fr 칸이 똑같이 나뉘어 내비는 전처럼 정확히 가운데다. 한쪽이 남은 폭의 절반보다 크면 그 칸은 자기 내용 폭을 지키고, 내비가 모자란 만큼만 반대쪽으로 비켜난다. 그래도 모자라면 내비가 줄을 바꾼다. 양보 순서는 **가운데 → 내비 한 줄**이고, 브랜드와 액션은 줄지도 덮이지도 않는다. diff --git a/docs/superpowers/plans/2026-10-08-ark-migration.md b/docs/superpowers/plans/2026-10-08-ark-migration.md new file mode 100644 index 0000000..b40ce44 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-ark-migration.md @@ -0,0 +1,23 @@ +# DDS Ark migration + +The approved design keeps DDS public imports, simple props, semantic CSS tokens and licensing. Ark UI owns accessible interaction inside DDS wrappers. Native buttons and native selects retain native semantics. No second design-system package is created. + +## Sequence + +- [x] Establish browser and server test baselines; distinguish infrastructure errors from regressions. +- [x] Delegate tabs, dialogs, tooltips, checkable controls and toast behavior to Ark, preserving controlled/uncontrolled state, native form behavior and retained panels. +- [x] Verify keyboard navigation, focus restoration, reactive disabled state, form submission, timer disposal, SSR and accessibility. Update implementation-specific source assertions to delegation contracts supported by runtime tests. +- [x] Add linked Ark UI credits to DDS pages, bilingual migration guidance and a decision superseding the old reference-credit restriction for actual dependencies. +- [ ] Build and verify the package and review the complete diff before integration. +- [ ] Finish framework-neutral Workspace OSS and its adapters, then migrate consumers after DDS validation. FM remains read-only until its concurrent work is complete. +- [ ] Clean temporary checkout and branch after integrated changes are preserved. + +## Review risks + +The read-only reviewer found no remaining critical or important issues after compatibility fixes. Packed-package SSR and interactive browser-entry hydration pass in a fresh consumer. Browser interaction checks pass at 390×844 and 1280×720. Integration and temporary checkout cleanup remain pending. + +Portal placement must not break CSS positioning or accessibility coverage. Reactive props must not become snapshots. Hidden panels must preserve instances. Radio grouping must remain native-form-compatible. Ark details objects must not escape DDS callbacks. Browser-only effects must not run during SSR. Package export conditions and bundled dependencies must work in consumers. + +## Constraints + +Work in the isolated `dds-ark` checkout on `codex/ark-wrappers`; preserve existing local DDS changes. Do not publish packages, deploy, or change FM business integrations. Homepage credits describe the actual Ark UI dependency, not authorship of the DevsLab visual design. diff --git a/packages/dds-solid/README.ko.md b/packages/dds-solid/README.ko.md index 406bb16..890089e 100644 --- a/packages/dds-solid/README.ko.md +++ b/packages/dds-solid/README.ko.md @@ -1,6 +1,6 @@ # @devslab/dds-solid -DDS의 공개 SolidJS 2 primitive 패키지다. 애플리케이션 셸에서 +DDS의 공개 SolidJS primitive 패키지다. 애플리케이션 셸에서 `@devslab/dds-solid/styles.css`를 한 번 불러오고, 컴포넌트는 `@devslab/dds-solid`에서 사용한다. @@ -14,3 +14,14 @@ Tooltip은 실제 trigger에 `aria-describedby`를 강제하기 위해 render pr 수동 닫기 컨트롤이 필요한 ToastProvider에는 로케일별 `dismissLabel`을 주입한다. 생략하면 기존 provider API는 유지하면서 닫기 컨트롤만 렌더하지 않으므로 패키지가 영어 접근성 이름을 임의로 출력하지 않는다. + +상호작용 primitive는 내부에서 [Ark UI](https://ark-ui.com/)를 사용하고, +DDS가 공개 API와 시각 토큰을 소유한다. 기존 import와 콜백 값은 유지한다. +연결 상세는 [마이그레이션 가이드](../../docs/ark-migration.ko.md)를 참고한다. + +`RadioGroup`은 `options`(`value`, `label`, 선택적 `disabled`)로 이름 있는 +그룹을 만들며 `name`, `value`/`defaultValue`, `onValueChange`를 지원한다. +기존 단독 `Radio`는 네이티브 동작을 유지한다. `ToastProvider`는 앱 최상위에 +하나를 두고, 알림 영역 이름이 필요하면 현지화한 `regionLabel`을 주입한다. +포인터·포커스 상호작용 중과 브라우저 페이지가 비활성일 때는 알림 타이머가 +멈춘다. duration이 0 이하이면 명시적으로 닫을 때까지 유지한다. diff --git a/packages/dds-solid/README.md b/packages/dds-solid/README.md index 84f20e2..095e46e 100644 --- a/packages/dds-solid/README.md +++ b/packages/dds-solid/README.md @@ -1,6 +1,6 @@ # @devslab/dds-solid -Public SolidJS 2 primitives for DDS. Import `@devslab/dds-solid/styles.css` +Public SolidJS primitives for DDS. Import `@devslab/dds-solid/styles.css` once in the application shell, then consume the native-semantic components from `@devslab/dds-solid`. @@ -13,3 +13,14 @@ prop so `aria-describedby` is always attached to the actual trigger. Pass a localized `dismissLabel` to `ToastProvider` when toasts need a manual dismiss control. Omitting it preserves the existing provider API while leaving out the control, so the package never emits an English accessible-name fallback. + +Interactive primitives use [Ark UI](https://ark-ui.com/) internally while DDS owns +the public API and visual tokens. Existing imports and callback values stay the +same. See [the migration guide](../../docs/ark-migration.md) for integration details. + +`RadioGroup` adds a labelled group from `options` (`value`, `label`, optional +`disabled`), with `name`, `value`/`defaultValue` and `onValueChange`. Existing standalone +`Radio` remains native. Use one application-level `ToastProvider`; inject a localized +`regionLabel` if the notification region needs a name. Toast timers pause during +pointer/focus interaction and when the browser page is idle. A non-positive duration +keeps the toast until dismissed. diff --git a/packages/dds-solid/ark-preview/index.html b/packages/dds-solid/ark-preview/index.html new file mode 100644 index 0000000..bcc84c6 --- /dev/null +++ b/packages/dds-solid/ark-preview/index.html @@ -0,0 +1,2 @@ + +DDS Ark verification
diff --git a/packages/dds-solid/ark-preview/main.tsx b/packages/dds-solid/ark-preview/main.tsx new file mode 100644 index 0000000..1aad1d5 --- /dev/null +++ b/packages/dds-solid/ark-preview/main.tsx @@ -0,0 +1,27 @@ +import { createSignal } from "solid-js"; +import { render } from "solid-js/web"; +import { Button, Checkbox, Dialog, Field, RadioGroup, Select, Switch, Tab, TabList, TabPanel, Tabs, ToastProvider, Tooltip, useToast } from "../src"; +import "../styles.css"; + +function SecondaryActions() { + const toast = useToast(); + return ; +} + +function Content() { + const [open, setOpen] = createSignal(false); + const [disabled, setDisabled] = createSignal(false); + const toast = useToast(); + return <> +

DDS Ark verification

+ First tabSecond tabThird tabSecond panelThird panel + +
{(control) => } + {(trigger) => } + + + + + ; +} +render(() => , document.getElementById("app")!); diff --git a/packages/dds-solid/package.json b/packages/dds-solid/package.json index fc9375e..cce6d2f 100644 --- a/packages/dds-solid/package.json +++ b/packages/dds-solid/package.json @@ -43,6 +43,7 @@ "test:a11y": "vitest run --config vitest.config.ts src/__tests__/a11y.test.tsx" }, "dependencies": { + "@ark-ui/solid": "5.39.3", "@devslab/dds-css": "workspace:0.17.1", "@devslab/dds-icons": "workspace:0.17.1", "@devslab/dds-tokens": "workspace:0.17.1" @@ -51,11 +52,11 @@ "solid-js": "1.9.15" }, "devDependencies": { + "@testing-library/jest-dom": "6.9.1", "@types/node": "26.3.0", "axe-core": "4.13.0", "jsdom": "30.0.1", "solid-js": "1.9.15", - "@testing-library/jest-dom": "6.9.1", "typescript": "7.0.2", "vite": "8.2.1", "vite-plugin-solid": "2.11.14", diff --git a/packages/dds-solid/src/__tests__/a11y.test.tsx b/packages/dds-solid/src/__tests__/a11y.test.tsx index c0442cb..12fefd5 100644 --- a/packages/dds-solid/src/__tests__/a11y.test.tsx +++ b/packages/dds-solid/src/__tests__/a11y.test.tsx @@ -3,7 +3,7 @@ import { render } from "solid-js/web"; import { afterEach, expect, it } from "vitest"; import { - Button, Checkbox, Dialog, Field, Icon, IconButton, Radio, Select, Switch, + Button, Checkbox, Dialog, Field, Icon, IconButton, Radio, RadioGroup, Select, Switch, Tab, TabList, TabPanel, Tabs, ToastProvider, Tooltip, useToast, } from "../index"; @@ -20,6 +20,7 @@ it("common form primitives have no detectable axe violations", async () => { {(control) => } + OneTwoFirstSecond diff --git a/packages/dds-solid/src/__tests__/primitives.test.tsx b/packages/dds-solid/src/__tests__/primitives.test.tsx index d891c0d..5b04bd7 100644 --- a/packages/dds-solid/src/__tests__/primitives.test.tsx +++ b/packages/dds-solid/src/__tests__/primitives.test.tsx @@ -3,7 +3,7 @@ import { createSignal } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - Button, Checkbox, Dialog, Field, Icon, IconButton, Radio, Select, Switch, + Button, Checkbox, Dialog, Field, Icon, IconButton, Radio, RadioGroup, Select, Switch, Tab, TabList, TabPanel, Tabs, ToastProvider, Tooltip, useToast, } from "../index"; import { createStatusPill } from "../status-pill"; @@ -13,6 +13,68 @@ let dispose: (() => void) | undefined; afterEach(() => { vi.useRealTimers(); dispose?.(); dispose = undefined; document.body.replaceChildren(); }); describe("native control behavior", () => { + it("preserves checkable consumer ARIA, focus handlers and click bubbling", async () => { + const host = document.body.appendChild(document.createElement("div")); + const clicked = vi.fn(); + const focused = vi.fn(); + dispose = render(() =>
Custom
, host); + const input = host.querySelector("input")!; + input.focus(); input.click(); + await Promise.resolve(); + expect(input.getAttribute("aria-labelledby")).toBe("custom-label"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(focused).toHaveBeenCalledOnce(); + expect(clicked).toHaveBeenCalledOnce(); + }); + + it("keeps multiple toast providers independent with unique region IDs", async () => { + const host = document.body.appendChild(document.createElement("div")); + let one!: ReturnType; + let two!: ReturnType; + const One = () => { one = useToast(); return null; }; + const Two = () => { two = useToast(); return null; }; + dispose = render(() => <>, host); + one.show({ message: "First" }); two.show({ message: "Second" }); + await Promise.resolve(); + const regions = [...host.querySelectorAll(".dds-toast-region")]; + expect(new Set(regions.map((region) => region.id)).size).toBe(2); + one.clear(); + await Promise.resolve(); + expect(regions[0]?.textContent).toBe(""); + expect(regions[1]?.textContent).toContain("Second"); + expect(regions[0]?.getAttribute("aria-label")).toBe(""); + }); + + it("preserves the legacy zero-argument tooltip trigger callbacks", async () => { + const host = document.body.appendChild(document.createElement("div")); + let focus!: () => void; + let blur!: () => void; + dispose = render(() => {(trigger) => { + focus = trigger.onFocus; blur = trigger.onBlur; + return ; + }}, host); + focus(); + await vi.waitFor(() => expect(host.querySelector('[role="tooltip"]:not([hidden])')?.textContent).toBe("Legacy")); + blur(); + await vi.waitFor(() => expect(host.querySelector('[role="tooltip"]:not([hidden])')).toBeNull()); + }); + it("submits grouped radios and resets native checkable values", async () => { + const host = document.body.appendChild(document.createElement("div")); + dispose = render(() =>
+ + + + , host); + const form = host.querySelector("form")!; + const checkboxes = [...form.querySelectorAll('input[type="checkbox"]')]; + checkboxes.forEach((input) => input.click()); + form.querySelector('input[value="b"]')!.click(); + await vi.waitFor(() => expect(new FormData(form).get("choice")).toBe("b")); + expect(new FormData(form).has("agree")).toBe(false); + form.reset(); + await vi.waitFor(() => expect(checkboxes.every((input) => input.checked)).toBe(true)); + await vi.waitFor(() => expect(new FormData(form).get("choice")).toBe("a")); + }); it("renders loading Button with native disabled semantics", () => { const host = document.body.appendChild(document.createElement("div")); dispose = render(() => , host); @@ -83,9 +145,11 @@ describe("native control behavior", () => { ; }, host); const buttons = [...host.querySelectorAll("button")]; + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); buttons[0]?.focus(); buttons[1]?.focus(); - await Promise.resolve(); + await vi.waitFor(() => expect(controlled()).toBe(true)); + await vi.waitFor(() => expect(host.querySelectorAll('[role="tooltip"]:not([hidden])')).toHaveLength(1)); const visibleTooltips = host.querySelectorAll('[role="tooltip"]:not([hidden])'); expect(visibleTooltips).toHaveLength(1); expect(visibleTooltips[0]?.textContent).toBe("Controlled help"); @@ -93,20 +157,75 @@ describe("native control behavior", () => { }); it("ToastProvider uses a localized dismiss label and clears timer lifecycle", async () => { - vi.useFakeTimers(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date", "performance"] }); let api!: ReturnType; const Capture = () => { api = useToast(); return null; }; const host = document.body.appendChild(document.createElement("div")); dispose = render(() => , host); api.show({ message: "Guardado" }); + await vi.advanceTimersByTimeAsync(0); expect(host.querySelector("button")?.getAttribute("aria-label")).toBe("Cerrar notificación"); - vi.advanceTimersByTime(1000); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1100); + expect(host.querySelector('[role="status"]')).toBeNull(); + }); + + it("pauses toast expiry while the user is reading it", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date", "performance"] }); + let api!: ReturnType; + const Capture = () => { api = useToast(); return null; }; + const host = document.body.appendChild(document.createElement("div")); + dispose = render(() => , host); + api.show({ message: "Read this" }); + await vi.advanceTimersByTimeAsync(100); + host.querySelector(".dds-toast-region")!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + await vi.advanceTimersByTimeAsync(2000); + expect(host.querySelector('[role="status"]')?.textContent).toContain("Read this"); + host.querySelector(".dds-toast-region")!.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + await vi.advanceTimersByTimeAsync(1000); expect(host.querySelector('[role="status"]')).toBeNull(); }); }); describe("keyboard lifecycle", () => { + it("inherits RTL tab direction without requiring an Ark provider", async () => { + const host = document.body.appendChild(document.createElement("div")); + host.dir = "rtl"; + dispose = render(() => OneTwoThreeFirstSecondThird, host); + const tabs = [...host.querySelectorAll('[role="tab"]')]; + tabs[0]!.focus(); + tabs[0]!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + await vi.waitFor(() => expect(tabs[1]!.getAttribute("aria-selected")).toBe("true")); + }); + it("does not let ToastProvider steal focus from application keys", async () => { + const host = document.body.appendChild(document.createElement("div")); + dispose = render(() => , host); + const input = host.querySelector("input")!; + input.focus(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "a", code: "KeyA", bubbles: true })); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + expect(document.activeElement).toBe(input); + }); + it("skips tabs disabled after mount and preserves inactive panel instances", async () => { + const host = document.body.appendChild(document.createElement("div")); + let disable!: () => void; + let mounts = 0; + const Page = () => { mounts++; return ; }; + dispose = render(() => { + const [disabled, setDisabled] = createSignal(false); + disable = () => setDisabled(true); + return OneTwoThreeThird; + }, host); + const tabs = [...host.querySelectorAll('[role="tab"]')]; + const input = host.querySelector("input"); + disable(); + tabs[0]!.focus(); + tabs[0]!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + await vi.waitFor(() => expect(document.activeElement).toBe(tabs[2])); + await vi.waitFor(() => expect(tabs[2]!.getAttribute("aria-selected")).toBe("true")); + tabs[0]!.click(); + expect(host.querySelector("input")).toBe(input); + expect(mounts).toBe(1); + }); it("closes Dialog on Escape and returns focus", async () => { const host = document.body.appendChild(document.createElement("div")); const opener = document.body.appendChild(document.createElement("button")); @@ -125,20 +244,22 @@ describe("keyboard lifecycle", () => { }); it("Dialog completes the focus trap cycle and returns focus during lifecycle cleanup", async () => { + // jsdom has no layout. Supply visible rectangles for the trap's tabbable scan. + const rectangles = vi.spyOn(HTMLElement.prototype, "getClientRects").mockImplementation(() => [new DOMRect(0, 0, 10, 10)] as unknown as DOMRectList); const host = document.body.appendChild(document.createElement("div")); const opener = document.body.appendChild(document.createElement("button")); opener.focus(); dispose = render(() => , host); - await Promise.resolve(); const buttons = [...host.querySelectorAll("button")]; + await vi.waitFor(() => expect(document.activeElement).toBe(buttons[0])); buttons[1]?.focus(); - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + buttons[1]!.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); expect(document.activeElement).toBe(buttons[0]); - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })); + buttons[0]!.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })); expect(document.activeElement).toBe(buttons[1]); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - await Promise.resolve(); - expect(document.activeElement).toBe(opener); + await vi.waitFor(() => expect(document.activeElement).toBe(opener)); + rectangles.mockRestore(); }); it("moves Tabs with arrow keys and exposes linked panels", async () => { @@ -147,13 +268,12 @@ describe("keyboard lifecycle", () => { const tabs = [...host.querySelectorAll('[role="tab"]')]; tabs[0]!.focus(); tabs[0]!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); - await Promise.resolve(); - expect(tabs[1]!.getAttribute("aria-selected")).toBe("true"); - expect(host.querySelector('[role="tabpanel"]:not([hidden])')?.textContent).toBe("Second"); + await vi.waitFor(() => expect(tabs[1]!.getAttribute("aria-selected")).toBe("true")); + await vi.waitFor(() => expect(host.querySelector('[role="tabpanel"]:not([hidden])')?.textContent).toBe("Second")); tabs[1]!.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); - expect(tabs[0]).toBe(document.activeElement); + await vi.waitFor(() => expect(tabs[0]).toBe(document.activeElement)); tabs[0]!.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true })); - expect(tabs[1]).toBe(document.activeElement); + await vi.waitFor(() => expect(tabs[1]).toBe(document.activeElement)); }); }); diff --git a/packages/dds-solid/src/__tests__/setup.ts b/packages/dds-solid/src/__tests__/setup.ts new file mode 100644 index 0000000..b0a07a6 --- /dev/null +++ b/packages/dds-solid/src/__tests__/setup.ts @@ -0,0 +1,18 @@ +import { vi } from "vitest"; + +// Geometry-dependent positioning is verified in a real browser. jsdom lacks +// ResizeObserver; this stub only lets component lifecycle tests mount poppers. +vi.stubGlobal("ResizeObserver", class { + observe() {} + unobserve() {} + disconnect() {} +}); + +// Browsers run a microtask checkpoint after EACH animation-frame callback. +// jsdom batches the callbacks in one synchronous loop, delaying Zag's queued +// focus event until after its selection callback. Separate tasks preserve the +// browser ordering instead of altering production keyboard behavior. +window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(performance.now()), 16); +window.cancelAnimationFrame = (id) => window.clearTimeout(id); +vi.stubGlobal("requestAnimationFrame", window.requestAnimationFrame); +vi.stubGlobal("cancelAnimationFrame", window.cancelAnimationFrame); diff --git a/packages/dds-solid/src/__tests__/ssr.test.tsx b/packages/dds-solid/src/__tests__/ssr.test.tsx index 5a2ce04..41b48c2 100644 --- a/packages/dds-solid/src/__tests__/ssr.test.tsx +++ b/packages/dds-solid/src/__tests__/ssr.test.tsx @@ -2,7 +2,7 @@ import { renderToString } from "solid-js/web"; import { expect, it } from "vitest"; import { - Button, Checkbox, ConsoleShell, Dialog, Field, Icon, IconButton, Radio, Select, Switch, + Button, Checkbox, ConsoleShell, Dialog, Field, Icon, IconButton, Radio, RadioGroup, Select, Switch, Tab, TabList, TabPanel, Tabs, ToastProvider, Tooltip, } from "../index"; @@ -12,6 +12,7 @@ const AllPrimitives = () =>
{(control) => } + OnePanel diff --git a/packages/dds-solid/src/controls.tsx b/packages/dds-solid/src/controls.tsx index d5c3231..2467511 100644 --- a/packages/dds-solid/src/controls.tsx +++ b/packages/dds-solid/src/controls.tsx @@ -6,6 +6,9 @@ import { } from "solid-js"; import { createControllableSignal } from "./controllable"; +import { useCheckbox } from "@ark-ui/solid/checkbox"; +import { useSwitch } from "@ark-ui/solid/switch"; +import { Field as ArkField } from "@ark-ui/solid/field"; import { classes, describedBy } from "./utils"; export type ButtonTone = "primary" | "secondary" | "ghost" | "danger"; @@ -84,12 +87,12 @@ export function Field(props: FieldProps) { "aria-required": props.required ? "true" : undefined, }); return ( -
- + + {props.label}{props.required && } {typeof props.children === "function" ? props.children(control()) : props.children} - {props.helpText &&
{props.helpText}
} - {props.error && } -
+ {props.helpText && {props.helpText}} + {props.error && {props.error}} + ); } @@ -141,10 +144,65 @@ function Checkable(props: CheckableProps & { type: "checkbox" | "radio"; role?: } export type CheckboxProps = CheckableProps; -export const Checkbox = (props: CheckboxProps) => ; +export const Checkbox = (props: CheckboxProps) => ; export type RadioProps = CheckableProps; export const Radio = (props: RadioProps) => ; export type SwitchProps = CheckableProps; -export const Switch = (props: SwitchProps) => ; +export const Switch = (props: SwitchProps) => ; + +function ArkCheckable(props: CheckableProps & { kind: "checkbox" | "switch" }) { + const generated = createUniqueId(); + const [local, rest] = splitProps(props, ["checked", "defaultChecked", "onCheckedChange", "label", "class", "inputClass", "kind", "id", "onClick", "onFocusIn", "onFocusOut", "style"]); + const options = () => ({ + id: `dds-check-${generated}`, + ids: { hiddenInput: local.id ?? `dds-check-${generated}-input` }, + checked: local.checked, defaultChecked: local.defaultChecked, + onCheckedChange: (details: { checked: boolean | "indeterminate" }) => local.onCheckedChange?.(details.checked === true), + disabled: rest.disabled, required: rest.required, readOnly: rest.readonly, + name: rest.name, form: rest.form, value: String(rest.value ?? "on"), + }); + const api = local.kind === "switch" ? useSwitch(options) : useCheckbox(options); + const inputClass = () => local.kind === "switch" ? "dds-switch__input" : "dds-check__input"; + const rootProps = () => { + const [, attributes] = splitProps(api().getRootProps(), ["onClick"]); + return attributes; + }; + return ; +} + +function callInputHandler(handler: JSX.FocusEventHandlerUnion | undefined, event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) { + if (typeof handler === "function") handler(event); + else if (handler) handler[0](handler[1], event); +} diff --git a/packages/dds-solid/src/dialog.tsx b/packages/dds-solid/src/dialog.tsx index 39291d1..429f059 100644 --- a/packages/dds-solid/src/dialog.tsx +++ b/packages/dds-solid/src/dialog.tsx @@ -1,21 +1,10 @@ -import { - Show, - createEffect, - createUniqueId, - onCleanup, - type JSX, -} from "solid-js"; - -import { createControllableSignal } from "./controllable"; -import { classes, describedBy } from "./utils"; +import { Dialog as ArkDialog } from "@ark-ui/solid/dialog"; +import { createUniqueId, type JSX } from "solid-js"; +import { classes } from "./utils"; export const focusable = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - "[tabindex]:not([tabindex='-1'])", + "a[href]", "button:not([disabled])", "input:not([disabled])", + "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", ].join(","); export interface DialogProps { @@ -35,78 +24,21 @@ export interface DialogProps { export function Dialog(props: DialogProps) { const generated = createUniqueId(); const id = () => props.id ?? `dds-dialog-${generated}`; - const titleId = () => `${id()}-title`; - const descriptionId = () => props.description ? `${id()}-description` : undefined; - const [open, setOpen] = createControllableSignal({ - value: () => props.open, - defaultValue: props.defaultOpen ?? false, - onChange: props.onOpenChange, - }); - let panel: HTMLDivElement | undefined; - let previouslyFocused: HTMLElement | null = null; - - createEffect(() => { - if (!open()) return; - previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; - queueMicrotask(() => { - const first = panel?.querySelector(focusable); - (first ?? panel)?.focus(); - }); - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && props.closeOnEscape !== false) { - event.preventDefault(); - setOpen(false); - return; - } - if (event.key !== "Tab" || !panel) return; - const candidates = [...panel.querySelectorAll(focusable)] - .filter((element) => !element.hidden && element.getAttribute("aria-hidden") !== "true"); - if (candidates.length === 0) { - event.preventDefault(); - panel.focus(); - return; - } - const first = candidates[0]!; - const last = candidates[candidates.length - 1]!; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }; - document.addEventListener("keydown", onKeyDown); - onCleanup(() => { - document.removeEventListener("keydown", onKeyDown); - queueMicrotask(() => previouslyFocused?.focus()); - }); - }); - - return ( - -
{ - if (event.target === event.currentTarget && props.closeOnOutside !== false) setOpen(false); - }} - > - -
-
- ); + return props.onOpenChange?.(details.open)} + closeOnEscape={props.closeOnEscape ?? true} + closeOnInteractOutside={props.closeOnOutside ?? true} + modal trapFocus preventScroll lazyMount unmountOnExit + ids={{ content: id(), title: `${id()}-title`, description: `${id()}-description` }} + > + + + {props.title} + {props.description && {props.description}} + {props.children} + {props.actions &&
{props.actions}
} +
+
+
; } diff --git a/packages/dds-solid/src/direction.ts b/packages/dds-solid/src/direction.ts new file mode 100644 index 0000000..b41944c --- /dev/null +++ b/packages/dds-solid/src/direction.ts @@ -0,0 +1,18 @@ +import { createSignal, onCleanup, onMount, type Accessor } from "solid-js"; + +export type Direction = "ltr" | "rtl"; + +/** SSR callers may supply direction; clients also inherit their containing DOM. */ +export function useDirection(element: () => HTMLElement | undefined, explicit: Accessor) { + const [ambient, setAmbient] = createSignal("ltr"); + onMount(() => { + const parent = element()?.parentElement; + if (!parent) return; + const update = () => setAmbient(getComputedStyle(parent).direction === "rtl" ? "rtl" : "ltr"); + update(); + const observer = new MutationObserver(update); + observer.observe(parent.ownerDocument.documentElement, { attributes: true, subtree: true, attributeFilter: ["dir"] }); + onCleanup(() => observer.disconnect()); + }); + return () => explicit() ?? ambient(); +} diff --git a/packages/dds-solid/src/index.ts b/packages/dds-solid/src/index.ts index d6eb420..5908cf6 100644 --- a/packages/dds-solid/src/index.ts +++ b/packages/dds-solid/src/index.ts @@ -20,6 +20,7 @@ export { } from "./controls"; export { ConsoleShell, type ConsoleHeader, type ConsoleNavGroup, type ConsoleNavItem, type ConsoleShellLabels, type ConsoleShellProps } from "./console-shell"; export { Dialog, type DialogProps } from "./dialog"; +export { RadioGroup, type RadioGroupProps, type RadioOption } from "./radio-group"; export { Icon, type IconProps } from "./icon"; export { createStatusPill } from "./status-pill"; export type { StatusTone } from "./status-pill"; diff --git a/packages/dds-solid/src/radio-group.tsx b/packages/dds-solid/src/radio-group.tsx new file mode 100644 index 0000000..5efcb07 --- /dev/null +++ b/packages/dds-solid/src/radio-group.tsx @@ -0,0 +1,39 @@ +import { RadioGroup as ArkRadioGroup } from "@ark-ui/solid/radio-group"; +import { For, type JSX } from "solid-js"; +import { classes } from "./utils"; + +export interface RadioOption { + value: string; + label: JSX.Element; + disabled?: boolean; +} +export interface RadioGroupProps { + options: readonly RadioOption[]; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + name?: string; + form?: string; + disabled?: boolean; + required?: boolean; + label: JSX.Element; + class?: string; +} + +/** Grouped radios delegate selection and roving keyboard focus to Ark. */ +export function RadioGroup(props: RadioGroupProps) { + const initialValue = props.value ?? props.defaultValue; + return { if (details.value !== null) props.onValueChange?.(details.value); }} + class={classes("dds-radio-group", props.class)} + > + {props.label} + {(option) => + { input.defaultChecked = initialValue === option.value; }} /> + {option.label} + } + ; +} diff --git a/packages/dds-solid/src/tabs.tsx b/packages/dds-solid/src/tabs.tsx index f402b0f..ae65dad 100644 --- a/packages/dds-solid/src/tabs.tsx +++ b/packages/dds-solid/src/tabs.tsx @@ -1,35 +1,8 @@ -import { - createContext, - createUniqueId, - onCleanup, - onMount, - splitProps, - useContext, - type Accessor, - type JSX, - type ParentProps, -} from "solid-js"; - -import { createControllableSignal } from "./controllable"; +import { Tabs as ArkTabs } from "@ark-ui/solid/tabs"; +import { LocaleProvider } from "@ark-ui/solid/locale"; +import { createUniqueId, splitProps, type JSX, type ParentProps } from "solid-js"; import { classes } from "./utils"; - -interface RegisteredTab { value: string; element: HTMLButtonElement; disabled: boolean } -interface TabsContextValue { - value: Accessor; - setValue: (value: string) => void; - orientation: "horizontal" | "vertical"; - register: (tab: RegisteredTab) => () => void; - tabs: () => RegisteredTab[]; - tabId: (value: string) => string; - panelId: (value: string) => string; -} - -const TabsContext = createContext(); -const useTabs = () => { - const context = useContext(TabsContext); - if (!context) throw new Error("Tab components must be rendered inside "); - return context; -}; +import { useDirection, type Direction } from "./direction"; export interface TabsProps { value?: string; @@ -38,61 +11,26 @@ export interface TabsProps { orientation?: "horizontal" | "vertical"; children: JSX.Element; id?: string; + dir?: Direction; } export function Tabs(props: TabsProps) { const generated = createUniqueId(); const id = () => props.id ?? `dds-tabs-${generated}`; - const [value, setValue] = createControllableSignal({ - value: () => props.value, - defaultValue: props.defaultValue, - onChange: props.onValueChange, - }); - const registered: RegisteredTab[] = []; - const context: TabsContextValue = { - value, - setValue, - orientation: props.orientation ?? "horizontal", - register(tab) { - registered.push(tab); - return () => { - const index = registered.indexOf(tab); - if (index >= 0) registered.splice(index, 1); - }; - }, - tabs: () => registered, - tabId: (tabValue) => `${id()}-tab-${tabValue}`, - panelId: (tabValue) => `${id()}-panel-${tabValue}`, - }; - return {props.children}; + let root: HTMLDivElement | undefined; + const direction = useDirection(() => root, () => props.dir); + return props.onValueChange?.(details.value)} + lazyMount={false} unmountOnExit={false} class="dds-tabs-root" + ids={{ trigger: (value) => `${id()}-tab-${value}`, content: (value) => `${id()}-panel-${value}` }} + >{props.children}; } export function TabList(props: ParentProps>) { - const context = useTabs(); - const [local, rest] = splitProps(props, ["class", "children", "onKeyDown"]); - const onKeyDown: JSX.EventHandler = (event) => { - if (typeof local.onKeyDown === "function") local.onKeyDown(event); - if (event.defaultPrevented) return; - const tabs = context.tabs().filter((tab) => !tab.disabled); - if (!tabs.length) return; - const current = tabs.findIndex((tab) => tab.element === document.activeElement); - let next = current; - const rtl = document.documentElement.dir === "rtl"; - if (event.key === "Home") next = 0; - else if (event.key === "End") next = tabs.length - 1; - else if (event.key === "ArrowRight" && context.orientation === "horizontal") next = (current + (rtl ? -1 : 1) + tabs.length) % tabs.length; - else if (event.key === "ArrowLeft" && context.orientation === "horizontal") next = (current + (rtl ? 1 : -1) + tabs.length) % tabs.length; - else if (event.key === "ArrowDown" && context.orientation === "vertical") next = (current + 1) % tabs.length; - else if (event.key === "ArrowUp" && context.orientation === "vertical") next = (current - 1 + tabs.length) % tabs.length; - else return; - event.preventDefault(); - const target = tabs[next]; - if (target) { - target.element.focus(); - context.setValue(target.value); - } - }; - return
{local.children}
; + const [local, rest] = splitProps(props, ["class", "children"]); + return {local.children}; } export interface TabProps extends Omit, "value" | "class"> { @@ -101,47 +39,13 @@ export interface TabProps extends Omit) { - const context = useTabs(); - const [local, rest] = splitProps(props, ["value", "class", "children", "disabled", "onClick"]); - let element!: HTMLButtonElement; - onMount(() => { - const unregister = context.register({ value: local.value, element, disabled: Boolean(local.disabled) }); - onCleanup(unregister); - }); - return ( - - ); + const [local, rest] = splitProps(props, ["class", "children", "value"]); + return {local.children}; } export interface TabPanelProps extends JSX.HTMLAttributes { value: string } export function TabPanel(props: ParentProps) { - const context = useTabs(); - const [local, rest] = splitProps(props, ["value", "class", "children"]); - return ( - - ); + const [local, rest] = splitProps(props, ["value", "children"]); + return {local.children}; } diff --git a/packages/dds-solid/src/toast.tsx b/packages/dds-solid/src/toast.tsx index 38f922b..9c0c26a 100644 --- a/packages/dds-solid/src/toast.tsx +++ b/packages/dds-solid/src/toast.tsx @@ -1,12 +1,6 @@ -import { - For, - createContext, - createSignal, - onCleanup, - useContext, - type JSX, -} from "solid-js"; - +import { Toast as ArkToast, Toaster, createToaster } from "@ark-ui/solid/toast"; +import { space } from "@devslab/dds-tokens"; +import { createContext, createUniqueId, onCleanup, useContext, type JSX } from "solid-js"; import { classes } from "./utils"; export type ToastTone = "success" | "warning" | "danger" | "info"; @@ -17,63 +11,72 @@ export interface ToastApi { dismiss: (id: string) => void; clear: () => void; } - const ToastContext = createContext(); export interface ToastProviderProps { children: JSX.Element; defaultDuration?: number; - /** Localized accessible name for the optional manual dismiss control. */ dismissLabel?: string | ((toast: ToastRecord) => string); + /** Optional localized label for the notification region. */ + regionLabel?: string; class?: string; } export function ToastProvider(props: ToastProviderProps) { - const [toasts, setToasts] = createSignal([]); - const timers = new Map>(); + const prefix = createUniqueId(); + let region: HTMLDivElement | undefined; + let scopedDocument: Document | undefined; + // Zag derives group IDs from placement rather than provider identity. Keep + // each provider's physical ID unique, and scope that one logical lookup. + const getRootNode = () => scopedDocument ??= new Proxy(region?.ownerDocument ?? document, { + get(target, property) { + if (property === "getElementById") return (id: string) => id === "toast-group:bottom-end" ? region ?? null : target.getElementById(id); + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const toaster = createToaster({ + placement: "bottom-end", overlap: false, gap: space[8], + // An empty array matches EVERY key in Zag. An impossible physical code + // disables the global shortcut without stealing application focus. + offsets: "var(--dds-space-16)", removeDelay: 0, hotkey: ["DDS_NO_HOTKEY"], + max: Number.MAX_SAFE_INTEGER, pauseOnPageIdle: true, + }); let sequence = 0; - const dismiss = (id: string) => { - const timer = timers.get(id); - if (timer) clearTimeout(timer); - timers.delete(id); - setToasts((current) => current.filter((toast) => toast.id !== id)); - }; const api: ToastApi = { show(input) { - const id = `dds-toast-${++sequence}`; - setToasts((current) => [...current, { ...input, id }]); + const id = `dds-toast-${prefix}-${++sequence}`; const duration = input.duration ?? props.defaultDuration ?? 5000; - if (duration > 0) timers.set(id, setTimeout(() => dismiss(id), duration)); + toaster.create({ + id, description: input.message, type: input.tone === "danger" ? "error" : input.tone ?? "info", + duration: duration > 0 ? duration : Infinity, + meta: { dds: { ...input, id } satisfies ToastRecord }, + }); return id; }, - dismiss, - clear() { - for (const timer of timers.values()) clearTimeout(timer); - timers.clear(); - setToasts([]); - }, + dismiss: (id) => toaster.remove(id), + clear: () => toaster.remove(), }; onCleanup(api.clear); - return ( - - {props.children} -
- {(toast) => ( -
- {toast.message} - {props.dismissLabel ? ( - - ) : null} -
- )}
-
-
- ); + return + {props.children} + {(toast) => { + const record = () => toast().meta?.dds as ToastRecord; + return + {toast().description} + {props.dismissLabel && ×} + ; + }} + ; } export function useToast(): ToastApi { diff --git a/packages/dds-solid/src/tooltip.tsx b/packages/dds-solid/src/tooltip.tsx index 42cd25e..93fabde 100644 --- a/packages/dds-solid/src/tooltip.tsx +++ b/packages/dds-solid/src/tooltip.tsx @@ -1,9 +1,10 @@ -import { createUniqueId, type JSX } from "solid-js"; - -import { createControllableSignal } from "./controllable"; +import { Tooltip as ArkTooltip, useTooltip } from "@ark-ui/solid/tooltip"; +import { createUniqueId, splitProps, type JSX } from "solid-js"; import { classes } from "./utils"; +import { createControllableSignal } from "./controllable"; -export interface TooltipTriggerProps { +/** Spread all trigger props onto the focusable trigger element. */ +export interface TooltipTriggerProps extends Omit, "aria-describedby" | "onFocus" | "onBlur" | "onPointerEnter" | "onPointerLeave"> { "aria-describedby": string; onFocus: () => void; onBlur: () => void; @@ -25,21 +26,26 @@ export function Tooltip(props: TooltipProps) { const generated = createUniqueId(); const id = () => props.id ?? `dds-tooltip-${generated}`; const [open, setOpen] = createControllableSignal({ - value: () => props.open, - defaultValue: props.defaultOpen ?? false, - onChange: props.onOpenChange, + value: () => props.open, defaultValue: props.defaultOpen ?? false, + onChange: (next) => props.onOpenChange?.(next), }); - const trigger: TooltipTriggerProps = { - "aria-describedby": id(), - onFocus: () => setOpen(true), - onBlur: () => setOpen(false), - onPointerEnter: () => setOpen(true), - onPointerLeave: () => setOpen(false), - }; - return ( + const api = useTooltip(() => ({ + id: id(), ids: { content: id() }, open: open(), + onOpenChange: (details) => setOpen(details.open), openDelay: 0, closeDelay: 0, + })); + return - {props.children(trigger)} - + { + const [, attributes] = splitProps(merge(), ["onFocusIn", "onFocusOut"]); + return props.children({ + ...attributes, "aria-describedby": id(), + // Preserve DDS's established composable, zero-argument callbacks. + onFocus: () => api().setOpen(true), onBlur: () => api().setOpen(false), + onPointerEnter: () => api().setOpen(true), onPointerLeave: () => api().setOpen(false), + }); }} /> + + {props.content} + - ); + ; } diff --git a/packages/dds-solid/styles.css b/packages/dds-solid/styles.css index 5ac3922..c58d168 100644 --- a/packages/dds-solid/styles.css +++ b/packages/dds-solid/styles.css @@ -1,2 +1,18 @@ @import "@devslab/dds-tokens/tokens.css"; @import "@devslab/dds-css/dds.css"; + +/* Keep the former context-only Tabs layout while Ark owns its DOM contract. */ +.dds-tabs-root { display: contents; } + +/* Ark positions the wrapper; DDS continues to own the bubble appearance. */ +.dds-tooltip__bubble[data-scope="tooltip"] { + position: relative; + inset: auto; + transform: none; + opacity: 1; +} + +.dds-toast[data-scope="toast"] { + transform: translate(var(--x), var(--y)); + opacity: var(--opacity); +} diff --git a/packages/dds-solid/vite.config.ts b/packages/dds-solid/vite.config.ts index 5d7dcd1..a135277 100644 --- a/packages/dds-solid/vite.config.ts +++ b/packages/dds-solid/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" }, sourcemap: true, rollupOptions: { - external: [/^solid-js(?:\/|$)/, /^@solidjs\/web(?:\/|$)/, /^@devslab\//], + external: [/^solid-js(?:\/|$)/, /^@solidjs\/web(?:\/|$)/, /^@devslab\//, /^@ark-ui\//], }, }, }); diff --git a/packages/dds-solid/vite.preview.config.ts b/packages/dds-solid/vite.preview.config.ts new file mode 100644 index 0000000..31d7274 --- /dev/null +++ b/packages/dds-solid/vite.preview.config.ts @@ -0,0 +1,7 @@ +import solid from "vite-plugin-solid"; +import { defineConfig } from "vite"; +export default defineConfig({ + root: "ark-preview", + plugins: [solid()], + server: { host: "127.0.0.1", port: 4179, strictPort: true }, +}); diff --git a/packages/dds-solid/vite.server.config.ts b/packages/dds-solid/vite.server.config.ts index 2274f1b..e899748 100644 --- a/packages/dds-solid/vite.server.config.ts +++ b/packages/dds-solid/vite.server.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ emptyOutDir: false, sourcemap: true, rollupOptions: { - external: [/^solid-js(?:\/|$)/, /^@devslab\//], + external: [/^solid-js(?:\/|$)/, /^@devslab\//, /^@ark-ui\//], output: { entryFileNames: "server.js" }, }, }, diff --git a/packages/dds-solid/vitest.config.ts b/packages/dds-solid/vitest.config.ts index 1974d51..bd08001 100644 --- a/packages/dds-solid/vitest.config.ts +++ b/packages/dds-solid/vitest.config.ts @@ -4,10 +4,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [solid({ hot: false })], test: { + pool: process.platform === "win32" ? "threads" : "forks", + maxWorkers: 2, environment: "jsdom", include: ["src/__tests__/a11y.test.tsx", "src/__tests__/primitives.test.tsx"], globals: false, restoreMocks: true, - setupFiles: [], + setupFiles: ["src/__tests__/setup.ts"], }, }); diff --git a/packages/dds-solid/vitest.ssr.config.ts b/packages/dds-solid/vitest.ssr.config.ts index 0955366..235cd65 100644 --- a/packages/dds-solid/vitest.ssr.config.ts +++ b/packages/dds-solid/vitest.ssr.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [solid({ ssr: true, hot: false })], test: { + pool: process.platform === "win32" ? "threads" : "forks", environment: "node", include: ["src/__tests__/ssr.test.tsx"], setupFiles: [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72853b2..6d0dfb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: packages/dds-solid: dependencies: + '@ark-ui/solid': + specifier: 5.39.3 + version: 5.39.3(solid-js@1.9.15) '@devslab/dds-css': specifier: workspace:0.17.1 version: link:../dds-css @@ -214,6 +217,11 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ark-ui/solid@5.39.3': + resolution: {integrity: sha512-IKjI4FoUSrVH4dV6LLgh7AJk4iv+94YGBVGd+kYYrMypkH4Q9MBsm0iXyzjArTgtZxI+MgVykT7/eIyPVPL+dw==} + peerDependencies: + solid-js: '>=1.6.0' + '@asamuzakjp/css-color@6.0.7': resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} engines: {node: ^22.13.0 || >=24.0.0} @@ -834,6 +842,15 @@ packages: '@noble/hashes': optional: true + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -989,6 +1006,12 @@ packages: '@types/node': optional: true + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1374,6 +1397,11 @@ packages: resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} + '@solid-primitives/keyed@1.5.3': + resolution: {integrity: sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/refs@1.1.4': resolution: {integrity: sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A==} peerDependencies: @@ -1438,6 +1466,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tanstack/history@1.162.1': resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} engines: {node: '>=20.19'} @@ -1775,6 +1806,245 @@ packages: '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} + '@zag-js/accordion@1.45.0': + resolution: {integrity: sha512-q29KwigVLWWM6PbZwmwPxmecKIUYpWwOK5k28mX4NdRACKEonxdkvZQePhcAD5FIKbCvacB4POE6OOCy/E3JSQ==} + + '@zag-js/anatomy@1.45.0': + resolution: {integrity: sha512-IPj5KyrIjfhxKBCeVyzZsP3j9LhDmZ5wTTpfeLTwPwGlPgYrRT8m2Tyh88uX9RCd6cEOkxgWYTIAJGwbaDzgSA==} + + '@zag-js/angle-slider@1.45.0': + resolution: {integrity: sha512-Fzk+teRpbnCg0TZQX92vDixeTNW7kSEgcqYDedjFFUWBYvmOwootH0pvI+bFts4m7CQWIJ1YJvoHT9v/CYuCkA==} + + '@zag-js/aria-hidden@1.45.0': + resolution: {integrity: sha512-IptHj2XJEoQffMqf+gReyHKnhkBKDBGQwZTClWiWLGizLPkQTYWLs1CFUa2BiDuIFk0HgeGS80tOjbdsgtt9yA==} + + '@zag-js/async-list@1.45.0': + resolution: {integrity: sha512-2w/zRwCjYfW3JV75pNpHBuTdEq8SlNjZa51mfup9Cz9MBdrXFgDtYmPmSMM8T8aXqHoKkHSJUNHVQPso494slQ==} + + '@zag-js/auto-resize@1.45.0': + resolution: {integrity: sha512-6dqnaam5fdJwIyZE94/uutw7rNavH8/poBxU0m1Lc1NzbMnZLRsn3OJnHczhMRlUCpc7awjnZElrjEKex6rRog==} + + '@zag-js/avatar@1.45.0': + resolution: {integrity: sha512-x+nKOULArQgYk4mU2hmY9SsswPlF8N+lizi/Acia9sTki99xzGEmkEGIzqbdQjzxgyGEjvxmjMSET924QnKj+A==} + + '@zag-js/carousel@1.45.0': + resolution: {integrity: sha512-PpJMJVm4zQ1THfp3ACt6FtE1YNGklFyj9vZ/kDCfBH4wQL490XdfBHCKTt7bk1UG666tmTNADqUGhuaT4tDdKg==} + + '@zag-js/cascade-select@1.45.0': + resolution: {integrity: sha512-4tPwg694mjXUdrli+aX5qvZmlBr4KO8YdZC7A5/1sRs7exdmOGsHQKLlnTlmZJ4angKadiIDBY6BRWl0dtv4Tg==} + + '@zag-js/checkbox@1.45.0': + resolution: {integrity: sha512-bv9fauSeuH+IyRcgS2TBjsHSn3p/56ek19uNuiVdxxJEm8KJWKW6+RCrpcFx7pzyTwK3W8D/eeNELPxUSKaVjw==} + + '@zag-js/clipboard@1.45.0': + resolution: {integrity: sha512-iSD5CHfYcVQronJk1HPvormU4RKcZnq9RrvwGyBi5DaUuaY/++PjbrZSxfQXhuZAUUI4wcpePRqfyodukXpCgg==} + + '@zag-js/collapsible@1.45.0': + resolution: {integrity: sha512-ptlYNdH5GC9752F4XRm9KIRoKhUBRnmiT22213KMcstS5c1xQ4V15yF+VzpPgWCjTWVezHY516TtMq4NJenOHg==} + + '@zag-js/collection@1.45.0': + resolution: {integrity: sha512-ikwXZPudIN5PpsD6+++hu+DcUfGviYax8jmNlAhDpocA+Fxpf334+4J6URNkPQYLHawAmhcCOO7MkHfYfXHEgw==} + + '@zag-js/color-picker@1.45.0': + resolution: {integrity: sha512-aXrZQT+jeCU8/Orbmo6VWSBJGujRHkXbC0BKP7k9l3ynWsjn5brFaIJmcWGyMErXZTMDlg6/s5J7Xs3kZHNgCQ==} + + '@zag-js/color-utils@1.45.0': + resolution: {integrity: sha512-xfb/GLutoPvHFFkANvqx57VQREBMX7IF6tt6eGgYo7ZiOGpXOmt7YuMHiJ9EgCRxfv3EQP0E6c4riX2oL13ifA==} + + '@zag-js/combobox@1.45.0': + resolution: {integrity: sha512-5WX0Gaf/0lBIMuK9t0oL6LQLBz0OMRKBPmntw3TpBD/l74qD7w7jPSBoftTBBjcHaD7Ylq2ZfD/BiV0G0FQ+Sw==} + + '@zag-js/core@1.45.0': + resolution: {integrity: sha512-xDd+ezb1+jzGVf+85HgVuqBzCuX+x8/GH+FnPESsSU97Hzq6OYJbvd56YpN5gioqViuJkJUFkPBCYcq0Niw2LA==} + + '@zag-js/date-input@1.45.0': + resolution: {integrity: sha512-RV9qAwvJcOgHrmdlpWb4B0ikzdKsDcQpOgzZr9ExQGnnKG6DpJlpZrL8gmZbJaq5qssbu+KAh8sqIZo1TLrMQQ==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-picker@1.45.0': + resolution: {integrity: sha512-nK0PN/cI+1j8p6WIH3Y7QPBWfqLuiJxa0oyXGwl6UM8O9DA86JBcTSyCNNGNG72jdH80KdC0K60XuEQjn2w4uA==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-utils@1.45.0': + resolution: {integrity: sha512-5Qx31rkwtYsSuiYSBw8XYSJKlA0PU8G8VLo5rMI/2I3ORYipe9E0Wx0BZEO6rG7qBKzK4EVXA16TbILeWgrxvA==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/dialog@1.45.0': + resolution: {integrity: sha512-mnh7rUoR3OGiD92qqHRdi3zS6vyl1JHbRcJDmqnWbFo8gi6DtgyYivFhv/zQWx8rKXeRM4NB5CkdvFRg0hMM5w==} + + '@zag-js/dismissable@1.45.0': + resolution: {integrity: sha512-GblGg5CXomPu8t+aE+vvXHG/Mdl1LAcE83NwJqVnDtOxFcXUrkizq2dCtiIUQOkk0GfKnh14AZN3wf5QXqxblA==} + + '@zag-js/dom-query@1.45.0': + resolution: {integrity: sha512-rJIESTiyzS6sfplDZxlO5WO0fTeHwL8RFylPDetpAtxhbADIV3kcP6qAd7pEMyXM5rdh7qTXOFm0PJOU2selyA==} + + '@zag-js/drawer@1.45.0': + resolution: {integrity: sha512-XPJXyMkNWLsvnyj4Ym5p03Wtwhusz7iqt309J0C+VeiR4byvMkj/EnsgxqdPcgBNwhnb1YRYmmsr7J1gkWsLgQ==} + + '@zag-js/editable@1.45.0': + resolution: {integrity: sha512-IPbohcD+ZUEypJZ7oFkOSSkdUXYzWZ68K5J9FzjnhBUZ8enECWP9+ucyp4cXBhHB3RbQK1iJ2p9sMcvNnjCGzA==} + + '@zag-js/file-upload@1.45.0': + resolution: {integrity: sha512-Nm1pr3YFzCxI4wmLtm64Dz5n/Bxl6J6FI1GwHrmSZB9Xt/CvzF3xVY3eb4uhkqT5hQaydO/8VWD8gD5jbJ2oxA==} + + '@zag-js/file-utils@1.45.0': + resolution: {integrity: sha512-6GbM07Z1wWEVkDWNs6+cMbUe2nIXYS2ny2ZezzB/2vEOtWrbUzHugrDWg4NGnlhGOI1mvsmyTwngkgoqv7PLGA==} + + '@zag-js/floating-panel@1.45.0': + resolution: {integrity: sha512-CbqdpVzGtRrm2tSkf4PVXQXsfjWMXSh6xwe+MzIekqmz+lMVYFJ6TRS5TufEHEhTP5+JvCooLMlYeyDhXFEaWg==} + + '@zag-js/focus-trap@1.45.0': + resolution: {integrity: sha512-9luXZfY8oBTU+7WzCKm1rJQu3V7xdUqscwBeAWyUb/kfKmZkg7V0Ve1iJtpLwovwETYIrpckUrD4m31lna63PA==} + + '@zag-js/focus-visible@1.45.0': + resolution: {integrity: sha512-PZXaKw56bCP+BuAbm99LdUKtUUhC0+RfwMXlK8ZTyX30LI8nCt2VGdVPYYCadZWqL0klbgLoOwtTujpArnV/5A==} + + '@zag-js/highlight-word@1.45.0': + resolution: {integrity: sha512-0Eua8aJB7tFqnQHICyRHlJUhkm4MqgavAwDU5oN26Gx+FiwUaDt3jMRaUd5wcwItyObAxafCoyKJ7UVWt/qvfQ==} + + '@zag-js/hotkeys@1.45.0': + resolution: {integrity: sha512-WUiAq1BYudm1/dDf/htDe+EHXzetLFKNhTHXk/fmmB7mg++3r9kKS9y7o73T6Xun38ssMYr7cFshnY/6XY0K3g==} + + '@zag-js/hover-card@1.45.0': + resolution: {integrity: sha512-h3+RIGemCtDij501/lZte+cH9RkZRE3NyvMWEt8t9pJv03VNowPdMgXKc4GthRtugXkjeveyZI5/i668UKZGGA==} + + '@zag-js/i18n-utils@1.45.0': + resolution: {integrity: sha512-Qhp/l9zmTY16FIsqE6jDkimN8n8TJxQEHWb0P6HbTnnHfWNJJ/gdixMrgM/BAwzKA64/hAN7p8d5A46mQ9o3Qw==} + + '@zag-js/image-cropper@1.45.0': + resolution: {integrity: sha512-FxqVTVbdlJdEMP4+1BrpGrw7Ol1rU7Ut94F2b/1H96dK31Qj+nxBdbp4WseE16Q6uWz07Ex8pRwFa/oP1Lc++A==} + + '@zag-js/interact-outside@1.45.0': + resolution: {integrity: sha512-QKV/Vluw5jB2nz0/a2GvsiNINaOb11im4FTPogLtb9IRM1NmHQpDjAEpz5V6NE+awpqwI6Gnxg9eNUd/V79pFw==} + + '@zag-js/json-tree-utils@1.45.0': + resolution: {integrity: sha512-hZohkZwK80/BGFBSH8w4PjWsJG/2hiYv5afwO70LyUe3VJcccp/6Tp3Z4urcQ9ygMh8QsBtxO4XEDyIzhKCd/A==} + + '@zag-js/listbox@1.45.0': + resolution: {integrity: sha512-8fmcgwREaphWzo14sbkDXFQQJUpqJ5oqgZQQFMdJKDSXwKoQ/V4WnMHmCfvvq8m4iFcP+l3EgvvD9MXqDQOM0g==} + + '@zag-js/live-region@1.45.0': + resolution: {integrity: sha512-Tb1nYcQDcChC8l/JPwEPt1yC+EXsbr0X7zzHuyK0vn4hoxjPH1qjPJI432wyEB07lj3tA01nvMNf8DNRXgU9CA==} + + '@zag-js/marquee@1.45.0': + resolution: {integrity: sha512-ulh0Nr3HN8I8TVgmW4aM4N019S9to15mVczaw5e4cJzjwBHg0oLYoZcUqIpcHdGndlM3CHRGsOFi8dr1sN2dLA==} + + '@zag-js/menu@1.45.0': + resolution: {integrity: sha512-gV7yGEAlaGuseaTcQO6LCDrMjr7PxfORqTUcBBl5solOnWKWaalfuEPGzPaEEPfPuOJY4QkdFEpovz3g9+hUPQ==} + + '@zag-js/navigation-menu@1.45.0': + resolution: {integrity: sha512-m0M5GxVxsvptQyDgDPPWNqbU3K22wDqAKjIdOWtQN6eOCK5YF0bHJ8IKF0LxSvDgzX/UuXXuwoeQCnZn4XvW1A==} + + '@zag-js/number-input@1.45.0': + resolution: {integrity: sha512-INZz+oyIWGbiOq2eCcggC9T8ajALD6hBR46xJ+13PGezG4sF7UTNQan9MitNx/033p/zkecxyfAn5/11UWAkew==} + + '@zag-js/pagination@1.45.0': + resolution: {integrity: sha512-vywjvhBT8UdbbWtCy1JnIXH51YJPoRA2eQy4VSxFY/beV0KNJEcamtTvMnGW1f4qJ3nvVXqMAo31ob92TO/YKw==} + + '@zag-js/password-input@1.45.0': + resolution: {integrity: sha512-LL0NkvYx+dg1WAmNWcdLwHi7gMYWlnEx9JZpDvWgzqtCDdkVy0Fq7cbllPcok7NC2HbIvegdG4Jw+NbKpobovA==} + + '@zag-js/pin-input@1.45.0': + resolution: {integrity: sha512-fJy+y6oFX8MHkNLYncmhbb2ycPibN0cQ5qH4qTJKnXxFY0iNQYDIdTW0l9l0jrXd2h7e9TLZlOQWrfeBIPISIw==} + + '@zag-js/popover@1.45.0': + resolution: {integrity: sha512-qFVNYs1Gt5mUqEYqE8IWlvu5aDMttE0H35g3SnfzmB0uchMR40JcKmNRGJMsMc/bnX7ahS/Gv1Xhc82ikumE2Q==} + + '@zag-js/popper@1.45.0': + resolution: {integrity: sha512-oFEbo8nHW0tI36LrnqpAl+pNPozc2aWN1gRHy5YQpZQyBT2De46/9p2cN/ZACsVP/y/P7v9nMsV1tB95iECRvw==} + + '@zag-js/presence@1.45.0': + resolution: {integrity: sha512-GCO6HePUNOTpMfukHmmltKyY8zfLvkiX3oZICiZ8iPu50sEovjKP3noqJCDMvKsgANiQ/uK5djY99B6A7l1IhQ==} + + '@zag-js/progress@1.45.0': + resolution: {integrity: sha512-oKDoptRRNClXQAtHrzwt2xLDqZ0K7f13PXotC4nVmDp5dpcdfl8bO4y/iGQs9879Hqrzl/tpLjOPeOyMqlgo1g==} + + '@zag-js/qr-code@1.45.0': + resolution: {integrity: sha512-nqSxPdSfhIzF4/zSXgNplyseHFnGJLtmghc9wsJaJusTioMkksI0nt3QmuzRBPWb3rSmK1Xa22vK6z+1T7D22A==} + + '@zag-js/radio-group@1.45.0': + resolution: {integrity: sha512-9K5SJ0wXk9NDItqBSUXZKKOzrkZ16LHrY8AtTsYRVQFKUA+d/O2hP6LVSZL5rZgb247QKdbTzlcXzVpdNnhMSg==} + + '@zag-js/rating-group@1.45.0': + resolution: {integrity: sha512-aOh0BvNBX5mL3bz8YbZTYv/plR/7AD+80wJWA0Yw1aOGHhV2fIcomDExCEz/d0z/uNZdSh2mAKQPltoFLUeVUw==} + + '@zag-js/rect-utils@1.45.0': + resolution: {integrity: sha512-hL1CGFArK4261i9ac/vMjHG5okLdnWCbD3OuN0zUeYwhM1EKajE8qrCPURCcNLdhZEHeQoUQNO4nPH+BrdoQzw==} + + '@zag-js/remove-scroll@1.45.0': + resolution: {integrity: sha512-ZT8K54tnyLPrO0s2gUQyNvxHV5uwSaYMB5qs3qDXTUQnd7ONQyYFiJRiom4aHnbg33dFt5lwLJIxSpuL4cO0HA==} + + '@zag-js/scroll-area@1.45.0': + resolution: {integrity: sha512-5hAei2Hbfhxae8Iv0NLKdeFe56+ZDpdwvRTxbFfqs828zsm0rKP8XciH+v+7sJHKDlRBXdnS0EtF7JudeLa5dg==} + + '@zag-js/scroll-snap@1.45.0': + resolution: {integrity: sha512-qNTehDaZ6B6uYAchR49xwJ184ISCt4it7OTwC1XpveXNnS4ZBuGlMKAIfnq2il2rsI0BgH5NhVDbd+qzd3EKxA==} + + '@zag-js/select@1.45.0': + resolution: {integrity: sha512-bylJNDctCKnJCyQxDKBEukscx+lfIcCTm/cRgKEDd9SLyqSp0WgnLmthjefd+4YDlWKLW7MFNdteUMbwAilKAQ==} + + '@zag-js/signature-pad@1.45.0': + resolution: {integrity: sha512-oSm80ZLPynxqfKPsLNo5ETOK+FpHsHUwAPto2wkX44JstWLxTCwX/QzYS0zAl1IhvZD7Ao1kZsbvMaywvuL+Tw==} + + '@zag-js/slider@1.45.0': + resolution: {integrity: sha512-H7ib9qE5Z1dKoMkdCUcUSJyt4jSonEFHIcidAM4w0NaQ5k5uIeSK0fh6VZVw7ODUc55p787yWNJ7IBrZH/x7VQ==} + + '@zag-js/solid@1.45.0': + resolution: {integrity: sha512-2VoxjTdnjwvHp9gN6ZLWnDrwpZmUCGzbwD/LFb8D/LtokXI2xRiQE5W1k7tAy/pFdH5yul5m+SBlVzKCICgfvQ==} + peerDependencies: + solid-js: '>=1.1.3' + + '@zag-js/splitter@1.45.0': + resolution: {integrity: sha512-2xbSxSR2OUsxrcZxlaLXRBzeXF0EJ+c3EKVbNbZfTTABJlwnDdl1Sh3Ym1jk7AlJrwiXIZX1fyyEb8mZcwlgfg==} + + '@zag-js/steps@1.45.0': + resolution: {integrity: sha512-dkpwvrjzc7p29JxDMALWmTSZpfsBEsKd/nPgczDvBdXwWOrKjHuek6S27gmeMFXfHO1gLF+bPXbyAKyNAblNNw==} + + '@zag-js/store@1.45.0': + resolution: {integrity: sha512-zA5zfOXZsDEZxK7gzGF0dldZn3imxaxY8r7g+Zcs/Am0x5VAmVJx2uJHs1Vwx9agZ0XHtnqBVrjV+UhUzST5rg==} + + '@zag-js/switch@1.45.0': + resolution: {integrity: sha512-Ck6q8Lc0RVqXJsIWLU+CVw2cMNcrEGH6TWxxqnWcJ7Z58m7OHYwz3Uh9juDj3Pr+0xLtNod97Be5mcGRjgTNgw==} + + '@zag-js/tabs@1.45.0': + resolution: {integrity: sha512-MT0aPoaQpWjOq4RwVrSdHSHuGbyetdlNbNhoyo6at84kHqSLvIjm6u81+02Nvdc8Yd/M1i3er3vQNwH+wWIV/A==} + + '@zag-js/tags-input@1.45.0': + resolution: {integrity: sha512-B5SCCaHNpBbT5U0jDCzKQuL3egYy7R1hBZsc8yZ+xA2xnKnDFrJxkdnJklt7lcJJ2+KmtkI0q0c8vmrv3KmUnQ==} + + '@zag-js/timer@1.45.0': + resolution: {integrity: sha512-QWQF+vTKCV7WYu8fjJjHlM8ozGlnHkQJHqGtELy8LfCAw3QNxqrtldeFG1shu8Ak/jjuDHrWOyJE3zieuszzlQ==} + + '@zag-js/toast@1.45.0': + resolution: {integrity: sha512-wMIVGbseGJrRpfE2/qHMdEkRzpE+zsMGN0O6H90bSInTiA5n6lXONRpXsOuGlQhgDvHn/X5mD4g7z46X1vco9w==} + + '@zag-js/toc@1.45.0': + resolution: {integrity: sha512-/a6y0wBEphWmmHTQqXfpOcnGaVIYJqkaQgHlQLfO/MDqCDP1KJcgII+PhgLFZMVhK8OZ9d6CEUFre6Xx4N9ULw==} + + '@zag-js/toggle-group@1.45.0': + resolution: {integrity: sha512-SHGu0aYb12bg1BvpZrzB4h2GM0kaIGilEfgqKJ8d+2DuNsmBnf7D/fb7ALJFU4EUh11NM8QA3ZpQLfTZZJFw/A==} + + '@zag-js/toggle@1.45.0': + resolution: {integrity: sha512-bWp0qyR89OB+uweHSaomWKgg2kCgVsbHTaN8GY/JxNHlp34GSor05L4kC0z4JtL6JhOY8O6tTlMbz994YB/unw==} + + '@zag-js/tooltip@1.45.0': + resolution: {integrity: sha512-7t60A2y1trAvdabsyhfTpCcynp4sspCKxvreiPqRF5ukm4VP5yJy3IrdjghHQ8GcDiC/blnmLgVF2pTi1Mz63Q==} + + '@zag-js/tour@1.45.0': + resolution: {integrity: sha512-2xFj/eLpCu29CLosF11w9jnIT1LFLhlmLyJhEtNtLRbJHEjeX7qzXP2XRKNv6GFsda3Wz31yQ/jUgtR9qAzmHw==} + + '@zag-js/tree-view@1.45.0': + resolution: {integrity: sha512-oTcMrTMUjjVqP6nYmK/xWpwZzt77yHyiGUXDhMFNE8kWnfgo5DlixgxQawsRdzML6YCmQcQHD0DjdGbn4TtIpw==} + + '@zag-js/types@1.45.0': + resolution: {integrity: sha512-92xETOwIVWksA+wqZ/Dex/obbC5cgCCRCF7HQ7Cf8Wgv0jBKqUphccZRzmHic1qOthoW7ZAK2xkUtyXFmd2/SQ==} + + '@zag-js/utils@1.45.0': + resolution: {integrity: sha512-LyeGfTEojjhDcajCCGxvjrgFuPoMia1HKefdP6KqvJpC78+1awahBoZpYYZlbab2WPawgCaprcDevBJFlOe6Ag==} + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -2448,6 +2718,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + perfect-freehand@1.2.3: + resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2491,6 +2764,12 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + + proxy-memoize@3.0.1: + resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2814,6 +3093,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -3044,6 +3326,79 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@ark-ui/solid@5.39.3(solid-js@1.9.15)': + dependencies: + '@internationalized/date': 3.12.4 + '@zag-js/accordion': 1.45.0 + '@zag-js/anatomy': 1.45.0 + '@zag-js/angle-slider': 1.45.0 + '@zag-js/async-list': 1.45.0 + '@zag-js/auto-resize': 1.45.0 + '@zag-js/avatar': 1.45.0 + '@zag-js/carousel': 1.45.0 + '@zag-js/cascade-select': 1.45.0 + '@zag-js/checkbox': 1.45.0 + '@zag-js/clipboard': 1.45.0 + '@zag-js/collapsible': 1.45.0 + '@zag-js/collection': 1.45.0 + '@zag-js/color-picker': 1.45.0 + '@zag-js/color-utils': 1.45.0 + '@zag-js/combobox': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/date-input': 1.45.0(@internationalized/date@3.12.4) + '@zag-js/date-picker': 1.45.0(@internationalized/date@3.12.4) + '@zag-js/date-utils': 1.45.0(@internationalized/date@3.12.4) + '@zag-js/dialog': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/drawer': 1.45.0 + '@zag-js/editable': 1.45.0 + '@zag-js/file-upload': 1.45.0 + '@zag-js/file-utils': 1.45.0 + '@zag-js/floating-panel': 1.45.0 + '@zag-js/focus-trap': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/highlight-word': 1.45.0 + '@zag-js/hotkeys': 1.45.0 + '@zag-js/hover-card': 1.45.0 + '@zag-js/i18n-utils': 1.45.0 + '@zag-js/image-cropper': 1.45.0 + '@zag-js/json-tree-utils': 1.45.0 + '@zag-js/listbox': 1.45.0 + '@zag-js/marquee': 1.45.0 + '@zag-js/menu': 1.45.0 + '@zag-js/navigation-menu': 1.45.0 + '@zag-js/number-input': 1.45.0 + '@zag-js/pagination': 1.45.0 + '@zag-js/password-input': 1.45.0 + '@zag-js/pin-input': 1.45.0 + '@zag-js/popover': 1.45.0 + '@zag-js/presence': 1.45.0 + '@zag-js/progress': 1.45.0 + '@zag-js/qr-code': 1.45.0 + '@zag-js/radio-group': 1.45.0 + '@zag-js/rating-group': 1.45.0 + '@zag-js/scroll-area': 1.45.0 + '@zag-js/select': 1.45.0 + '@zag-js/signature-pad': 1.45.0 + '@zag-js/slider': 1.45.0 + '@zag-js/solid': 1.45.0(solid-js@1.9.15) + '@zag-js/splitter': 1.45.0 + '@zag-js/steps': 1.45.0 + '@zag-js/switch': 1.45.0 + '@zag-js/tabs': 1.45.0 + '@zag-js/tags-input': 1.45.0 + '@zag-js/timer': 1.45.0 + '@zag-js/toast': 1.45.0 + '@zag-js/toc': 1.45.0 + '@zag-js/toggle': 1.45.0 + '@zag-js/toggle-group': 1.45.0 + '@zag-js/tooltip': 1.45.0 + '@zag-js/tour': 1.45.0 + '@zag-js/tree-view': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + solid-js: 1.9.15 + '@asamuzakjp/css-color@6.0.7': dependencies: '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -3607,6 +3962,17 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.2': @@ -3720,6 +4086,14 @@ snapshots: optionalDependencies: '@types/node': 26.3.0 + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3994,6 +4368,10 @@ snapshots: '@sindresorhus/is@7.2.0': {} + '@solid-primitives/keyed@1.5.3(solid-js@1.9.15)': + dependencies: + solid-js: 1.9.15 + '@solid-primitives/refs@1.1.4(solid-js@1.9.15)': dependencies: '@solid-primitives/utils': 6.4.1(solid-js@1.9.15) @@ -4053,6 +4431,10 @@ snapshots: dependencies: react: 19.2.8 + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@tanstack/history@1.162.1': {} '@tanstack/query-core@5.102.8': {} @@ -4447,6 +4829,583 @@ snapshots: '@webcontainer/env@1.1.1': {} + '@zag-js/accordion@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/anatomy@1.45.0': {} + + '@zag-js/angle-slider@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/rect-utils': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/aria-hidden@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + + '@zag-js/async-list@1.45.0': + dependencies: + '@zag-js/core': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/auto-resize@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + + '@zag-js/avatar@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/carousel@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/scroll-snap': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/cascade-select@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/collection': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/rect-utils': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/checkbox@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/clipboard@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/collapsible@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/collection@1.45.0': + dependencies: + '@zag-js/utils': 1.45.0 + + '@zag-js/color-picker@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/color-utils': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/color-utils@1.45.0': + dependencies: + '@zag-js/utils': 1.45.0 + + '@zag-js/combobox@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/collection': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/live-region': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/core@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/date-input@1.45.0(@internationalized/date@3.12.4)': + dependencies: + '@internationalized/date': 3.12.4 + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/date-utils': 1.45.0(@internationalized/date@3.12.4) + '@zag-js/dom-query': 1.45.0 + '@zag-js/live-region': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/date-picker@1.45.0(@internationalized/date@3.12.4)': + dependencies: + '@internationalized/date': 3.12.4 + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/date-utils': 1.45.0(@internationalized/date@3.12.4) + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/live-region': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/date-utils@1.45.0(@internationalized/date@3.12.4)': + dependencies: + '@internationalized/date': 3.12.4 + + '@zag-js/dialog@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/aria-hidden': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-trap': 1.45.0 + '@zag-js/remove-scroll': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/dismissable@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + '@zag-js/interact-outside': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/dom-query@1.45.0': + dependencies: + '@zag-js/types': 1.45.0 + + '@zag-js/drawer@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/aria-hidden': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-trap': 1.45.0 + '@zag-js/remove-scroll': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/editable@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/interact-outside': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/file-upload@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/file-utils': 1.45.0 + '@zag-js/i18n-utils': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/file-utils@1.45.0': + dependencies: + '@zag-js/i18n-utils': 1.45.0 + + '@zag-js/floating-panel@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/rect-utils': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/focus-trap@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + + '@zag-js/focus-visible@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + + '@zag-js/highlight-word@1.45.0': {} + + '@zag-js/hotkeys@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + + '@zag-js/hover-card@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/i18n-utils@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + + '@zag-js/image-cropper@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/interact-outside@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/json-tree-utils@1.45.0': {} + + '@zag-js/listbox@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/collection': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/live-region@1.45.0': {} + + '@zag-js/marquee@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/menu@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/rect-utils': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/navigation-menu@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/number-input@1.45.0': + dependencies: + '@internationalized/number': 3.6.7 + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/pagination@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/password-input@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/pin-input@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/popover@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/aria-hidden': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-trap': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/remove-scroll': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/popper@1.45.0': + dependencies: + '@floating-ui/dom': 1.8.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/presence@1.45.0': + dependencies: + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + + '@zag-js/progress@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/qr-code@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + proxy-memoize: 3.0.1 + uqr: 0.1.3 + + '@zag-js/radio-group@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/rating-group@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/rect-utils@1.45.0': {} + + '@zag-js/remove-scroll@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + + '@zag-js/scroll-area@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/scroll-snap@1.45.0': + dependencies: + '@zag-js/dom-query': 1.45.0 + + '@zag-js/select@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/collection': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/signature-pad@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + perfect-freehand: 1.2.3 + + '@zag-js/slider@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/solid@1.45.0(solid-js@1.9.15)': + dependencies: + '@solid-primitives/keyed': 1.5.3(solid-js@1.9.15) + '@zag-js/core': 1.45.0 + '@zag-js/store': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + solid-js: 1.9.15 + + '@zag-js/splitter@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/steps@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/store@1.45.0': + dependencies: + proxy-compare: 3.0.1 + + '@zag-js/switch@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/tabs@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/tags-input@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/auto-resize': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/interact-outside': 1.45.0 + '@zag-js/live-region': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/timer@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/toast@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/toc@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/toggle-group@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/toggle@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/tooltip@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-visible': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/tour@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dismissable': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/focus-trap': 1.45.0 + '@zag-js/interact-outside': 1.45.0 + '@zag-js/popper': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/tree-view@1.45.0': + dependencies: + '@zag-js/anatomy': 1.45.0 + '@zag-js/collection': 1.45.0 + '@zag-js/core': 1.45.0 + '@zag-js/dom-query': 1.45.0 + '@zag-js/types': 1.45.0 + '@zag-js/utils': 1.45.0 + + '@zag-js/types@1.45.0': + dependencies: + csstype: 3.2.3 + + '@zag-js/utils@1.45.0': {} + acorn@8.18.0: {} ansi-colors@4.1.3: {} @@ -5106,6 +6065,8 @@ snapshots: pathval@2.0.1: {} + perfect-freehand@1.2.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5138,6 +6099,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + proxy-compare@3.0.1: {} + + proxy-memoize@3.0.1: + dependencies: + proxy-compare: 3.0.1 + punycode@2.3.1: {} quansync@0.2.11: {} @@ -5453,6 +6420,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uqr@0.1.3: {} + use-sync-external-store@1.6.0(react@19.2.8): dependencies: react: 19.2.8 diff --git a/preview/index.html b/preview/index.html index 21a3960..e93f029 100644 --- a/preview/index.html +++ b/preview/index.html @@ -634,6 +634,7 @@
자료를 다시 색인할까요?
diff --git a/scripts/fixtures/solid-ark-consumer.mjs b/scripts/fixtures/solid-ark-consumer.mjs new file mode 100644 index 0000000..c75a692 --- /dev/null +++ b/scripts/fixtures/solid-ark-consumer.mjs @@ -0,0 +1,27 @@ +import { createComponent, createSignal, mergeProps } from "solid-js"; +import { Button, Checkbox, Dialog, Field, RadioGroup, Select, Switch, Tabs, TabList, Tab, TabPanel, ToastProvider, Tooltip } from "@devslab/dds-solid"; + +export const events = []; + +export function App() { + const [open, setOpen] = createSignal(false); + return createComponent(ToastProvider, { + dismissLabel: "Close notice", defaultDuration: 0, + get children() { return [ + createComponent(Field, { label: "Country", children: (control) => createComponent(Select, mergeProps(control, { + name: "country", + })) }), + createComponent(Checkbox, { label: "Agree", name: "agree", defaultChecked: true, onCheckedChange: value => events.push(["checked", value]) }), + createComponent(Switch, { label: "Enabled", name: "enabled" }), + createComponent(RadioGroup, { label: "Choice", name: "choice", defaultValue: "a", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] }), + createComponent(Tabs, { defaultValue: "one", onValueChange: value => events.push(["tab", value]), get children() { return [ + createComponent(TabList, { get children() { return [createComponent(Tab, { value: "one", children: "One" }), createComponent(Tab, { value: "two", children: "Two" })]; } }), + createComponent(TabPanel, { value: "one", children: "First" }), + createComponent(TabPanel, { value: "two", children: "Second" }), + ]; } }), + createComponent(Tooltip, { content: "Help", children: trigger => createComponent(Button, mergeProps(trigger, { children: "Help trigger" })) }), + createComponent(Button, { children: "Open confirmation", onClick: () => setOpen(true) }), + createComponent(Dialog, { get open() { return open(); }, onOpenChange: setOpen, title: "Confirmation", get children() { return createComponent(Button, { children: "Close confirmation", onClick: () => setOpen(false) }); } }), + ]; }, + }); +} diff --git a/scripts/verify-ark-browser.cjs b/scripts/verify-ark-browser.cjs new file mode 100644 index 0000000..d0ae4ca --- /dev/null +++ b/scripts/verify-ark-browser.cjs @@ -0,0 +1,49 @@ +async (page) => { + const check = (condition, message) => { if (!condition) throw new Error(message); }; + await page.getByRole('textbox', {name:'Retained note'}).fill('Kept note'); + await page.getByRole('button', {name:'Disable second tab'}).click(); + await page.getByRole('tab', {name:'First tab', exact:true}).focus(); + await page.keyboard.press('ArrowRight'); + await page.waitForFunction(() => document.querySelector('[role="tab"][aria-selected="true"]')?.textContent === 'Third tab'); + await page.getByRole('tab', {name:'First tab', exact:true}).click(); + check(await page.getByRole('textbox', {name:'Retained note'}).inputValue() === 'Kept note', 'Retained panel lost its input'); + await page.getByRole('radio', {name:'Choice A', exact:true}).focus(); + await page.keyboard.press('ArrowDown'); + check(await page.getByRole('radio', {name:'Choice B', exact:true}).isChecked(), 'Radio group arrow navigation failed'); + const entries = await page.locator('#controls').evaluate(form => Object.fromEntries(new FormData(form))); + check(entries.agree === 'on' && entries.enabled === 'on' && entries.choice === 'b' && entries.country === 'kr', 'Native form values lost'); + await page.locator('#controls').evaluate(form => form.reset()); + const resetEntries = await page.locator('#controls').evaluate(form => Object.fromEntries(new FormData(form))); + check(resetEntries.agree === 'on' && resetEntries.enabled === 'on' && resetEntries.choice === 'a', 'Native form reset lost defaults'); + await page.getByRole('button', {name:'Open dialog', exact:true}).click(); + const dialog = page.getByRole('dialog', {name:'Confirmation'}); + await dialog.waitFor(); + check(await page.getByRole('button', {name:'First action', exact:true}).evaluate(el => el === document.activeElement), 'Dialog initial focus failed'); + await page.getByRole('button', {name:'Close dialog', exact:true}).focus(); + await page.keyboard.press('Tab'); + check(await page.getByRole('button', {name:'First action', exact:true}).evaluate(el => el === document.activeElement), 'Dialog forward focus trap failed'); + await page.keyboard.press('Shift+Tab'); + check(await page.getByRole('button', {name:'Close dialog', exact:true}).evaluate(el => el === document.activeElement), 'Dialog backward focus trap failed'); + await page.keyboard.press('Escape'); + await dialog.waitFor({state:'hidden'}); + check(await page.getByRole('button', {name:'Open dialog', exact:true}).evaluate(el => el === document.activeElement), 'Dialog focus restoration failed'); + await page.keyboard.press('Tab'); + await page.getByRole('button', {name:'Help trigger', exact:true}).focus(); + await page.getByRole('tooltip').waitFor({state:'visible'}); + await page.keyboard.press('Escape'); + await page.getByRole('tooltip').waitFor({state:'hidden'}); + await page.getByRole('button', {name:'Show toast', exact:true}).click(); + await page.getByRole('status').waitFor(); + const toastBox = await page.getByRole('status').boundingBox(); + const viewport = page.viewportSize(); + check(toastBox && viewport && toastBox.x >= 0 && toastBox.y >= 0 && toastBox.x + toastBox.width <= viewport.width && toastBox.y + toastBox.height <= viewport.height, 'Toast exceeds viewport'); + await page.getByRole('status').waitFor({state:'hidden',timeout:5000}); + await page.getByRole('button', {name:'Show secondary toast',exact:true}).click(); + await page.getByRole('button', {name:'Open dialog',exact:true}).click(); + await page.getByRole('dialog', {name:'Confirmation'}).waitFor(); + await page.getByRole('button', {name:'Close secondary notification',exact:true}).click(); + check(await page.getByRole('dialog', {name:'Confirmation'}).isVisible(), 'Second toaster was treated as a dialog outside click'); + await page.keyboard.press('Escape'); + await page.getByRole('dialog', {name:'Confirmation'}).waitFor({state:'hidden'}); + return {tabs:'passed',retention:'passed',form:entries,reset:'passed',dialog:'passed',tooltip:'passed',toast:'passed',multipleProviders:'passed',viewport}; +} diff --git a/scripts/verify-solid-release.mjs b/scripts/verify-solid-release.mjs index f1029e9..8942756 100644 --- a/scripts/verify-solid-release.mjs +++ b/scripts/verify-solid-release.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { spawnSync } from "node:child_process"; const workspace = resolve(new URL("..", import.meta.url).pathname.replace(/^\/(?:([A-Za-z]:))/, "$1")); @@ -73,6 +74,10 @@ try { await writeFile(join(temp, "package.json"), JSON.stringify({ private: true, type: "module" }), "utf8"); run(["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs, "solid-js@1.9.15", "jsdom@30.0.1"], temp); const installedRoot = join(temp, "node_modules", "@devslab", "dds-solid"); + // Node always enables its `node` export condition, even with + // --conditions=browser. Load the installed browser entry explicitly for the + // hydration harness, as a browser bundler would (SSR uses normal exports). + const browserEntry = pathToFileURL(join(installedRoot, "dist/index.js")).href; const manifest = JSON.parse(await readFile(join(installedRoot, "package.json"), "utf8")); assert.equal(manifest.name, "@devslab/dds-solid"); for (const path of ["dist/index.js", "dist/server.js", "dist/index.d.ts", "styles.css"]) { @@ -101,7 +106,7 @@ Object.defineProperty(globalThis, "_$HY", { value: dom.window._$HY, configurable const host = document.querySelector("#root"); const { hydrate } = await import("solid-js/web"); const { createComponent } = await import("solid-js"); -const { Button, Icon } = await import("@devslab/dds-solid"); +const { Button, Icon } = await import(${JSON.stringify(browserEntry)}); const diagnostics = []; const warn = console.warn; const error = console.error; console.warn = (...values) => diagnostics.push(values.join(" ")); @@ -112,6 +117,59 @@ dispose(); console.warn = warn; console.error = error; if (diagnostics.length) throw new Error('fresh consumer hydration diagnostics: ' + diagnostics.join("\\n")); `, "utf8"); runNode(["--conditions=browser", hydrationScript], temp); + // The native Button/Icon check above cannot detect mismatches in Ark state, + // component IDs, presence boundaries or defaultChecked. Exercise the new + // interactive wrappers through the actual packed package as well. + await writeFile(join(temp, "ark-app.mjs"), await readFile(join(workspace, "scripts/fixtures/solid-ark-consumer.mjs"), "utf8")); + await writeFile(join(temp, "ark-ssr.mjs"), ` +import { generateHydrationScript, renderToString } from "solid-js/web"; +import { createComponent } from "solid-js"; +import { App } from "./ark-app.mjs"; +process.stdout.write(JSON.stringify({ bootstrap: generateHydrationScript(), html: renderToString(() => createComponent(App, {})) })); +`); + await writeFile(join(temp, "ark-ssr.json"), runNode([join(temp, "ark-ssr.mjs")], temp)); + await writeFile(join(temp, "ark-client-app.mjs"), (await readFile(join(temp, "ark-app.mjs"), "utf8")).replace('"@devslab/dds-solid"', JSON.stringify(browserEntry))); + await writeFile(join(temp, "ark-hydrate.mjs"), ` +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { JSDOM } from "jsdom"; +const { bootstrap, html } = JSON.parse(await readFile(new URL("./ark-ssr.json", import.meta.url), "utf8")); +const dom = new JSDOM('' + bootstrap + '
' + html + '
', { runScripts: "dangerously", pretendToBeVisual: true }); +for (const key of ["window", "document", "Node", "Element", "HTMLElement", "HTMLInputElement", "HTMLFormElement", "SVGElement", "Document", "ShadowRoot", "MutationObserver", "navigator", "CustomEvent", "getComputedStyle", "requestAnimationFrame", "cancelAnimationFrame"]) { + const value = dom.window[key]; + Object.defineProperty(globalThis, key, { value: typeof value === "function" && /^[a-z]/.test(key) ? value.bind(dom.window) : value, configurable: true, writable: true }); +} +Object.defineProperty(globalThis, "_$HY", { value: dom.window._$HY, configurable: true, writable: true }); +const { hydrate } = await import("solid-js/web"); +const { createComponent } = await import("solid-js"); +const { App, events } = await import("./ark-client-app.mjs"); +const host = document.querySelector("#root"); +const input = host.querySelector('input[name="agree"]'); +const diagnostics = []; const warn = console.warn; const error = console.error; +console.warn = (...values) => diagnostics.push(values.join(" ")); +console.error = (...values) => diagnostics.push(values.join(" ")); +const dispose = hydrate(() => createComponent(App, {}), host); +await new Promise(resolve => setTimeout(resolve, 40)); +assert.equal(host.querySelector('input[name="agree"]'), input, "Hydration replaced the server input"); +assert.equal(input.checked, true); +input.click(); +await new Promise(resolve => setTimeout(resolve, 40)); +assert.equal(input.checked, false); +assert.ok(events.some(([type, value]) => type === "checked" && value === false)); +host.querySelectorAll('[role="tab"]')[1].click(); +await new Promise(resolve => setTimeout(resolve, 40)); +assert.equal(host.querySelector('[role="tab"][aria-selected="true"]').textContent, "Two"); +[...host.querySelectorAll("button")].find(button => button.textContent === "Open confirmation").click(); +await new Promise(resolve => setTimeout(resolve, 40)); +assert.ok(host.querySelector('[role="dialog"]'), "Hydrated dialog did not open"); +[...host.querySelectorAll("button")].find(button => button.textContent === "Close confirmation").click(); +await new Promise(resolve => setTimeout(resolve, 40)); +assert.equal(host.querySelector('[role="dialog"]'), null); +dispose(); console.warn = warn; console.error = error; +assert.deepEqual(diagnostics, []); +dom.window.close(); +`); + runNode(["--conditions=browser", join(temp, "ark-hydrate.mjs")], temp); console.log("dds-solid pack, publish dry-run, and fresh consumer import, SSR, and hydration passed"); } finally { await rm(temp, { recursive: true, force: true }); diff --git a/tests/dds-solid-contracts.test.mjs b/tests/dds-solid-contracts.test.mjs index fb0bf67..0680c60 100644 --- a/tests/dds-solid-contracts.test.mjs +++ b/tests/dds-solid-contracts.test.mjs @@ -38,7 +38,7 @@ test("native controls preserve semantic elements and accessible names", async () assert.match(controls, /} +> + + +``` + +같은 document의 의도한 영역만 허용한다. getter에서 반응형 참조를 읽으면 참조 변경에 따라 컨테이너 목록도 갱신된다. 이미 있는 영역에 재시도 버튼을 추가·제거하는 동작은 포커스 트랩이 처리한다. 기존 수동 dialog-to-toast Tab 브리지는 필요하지 않다. 허용된 재시도 영역 클릭은 외부 클릭으로 취급하지 않는다. 영역 자체를 제거할 때는 반응형 참조/getter도 갱신해야 한다. + +브라우저 전용 포커스·컨테이너 getter는 SSR에서 실행하지 않는다. Portal 콘텐츠는 클라이언트에서 마운트된다. 일반 모달은 Ark의 포커스·접근성 격리를 유지하고, 외부 영역을 허용한 모달은 Zag의 다중 컨테이너 트랩과 공유 격리 처리를 사용한다. + +DOM 테스트는 Tab/Shift+Tab 순환, 배경 격리, 포커스 복귀, 사용자 프레임·Portal, Escape 취소를 검증한다. SSR 테스트는 실행 시 예외를 내는 브라우저 전용 getter로 서버 실행 여부를 확인한다. 실제 브라우저 회귀 검증은 `node packages/dds-solid/src/__tests__/dialog-browser.test.mjs`로 실행한다. 모달이 열린 뒤 재시도 버튼을 추가하고, 실제 포커스 이동·허용 영역 클릭·작업 중 Escape·닫기 후 격리 정리를 확인한다. diff --git a/docs/dialog-focus.md b/docs/dialog-focus.md new file mode 100644 index 0000000..e4ac40f --- /dev/null +++ b/docs/dialog-focus.md @@ -0,0 +1,37 @@ +# DDS Dialog frames and external focus regions + +`Dialog` keeps its existing props and defaults. Its optional presentation and focus props let an application keep its own task frame while DDS owns dialog interaction. + +- `portal` renders under `document.body`; `portalMount` chooses another mounted element. The default remains inline rendering. +- `role` accepts `dialog` or `alertdialog`. +- `unstyled` removes DDS component classes. `class`, `overlayClass`, `titleClass`, `descriptionClass`, and `actionsClass` add application classes. +- `frame(parts)` arranges the supplied `title`, `description`, `children`, and `actions` nodes. Render each supplied title/description once to retain its generated accessible association. These are DDS-owned nodes; applications do not import Ark components. +- `contentProps` supplies native content attributes and handlers, such as `aria-busy` and `onKeyDown`. +- `initialFocus` and `finalFocus` return application-selected focus targets. `restoreFocus={false}` suppresses restoration when a workspace page or task is no longer active. +- `onEscapeKeyDown(event)` receives the native keyboard event. `event.preventDefault()` cancels Escape dismissal, for example while an application is busy. `closeOnEscape` and `closeOnOutside` keep their existing policies. + +`additionalFocusContainers` explicitly authorizes external DOM regions to join the modal's focus cycle **and** its accessibility scope. A retry toast can then remain keyboard reachable without exposing the rest of the background: + +```tsx + firstField} + finalFocus={() => usableOpener() ?? fallbackControl()} + restoreFocus={pageActive()} + onEscapeKeyDown={(event) => { if (busy()) event.preventDefault(); }} + additionalFocusContainers={() => [toastRegion]} + actions={} +> + + +``` + +Authorize only the intended regions in the same document. The getter can read reactive references; changes update the container list. Adding/removing retry controls inside an existing region is handled by the focus trap. No manual dialog-to-toast Tab bridge is needed. An authorized retry click does not count as outside interaction. Removing an authorized region should update its reference/reactive getter. + +Browser-only focus/container getters are not evaluated during SSR. The portalled content mounts on the client. Normal dialogs retain Ark's focus and isolation behavior; external-scope dialogs reuse Zag's multi-container trap and shared modal isolation bookkeeping. + +Validation: DOM tests cover the combined Tab/Shift+Tab cycle, background isolation, restoration, custom frame/portal and Escape cancellation. An SSR test uses throwing browser-only getters. Run the real-browser regression with `node packages/dds-solid/src/__tests__/dialog-browser.test.mjs`; it additionally adds retry controls after opening and checks real focus, authorized clicks, busy Escape and isolation cleanup. diff --git a/docs/superpowers/plans/2026-10-08-ark-migration.md b/docs/superpowers/plans/2026-10-08-ark-migration.md index b40ce44..d5c295d 100644 --- a/docs/superpowers/plans/2026-10-08-ark-migration.md +++ b/docs/superpowers/plans/2026-10-08-ark-migration.md @@ -8,16 +8,19 @@ The approved design keeps DDS public imports, simple props, semantic CSS tokens - [x] Delegate tabs, dialogs, tooltips, checkable controls and toast behavior to Ark, preserving controlled/uncontrolled state, native form behavior and retained panels. - [x] Verify keyboard navigation, focus restoration, reactive disabled state, form submission, timer disposal, SSR and accessibility. Update implementation-specific source assertions to delegation contracts supported by runtime tests. - [x] Add linked Ark UI credits to DDS pages, bilingual migration guidance and a decision superseding the old reference-credit restriction for actual dependencies. -- [ ] Build and verify the package and review the complete diff before integration. -- [ ] Finish framework-neutral Workspace OSS and its adapters, then migrate consumers after DDS validation. FM remains read-only until its concurrent work is complete. +- [x] Build and verify DDS package exports, fresh consumer SSR/hydration and extended dialog focus scope before consumer integration. +- [x] Finish framework-neutral Workspace OSS and Solid/React/Vue/Svelte adapters, with headless composition, optional history and DDS theme mapping. +- [ ] Verify the FM migration in its isolated checkout and integrate reviewed consumer changes. The original FM checkout remains untouched. - [ ] Clean temporary checkout and branch after integrated changes are preserved. ## Review risks The read-only reviewer found no remaining critical or important issues after compatibility fixes. Packed-package SSR and interactive browser-entry hydration pass in a fresh consumer. Browser interaction checks pass at 390×844 and 1280×720. Integration and temporary checkout cleanup remain pending. +DDS verification after the consumer API extensions: 7 source contracts, 35 DOM/accessibility tests, 3 SSR tests, typecheck and both builds passed. The packaged SSR/hydration consumer and real-browser authorized retry focus/isolation test passed. Workspace browser checks passed 36 desktop/mobile cases plus 18 core/adapter SSR tests, native Svelte checks and isolated package-consumer validation. Website credits are preserved in the separate website branch; FM integration tests are still in progress. + Portal placement must not break CSS positioning or accessibility coverage. Reactive props must not become snapshots. Hidden panels must preserve instances. Radio grouping must remain native-form-compatible. Ark details objects must not escape DDS callbacks. Browser-only effects must not run during SSR. Package export conditions and bundled dependencies must work in consumers. ## Constraints -Work in the isolated `dds-ark` checkout on `codex/ark-wrappers`; preserve existing local DDS changes. Do not publish packages, deploy, or change FM business integrations. Homepage credits describe the actual Ark UI dependency, not authorship of the DevsLab visual design. +Work in the isolated `dds-ark` checkout on `codex/ark-wrappers`; preserve existing local DDS changes. Consumer UI changes belong in `fm-workspace-migration` and preserve its router, permissions, server/data and analytics contracts. Do not publish packages or deploy. Homepage credits describe the actual Ark UI dependency, not authorship of the DevsLab visual design. diff --git a/packages/dds-solid/package.json b/packages/dds-solid/package.json index cce6d2f..423caaf 100644 --- a/packages/dds-solid/package.json +++ b/packages/dds-solid/package.json @@ -46,7 +46,9 @@ "@ark-ui/solid": "5.39.3", "@devslab/dds-css": "workspace:0.17.1", "@devslab/dds-icons": "workspace:0.17.1", - "@devslab/dds-tokens": "workspace:0.17.1" + "@devslab/dds-tokens": "workspace:0.17.1", + "@zag-js/aria-hidden": "1.45.0", + "@zag-js/focus-trap": "1.45.0" }, "peerDependencies": { "solid-js": "1.9.15" diff --git a/packages/dds-solid/src/__tests__/dialog-browser.test.mjs b/packages/dds-solid/src/__tests__/dialog-browser.test.mjs new file mode 100644 index 0000000..350fc90 --- /dev/null +++ b/packages/dds-solid/src/__tests__/dialog-browser.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { createServer } from 'vite'; +import solid from 'vite-plugin-solid'; +import { chromium } from '@playwright/test'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const fixtureId = join(root, 'src/__tests__/dialog-focus-browser.tsx').replaceAll('\\', '/'); +const fixture = ` + import { createSignal, Show } from 'solid-js'; + import { render } from 'solid-js/web'; + import { Dialog } from '../dialog'; + const [open, setOpen] = createSignal(false); + const [busy, setBusy] = createSignal(false); + const [retry, setRetry] = createSignal(false); + let field: HTMLInputElement; let fallback: HTMLButtonElement; + Object.assign(window, { addRetry: () => setRetry(true), setDialogBusy: setBusy }); + render(() => <> + +

Background work

+ +
+ field} finalFocus={() => fallback} + additionalFocusContainers={() => [document.getElementById('retry-region')]} + onEscapeKeyDown={event => { if (busy()) event.preventDefault(); }} + frame={parts => <>
{parts.title}
{parts.children}
{parts.actions}
} + actions={}> + +
+ , document.getElementById('app')!); +`; +let server; +let browser; +try { + server = await createServer({ root, configFile: false, plugins: [ + { name: 'dialog-test-fixture', + resolveId(id) { if (id === '/src/__tests__/dialog-focus-browser.tsx' || id.replaceAll('\\', '/') === fixtureId) return fixtureId; }, + load(id) { if (id === fixtureId) return fixture; }, + configureServer(vite) { + vite.middlewares.use((request, response, next) => { + if (request.url !== '/') return next(); + response.setHeader('Content-Type', 'text/html'); + response.end('
'); + }); + }, + }, + solid({ hot: false }), + ], server: { host: '127.0.0.1', port: 0 } }); + await server.listen(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const errors = []; page.on('pageerror', error => { errors.push(error.message); console.error(error.message); }); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}`); + await page.getByRole('button', { name: 'Open task' }).click(); + const dialog = page.getByRole('alertdialog', { name: 'Scoped task' }); + await dialog.waitFor(); + await page.waitForFunction(() => document.activeElement?.getAttribute('aria-label') === 'Task field'); + assert.equal(await page.locator('#background').evaluate(element => Boolean(element.closest('[aria-hidden="true"]'))), true); + assert.equal(await page.locator('#retry-region').evaluate(element => Boolean(element.closest('[aria-hidden="true"]'))), false); + await page.evaluate(() => window.addRetry()); + await page.locator('#last').focus(); await page.keyboard.press('Tab'); + assert.equal(await page.locator('#retry').evaluate(element => element === document.activeElement), true); + await page.keyboard.press('Tab'); + assert.equal(await page.getByRole('textbox', { name: 'Task field' }).evaluate(element => element === document.activeElement), true); + await page.keyboard.press('Shift+Tab'); + assert.equal(await page.locator('#retry').evaluate(element => element === document.activeElement), true); + await page.getByRole('button', { name: 'Retry failed task' }).click(); + await page.locator('#retry').waitFor({ state: 'detached' }); + assert.equal(await dialog.isVisible(), true); + await page.evaluate(() => window.setDialogBusy(true)); await page.keyboard.press('Escape'); + assert.equal(await dialog.isVisible(), true); + await page.evaluate(() => window.setDialogBusy(false)); await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + await page.waitForFunction(() => document.activeElement?.id === 'fallback'); + assert.equal(await page.locator('#background').evaluate(element => Boolean(element.closest('[aria-hidden="true"]'))), false); + assert.deepEqual(errors, []); + console.log('DDS dialog real-browser portal/frame, initial/final focus, dynamic retry Tab cycle, external ARIA scope and busy Escape passed'); +} finally { + await Promise.allSettled([browser?.close(), server?.close()]); +} diff --git a/packages/dds-solid/src/__tests__/primitives.test.tsx b/packages/dds-solid/src/__tests__/primitives.test.tsx index 5b04bd7..03b162e 100644 --- a/packages/dds-solid/src/__tests__/primitives.test.tsx +++ b/packages/dds-solid/src/__tests__/primitives.test.tsx @@ -13,6 +13,45 @@ let dispose: (() => void) | undefined; afterEach(() => { vi.useRealTimers(); dispose?.(); dispose = undefined; document.body.replaceChildren(); }); describe("native control behavior", () => { + it("composes the tab root onto the consumer layout without an extra wrapper", () => { + const host = document.body.appendChild(document.createElement("div")); + dispose = render(() =>
}> +
APage
+ , host); + expect(host.children).toHaveLength(1); + expect(host.firstElementChild?.className).toBe("consumer-layout"); + expect(host.querySelector(".consumer-layout > main")).not.toBeNull(); + expect(host.querySelector('[role="tab"]')?.getAttribute("aria-selected")).toBe("true"); + }); + it("supports manual tabs, consumer IDs and custom native triggers without changing selection on arrow focus", async () => { + const host = document.body.appendChild(document.createElement("div")); + const changed = vi.fn(); + const clicked = vi.fn(); + let custom!: HTMLButtonElement; + dispose = render(() => `consumer-tab-${value}`} panelId={value => `consumer-panel-${value}`}> + A } /> + FirstSecond + , host); + const first = host.querySelector('#consumer-tab-a')!; + first.focus(); + first.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + await vi.waitFor(() => expect(document.activeElement).toBe(custom)); + expect(changed).not.toHaveBeenCalled(); + expect(first.getAttribute("aria-selected")).toBe("true"); + expect(host.querySelector('#consumer-panel-a')!.style.display).toBe("flex"); + expect(host.querySelector('#consumer-panel-b')!.style.display).toBe("none"); + expect(custom.id).toBe("consumer-tab-b"); + expect(custom.classList.contains("dds-tab")).toBe(false); + expect(host.querySelector('[role="tablist"]')?.classList.contains("dds-tabs")).toBe(false); + custom.click(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledWith("b")); + expect(custom.getAttribute("aria-controls")).toBe("consumer-panel-b"); + await vi.waitFor(() => expect(host.querySelector('#consumer-panel-a')!.style.display).toBe("none")); + expect(host.querySelector('#consumer-panel-b')!.style.display).toBe("grid"); + expect(clicked).toHaveBeenCalledOnce(); + }); it("preserves checkable consumer ARIA, focus handlers and click bubbling", async () => { const host = document.body.appendChild(document.createElement("div")); const clicked = vi.fn(); @@ -187,6 +226,55 @@ describe("native control behavior", () => { }); describe("keyboard lifecycle", () => { + it("keeps an authorized external retry region in the dialog focus and accessibility scope", async () => { + const rectangles = vi.spyOn(HTMLElement.prototype, "getClientRects").mockImplementation(() => [new DOMRect(0, 0, 10, 10)] as unknown as DOMRectList); + const background = document.body.appendChild(document.createElement("div")); + background.textContent = "Background"; + const opener = background.appendChild(document.createElement("button")); opener.textContent = "Open"; opener.focus(); + const region = document.body.appendChild(document.createElement("div")); + const retry = region.appendChild(document.createElement("button")); retry.textContent = "Retry"; + const host = document.body.appendChild(document.createElement("div")); + dispose = render(() => [region]}>, host); + const buttons = [...host.querySelectorAll("button")]; + await vi.waitFor(() => expect(document.activeElement).toBe(buttons[0])); + await vi.waitFor(() => expect(background.getAttribute("aria-hidden")).toBe("true")); + expect(region.closest('[aria-hidden="true"]')).toBeNull(); + buttons[1]!.focus(); buttons[1]!.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(document.activeElement).toBe(retry); + retry.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(document.activeElement).toBe(buttons[0]); + buttons[0]!.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })); + expect(document.activeElement).toBe(retry); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await vi.waitFor(() => expect(document.activeElement).toBe(opener)); + expect(background.getAttribute("aria-hidden")).toBeNull(); + rectangles.mockRestore(); + }); + + it("supports a portalled custom dialog frame, focus targets and cancellable Escape", async () => { + const rectangles = vi.spyOn(HTMLElement.prototype, "getClientRects").mockImplementation(() => [new DOMRect(0, 0, 10, 10)] as unknown as DOMRectList); + const host = document.body.appendChild(document.createElement("div")); + const destination = document.body.appendChild(document.createElement("div")); + const final = document.body.appendChild(document.createElement("button")); final.textContent = "Fallback"; + let initial!: HTMLInputElement; + const escape = vi.fn((event: KeyboardEvent) => event.preventDefault()); + dispose = render(() => initial} finalFocus={() => final} onEscapeKeyDown={escape} + unstyled class="custom-panel" overlayClass="custom-overlay" titleClass="custom-title" + frame={(parts) => <>
{parts.title}
{parts.children}
}> + +
, host); + await vi.waitFor(() => expect(document.activeElement).toBe(initial)); + expect(host.querySelector('[role="alertdialog"]')).toBeNull(); + expect(destination.querySelector('.custom-panel[role="alertdialog"]')).not.toBeNull(); + expect(destination.querySelector("header .custom-title")?.textContent).toBe("Custom task"); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await vi.waitFor(() => expect(escape).toHaveBeenCalledTimes(1)); + expect(destination.querySelector('[role="alertdialog"]')).not.toBeNull(); + dispose?.(); dispose = undefined; + await vi.waitFor(() => expect(document.activeElement).toBe(final)); + rectangles.mockRestore(); + }); it("inherits RTL tab direction without requiring an Ark provider", async () => { const host = document.body.appendChild(document.createElement("div")); host.dir = "rtl"; diff --git a/packages/dds-solid/src/__tests__/ssr.test.tsx b/packages/dds-solid/src/__tests__/ssr.test.tsx index 41b48c2..89df0f6 100644 --- a/packages/dds-solid/src/__tests__/ssr.test.tsx +++ b/packages/dds-solid/src/__tests__/ssr.test.tsx @@ -26,6 +26,14 @@ it("server-renders every public primitive", () => { expect(html).toContain("aria-label=\"Complete\""); }); +it("does not evaluate browser-only focus targets or additional dialog containers during SSR", () => { + const browserOnly = () => { throw new Error("Browser-only focus getter ran on the server"); }; + const html = renderToString(() => ); + expect(html).toContain("Scoped SSR"); +}); + it("server-renders ConsoleShell without the client-only hydration claim", () => { const nav = [{ label: "Build", items: [{ id: "projects", href: "/dashboard/projects", label: "Projects" }] }]; const labels = { skip: "Skip to content", menuOpen: "Open menu", menuClose: "Close menu", nav: "Dashboard navigation", badge: "{count} pending" }; diff --git a/packages/dds-solid/src/dialog.tsx b/packages/dds-solid/src/dialog.tsx index 429f059..953d2f3 100644 --- a/packages/dds-solid/src/dialog.tsx +++ b/packages/dds-solid/src/dialog.tsx @@ -1,5 +1,8 @@ -import { Dialog as ArkDialog } from "@ark-ui/solid/dialog"; -import { createUniqueId, type JSX } from "solid-js"; +import { Dialog as ArkDialog, useDialog } from "@ark-ui/solid/dialog"; +import { FocusTrap } from "@zag-js/focus-trap"; +import { ariaHidden } from "@zag-js/aria-hidden"; +import { createEffect, createMemo, createSignal, createUniqueId, onCleanup, Show, type JSX } from "solid-js"; +import { isServer, Portal } from "solid-js/web"; import { classes } from "./utils"; export const focusable = [ @@ -19,26 +22,103 @@ export interface DialogProps { closeOnOutside?: boolean; class?: string; id?: string; + role?: "dialog" | "alertdialog"; + portal?: boolean; + portalMount?: HTMLElement; + unstyled?: boolean; + overlayClass?: string; + titleClass?: string; + descriptionClass?: string; + actionsClass?: string; + contentProps?: Omit, "children" | "class" | "id" | "role" | "ref">; + initialFocus?: () => HTMLElement | null | undefined; + finalFocus?: () => HTMLElement | null | undefined; + restoreFocus?: boolean; + onEscapeKeyDown?: (event: KeyboardEvent) => void; + /** Authorized external regions join both the Tab cycle and modal accessibility scope. */ + additionalFocusContainers?: () => readonly (HTMLElement | null | undefined)[]; + /** Customize the inner layout using DDS-owned, already labelled title/description nodes. */ + frame?: (parts: DialogFrameParts) => JSX.Element; +} + +export interface DialogFrameParts { + title: JSX.Element; + description: JSX.Element; + children: JSX.Element; + actions: JSX.Element; } export function Dialog(props: DialogProps) { const generated = createUniqueId(); const id = () => props.id ?? `dds-dialog-${generated}`; - return props.onOpenChange?.(details.open)} - closeOnEscape={props.closeOnEscape ?? true} - closeOnInteractOutside={props.closeOnOutside ?? true} - modal trapFocus preventScroll lazyMount unmountOnExit - ids={{ content: id(), title: `${id()}-title`, description: `${id()}-description` }} - > - - - {props.title} - {props.description && {props.description}} - {props.children} - {props.actions &&
{props.actions}
} -
-
-
; + const [panel, setPanel] = createSignal(); + const api = useDialog(() => ({ + id: id(), open: props.open, defaultOpen: props.defaultOpen, + onOpenChange: (details) => props.onOpenChange?.(details.open), + closeOnEscape: props.closeOnEscape ?? true, + closeOnInteractOutside: props.closeOnOutside ?? true, + onEscapeKeyDown: props.onEscapeKeyDown, + initialFocusEl: () => props.initialFocus?.() ?? null, + finalFocusEl: () => props.finalFocus?.() ?? null, + restoreFocus: props.restoreFocus ?? true, + // Ark handles ordinary dialogs. A multi-container modal needs a shared + // Zag trap and isolation target list instead of Ark's single content node. + modal: !props.additionalFocusContainers, + trapFocus: !props.additionalFocusContainers, + preventScroll: true, + role: props.role ?? "dialog", + persistentElements: isServer ? [] : props.additionalFocusContainers?.().filter((element): element is HTMLElement => Boolean(element)).map(element => () => element), + ids: { content: id(), title: `${id()}-title`, description: `${id()}-description` }, + })); + const open = createMemo(() => api().open); + createEffect(() => { + const content = panel(); + if (!open() || !props.additionalFocusContainers || !content) return; + const opener = content.ownerDocument.activeElement; + let trap: FocusTrap | undefined; + let removeIsolation: (() => void) | undefined; + const [ready, setReady] = createSignal(false); + const containers = () => [content, ...props.additionalFocusContainers!().filter((element): element is HTMLElement => Boolean(element?.isConnected && element.ownerDocument === content.ownerDocument))]; + const frame = content.ownerDocument.defaultView!.requestAnimationFrame(() => { + trap = new FocusTrap(containers(), { + document: content.ownerDocument, + escapeDeactivates: false, allowOutsideClick: true, preventScroll: true, + delayInitialFocus: false, fallbackFocus: content, getShadowRoot: true, + initialFocus: () => props.initialFocus?.() ?? undefined, + setReturnFocus: () => props.finalFocus?.() ?? (opener instanceof HTMLElement && opener.isConnected ? opener : false), + }); + setReady(true); + trap.activate(); + }); + createEffect(() => { + if (!ready()) return; + const targets = containers(); + trap!.updateContainerElements(targets); + removeIsolation?.(); + removeIsolation = ariaHidden(targets, { defer: false }); + }); + onCleanup(() => { + content.ownerDocument.defaultView!.cancelAnimationFrame(frame); + removeIsolation?.(); + trap?.deactivate({ returnFocus: props.restoreFocus !== false }); + }); + }); + const cls = (base: string, extra?: string) => classes(props.unstyled ? undefined : base, extra); + const Content = () => + + {(() => { + const parts: DialogFrameParts = { + get title() { return {props.title}; }, + get description() { return props.description && {props.description}; }, + get children() { return props.children; }, + get actions() { return props.actions &&
{props.actions}
; }, + }; + return props.frame ? props.frame(parts) : <>{parts.title}{parts.description}{parts.children}{parts.actions}; + })()} +
+
; + return + }> + ; } diff --git a/packages/dds-solid/src/index.ts b/packages/dds-solid/src/index.ts index 5908cf6..1bf6f93 100644 --- a/packages/dds-solid/src/index.ts +++ b/packages/dds-solid/src/index.ts @@ -19,7 +19,7 @@ export { type ControlSize, } from "./controls"; export { ConsoleShell, type ConsoleHeader, type ConsoleNavGroup, type ConsoleNavItem, type ConsoleShellLabels, type ConsoleShellProps } from "./console-shell"; -export { Dialog, type DialogProps } from "./dialog"; +export { Dialog, type DialogProps, type DialogFrameParts } from "./dialog"; export { RadioGroup, type RadioGroupProps, type RadioOption } from "./radio-group"; export { Icon, type IconProps } from "./icon"; export { createStatusPill } from "./status-pill"; diff --git a/packages/dds-solid/src/tabs.tsx b/packages/dds-solid/src/tabs.tsx index ae65dad..3cabd31 100644 --- a/packages/dds-solid/src/tabs.tsx +++ b/packages/dds-solid/src/tabs.tsx @@ -1,6 +1,6 @@ -import { Tabs as ArkTabs } from "@ark-ui/solid/tabs"; +import { Tabs as ArkTabs, useTabsContext } from "@ark-ui/solid/tabs"; import { LocaleProvider } from "@ark-ui/solid/locale"; -import { createUniqueId, splitProps, type JSX, type ParentProps } from "solid-js"; +import { createUniqueId, mergeProps, splitProps, type JSX, type ParentProps } from "solid-js"; import { classes } from "./utils"; import { useDirection, type Direction } from "./direction"; @@ -12,6 +12,11 @@ export interface TabsProps { children: JSX.Element; id?: string; dir?: Direction; + activationMode?: "automatic" | "manual"; + tabId?: (value: string) => string; + panelId?: (value: string) => string; + unstyled?: boolean; + asChild?: (props: JSX.HTMLAttributes) => JSX.Element; } export function Tabs(props: TabsProps) { @@ -19,33 +24,50 @@ export function Tabs(props: TabsProps) { const id = () => props.id ?? `dds-tabs-${generated}`; let root: HTMLDivElement | undefined; const direction = useDirection(() => root, () => props.dir); + const child = props.asChild ? (merge: unknown) => props.asChild!(mergeProps( + (merge as (props: JSX.HTMLAttributes) => JSX.HTMLAttributes)({}), + { ref: (element: HTMLDivElement) => { root = element; } }, + )) : undefined; return props.onValueChange?.(details.value)} - lazyMount={false} unmountOnExit={false} class="dds-tabs-root" - ids={{ trigger: (value) => `${id()}-tab-${value}`, content: (value) => `${id()}-panel-${value}` }} + lazyMount={false} unmountOnExit={false} class={props.unstyled ? undefined : "dds-tabs-root"} + {...(child ? { asChild: child } : {})} + ids={{ trigger: (value) => props.tabId?.(value) ?? `${id()}-tab-${value}`, content: (value) => props.panelId?.(value) ?? `${id()}-panel-${value}` }} >{props.children}; } -export function TabList(props: ParentProps>) { - const [local, rest] = splitProps(props, ["class", "children"]); - return {local.children}; +export function TabList(props: ParentProps & { unstyled?: boolean }>) { + const [local, rest] = splitProps(props, ["class", "children", "unstyled"]); + return {local.children}; } export interface TabProps extends Omit, "value" | "class"> { value: string; class?: string; + unstyled?: boolean; + asChild?: (props: JSX.ButtonHTMLAttributes) => JSX.Element; } export function Tab(props: ParentProps) { - const [local, rest] = splitProps(props, ["class", "children", "value"]); - return {local.children}; + const [local, rest] = splitProps(props, ["class", "children", "value", "unstyled", "asChild"]); + // Ark's factory supplies a merger; DDS exposes native button attributes. + const child = local.asChild ? (merge: unknown) => local.asChild!(mergeProps( + (merge as (props: JSX.ButtonHTMLAttributes) => JSX.ButtonHTMLAttributes)({}), + { ref: props.ref }, + )) : undefined; + return {local.children}; } export interface TabPanelProps extends JSX.HTMLAttributes { value: string } export function TabPanel(props: ParentProps) { - const [local, rest] = splitProps(props, ["value", "children"]); - return {local.children}; + const [local, rest] = splitProps(props, ["value", "children", "style"]); + const tabs = useTabsContext(); + const inactive = () => tabs().value !== local.value; + const style = () => !inactive() ? local.style : typeof local.style === "string" + ? `${local.style.replace(/;+\s*$/, "")};display:none;` + : { ...local.style, display: "none" }; + return ; } diff --git a/packages/dds-solid/vite.config.ts b/packages/dds-solid/vite.config.ts index a135277..80ef66a 100644 --- a/packages/dds-solid/vite.config.ts +++ b/packages/dds-solid/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" }, sourcemap: true, rollupOptions: { - external: [/^solid-js(?:\/|$)/, /^@solidjs\/web(?:\/|$)/, /^@devslab\//, /^@ark-ui\//], + external: [/^solid-js(?:\/|$)/, /^@solidjs\/web(?:\/|$)/, /^@devslab\//, /^@ark-ui\//, /^@zag-js\//], }, }, }); diff --git a/packages/dds-solid/vite.server.config.ts b/packages/dds-solid/vite.server.config.ts index e899748..25678e8 100644 --- a/packages/dds-solid/vite.server.config.ts +++ b/packages/dds-solid/vite.server.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ emptyOutDir: false, sourcemap: true, rollupOptions: { - external: [/^solid-js(?:\/|$)/, /^@devslab\//, /^@ark-ui\//], + external: [/^solid-js(?:\/|$)/, /^@devslab\//, /^@ark-ui\//, /^@zag-js\//], output: { entryFileNames: "server.js" }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d0dfb8..9824576 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,12 @@ importers: '@devslab/dds-tokens': specifier: workspace:0.17.1 version: link:../dds-tokens + '@zag-js/aria-hidden': + specifier: 1.45.0 + version: 1.45.0 + '@zag-js/focus-trap': + specifier: 1.45.0 + version: 1.45.0 devDependencies: '@testing-library/jest-dom': specifier: 6.9.1 diff --git a/tests/dds-solid-contracts.test.mjs b/tests/dds-solid-contracts.test.mjs index 0680c60..f1f8e23 100644 --- a/tests/dds-solid-contracts.test.mjs +++ b/tests/dds-solid-contracts.test.mjs @@ -66,10 +66,14 @@ test("Dialog and Tabs delegate keyboard and focus lifecycle to Ark through DDS c const tabs = await read("packages/dds-solid/src/tabs.tsx"); assert.match(dialog, /Escape/); assert.match(dialog, /@ark-ui\/solid\/dialog/); - assert.match(dialog, /modal trapFocus preventScroll/); + assert.match(dialog, /modal: !props.additionalFocusContainers/); + assert.match(dialog, /trapFocus: !props.additionalFocusContainers/); + assert.match(dialog, /preventScroll: true/); + assert.match(dialog, /@zag-js\/focus-trap/); + assert.match(dialog, /@zag-js\/aria-hidden/); assert.match(dialog, /details.open/); assert.match(tabs, /@ark-ui\/solid\/tabs/); - assert.match(tabs, /activationMode="automatic"/); + assert.match(tabs, /activationMode=\{props.activationMode \?\? "automatic"\}/); assert.match(tabs, /lazyMount=\{false\} unmountOnExit=\{false\}/); assert.match(tabs, /details.value/); }); From 132d9dbf9873e8e48e290d8fbb8762107af70677 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 8 Oct 2026 15:32:44 +0900 Subject: [PATCH 3/3] fix: preserve native dialog composition and validate Ark consumers --- .changeset/dds-internal-ark.md | 2 ++ docs/dialog-focus.ko.md | 3 ++- docs/dialog-focus.md | 3 ++- .../src/__tests__/dialog-browser.test.mjs | 25 ++++++++++++++---- .../src/__tests__/primitives.test.tsx | 15 +++++++++++ packages/dds-solid/src/__tests__/ssr.test.tsx | 7 +++++ packages/dds-solid/src/dialog.tsx | 23 +++++++++++++--- .../src/solid/__tests__/consent.test.tsx | 15 ++++++++--- packages/site-kit/vitest.config.ts | 3 +++ packages/site-kit/vitest.ssr.config.ts | 2 ++ scripts/fixtures/solid-ark-consumer.mjs | 7 +++++ scripts/verify-solid-release.mjs | 26 +++++++++++++++---- 12 files changed, 111 insertions(+), 20 deletions(-) diff --git a/.changeset/dds-internal-ark.md b/.changeset/dds-internal-ark.md index 14cd0c8..ceb3ce5 100644 --- a/.changeset/dds-internal-ark.md +++ b/.changeset/dds-internal-ark.md @@ -5,3 +5,5 @@ Use Ark UI internally for accessible interactions while preserving DDS public APIs and visual tokens. Add a simple RadioGroup, retained tab panels with reactive disabled navigation, toast interaction pausing, and native form reset compatibility. Acknowledge Ark UI on DDS pages. Add optional manual tab activation, consumer element IDs, unstyled native composition, and a DDS-owned dialog frame/portal API. Authorized external focus containers participate in the modal's focus cycle and accessibility isolation, preserving retry actions without exposing Ark APIs. + +Allow native section dialog content while preserving dialog attributes, presence, and focus behavior for existing consumer markup contracts. diff --git a/docs/dialog-focus.ko.md b/docs/dialog-focus.ko.md index e44c709..2e16e27 100644 --- a/docs/dialog-focus.ko.md +++ b/docs/dialog-focus.ko.md @@ -4,6 +4,7 @@ - `portal`은 `document.body` 아래에 렌더링하며 `portalMount`로 다른 마운트된 요소를 지정할 수 있다. 기본값은 기존 인라인 렌더링이다. - `role`은 `dialog` 또는 `alertdialog`를 받는다. +- `contentAs`로 네이티브 `div`(기본값) 또는 `section`을 선택한다. 생성된 모달 속성, presence 수명과 포커스 등록은 그대로 유지한다. - `unstyled`는 DDS 컴포넌트 클래스를 제거한다. `class`, `overlayClass`, `titleClass`, `descriptionClass`, `actionsClass`로 앱 클래스를 지정한다. - `frame(parts)`는 제공된 `title`, `description`, `children`, `actions` 노드를 배치한다. 제목·설명 노드를 각각 한 번 렌더링하면 자동 접근성 연결을 유지한다. DDS가 제공한 노드이므로 앱에서 Ark 컴포넌트를 import할 필요가 없다. - `contentProps`로 `aria-busy`, `onKeyDown` 같은 네이티브 콘텐츠 속성·이벤트를 전달한다. @@ -34,4 +35,4 @@ 브라우저 전용 포커스·컨테이너 getter는 SSR에서 실행하지 않는다. Portal 콘텐츠는 클라이언트에서 마운트된다. 일반 모달은 Ark의 포커스·접근성 격리를 유지하고, 외부 영역을 허용한 모달은 Zag의 다중 컨테이너 트랩과 공유 격리 처리를 사용한다. -DOM 테스트는 Tab/Shift+Tab 순환, 배경 격리, 포커스 복귀, 사용자 프레임·Portal, Escape 취소를 검증한다. SSR 테스트는 실행 시 예외를 내는 브라우저 전용 getter로 서버 실행 여부를 확인한다. 실제 브라우저 회귀 검증은 `node packages/dds-solid/src/__tests__/dialog-browser.test.mjs`로 실행한다. 모달이 열린 뒤 재시도 버튼을 추가하고, 실제 포커스 이동·허용 영역 클릭·작업 중 Escape·닫기 후 격리 정리를 확인한다. +DOM 테스트는 네이티브 section의 presence·닫기, Tab/Shift+Tab 순환, 배경 격리, 포커스 복귀, 사용자 프레임·Portal, Escape 취소를 검증한다. SSR 테스트는 section 렌더링과 실행 시 예외를 내는 브라우저 전용 getter로 서버 실행 여부를 확인한다. 실제 브라우저 회귀 검증은 `node packages/dds-solid/src/__tests__/dialog-browser.test.mjs`로 실행한다. section 모달이 열린 뒤 재시도 버튼과 반응형 외부 팝업 영역을 추가하고 최초 포커스를 다시 설정하지 않는지, 실제 포커스 이동·허용 영역 클릭·작업 중 Escape·닫기 후 격리 정리를 확인한다. diff --git a/docs/dialog-focus.md b/docs/dialog-focus.md index e4ac40f..dad7598 100644 --- a/docs/dialog-focus.md +++ b/docs/dialog-focus.md @@ -4,6 +4,7 @@ - `portal` renders under `document.body`; `portalMount` chooses another mounted element. The default remains inline rendering. - `role` accepts `dialog` or `alertdialog`. +- `contentAs` selects the native `div` (default) or `section` while keeping the generated dialog attributes, presence lifecycle, and focus registration. - `unstyled` removes DDS component classes. `class`, `overlayClass`, `titleClass`, `descriptionClass`, and `actionsClass` add application classes. - `frame(parts)` arranges the supplied `title`, `description`, `children`, and `actions` nodes. Render each supplied title/description once to retain its generated accessible association. These are DDS-owned nodes; applications do not import Ark components. - `contentProps` supplies native content attributes and handlers, such as `aria-busy` and `onKeyDown`. @@ -34,4 +35,4 @@ Authorize only the intended regions in the same document. The getter can read re Browser-only focus/container getters are not evaluated during SSR. The portalled content mounts on the client. Normal dialogs retain Ark's focus and isolation behavior; external-scope dialogs reuse Zag's multi-container trap and shared modal isolation bookkeeping. -Validation: DOM tests cover the combined Tab/Shift+Tab cycle, background isolation, restoration, custom frame/portal and Escape cancellation. An SSR test uses throwing browser-only getters. Run the real-browser regression with `node packages/dds-solid/src/__tests__/dialog-browser.test.mjs`; it additionally adds retry controls after opening and checks real focus, authorized clicks, busy Escape and isolation cleanup. +Validation: DOM tests cover native section presence/dismissal, the combined Tab/Shift+Tab cycle, background isolation, restoration, custom frame/portal and Escape cancellation. SSR tests cover native sections and throwing browser-only getters. Run the real-browser regression with `node packages/dds-solid/src/__tests__/dialog-browser.test.mjs`; it additionally mounts a section, adds retry controls and a reactive external popup region after opening without resetting focus, and checks real focus, authorized clicks, busy Escape and isolation cleanup. diff --git a/packages/dds-solid/src/__tests__/dialog-browser.test.mjs b/packages/dds-solid/src/__tests__/dialog-browser.test.mjs index 350fc90..89ef1e7 100644 --- a/packages/dds-solid/src/__tests__/dialog-browser.test.mjs +++ b/packages/dds-solid/src/__tests__/dialog-browser.test.mjs @@ -14,19 +14,21 @@ const fixture = ` const [open, setOpen] = createSignal(false); const [busy, setBusy] = createSignal(false); const [retry, setRetry] = createSignal(false); + const [popup, setPopup] = createSignal(false); let field: HTMLInputElement; let fallback: HTMLButtonElement; - Object.assign(window, { addRetry: () => setRetry(true), setDialogBusy: setBusy }); + Object.assign(window, { addRetry: () => setRetry(true), setDialogBusy: setBusy, setPopup }); render(() => <>

Background work

+ field} finalFocus={() => fallback} - additionalFocusContainers={() => [document.getElementById('retry-region')]} + additionalFocusContainers={() => [document.getElementById('retry-region'), popup() ? document.getElementById('popup-region') : null]} onEscapeKeyDown={event => { if (busy()) event.preventDefault(); }} - frame={parts => <>
{parts.title}
{parts.children}
{parts.actions}
} + frame={parts => <>
{parts.title}
{parts.children}
{parts.actions}
} actions={}>
@@ -57,6 +59,7 @@ try { await page.getByRole('button', { name: 'Open task' }).click(); const dialog = page.getByRole('alertdialog', { name: 'Scoped task' }); await dialog.waitFor(); + assert.equal(await dialog.evaluate(element => element.tagName), 'SECTION'); await page.waitForFunction(() => document.activeElement?.getAttribute('aria-label') === 'Task field'); assert.equal(await page.locator('#background').evaluate(element => Boolean(element.closest('[aria-hidden="true"]'))), true); assert.equal(await page.locator('#retry-region').evaluate(element => Boolean(element.closest('[aria-hidden="true"]'))), false); @@ -64,12 +67,24 @@ try { await page.locator('#last').focus(); await page.keyboard.press('Tab'); assert.equal(await page.locator('#retry').evaluate(element => element === document.activeElement), true); await page.keyboard.press('Tab'); - assert.equal(await page.getByRole('textbox', { name: 'Task field' }).evaluate(element => element === document.activeElement), true); + assert.equal(await page.locator('#header-close').evaluate(element => element === document.activeElement), true); await page.keyboard.press('Shift+Tab'); assert.equal(await page.locator('#retry').evaluate(element => element === document.activeElement), true); await page.getByRole('button', { name: 'Retry failed task' }).click(); await page.locator('#retry').waitFor({ state: 'detached' }); assert.equal(await dialog.isVisible(), true); + await page.evaluate(() => window.setPopup(true)); + await page.locator('#popup-action').waitFor(); + assert.equal(await page.getByRole('textbox', { name: 'Task field' }).evaluate(element => element === document.activeElement), true); + await page.locator('#popup-action').focus(); + assert.equal(await page.locator('#popup-action').evaluate(element => element === document.activeElement), true); + assert.equal(await page.locator('#popup-region').evaluate(element => Boolean(element.closest('[aria-hidden="true"]'))), false); + await page.keyboard.press('Tab'); + assert.equal(await page.locator('#header-close').evaluate(element => element === document.activeElement), true); + await page.evaluate(() => window.setPopup(false)); + await page.locator('#popup-region').waitFor({ state: 'detached' }); + assert.equal(await page.locator('#header-close').evaluate(element => element === document.activeElement), true); + assert.equal(await page.locator('#background').evaluate(element => Boolean(element.closest('[aria-hidden="true"]'))), true); await page.evaluate(() => window.setDialogBusy(true)); await page.keyboard.press('Escape'); assert.equal(await dialog.isVisible(), true); await page.evaluate(() => window.setDialogBusy(false)); await page.keyboard.press('Escape'); diff --git a/packages/dds-solid/src/__tests__/primitives.test.tsx b/packages/dds-solid/src/__tests__/primitives.test.tsx index 03b162e..c7d3dd2 100644 --- a/packages/dds-solid/src/__tests__/primitives.test.tsx +++ b/packages/dds-solid/src/__tests__/primitives.test.tsx @@ -365,6 +365,21 @@ describe("keyboard lifecycle", () => { }); }); +it("keeps native section dialog presence and ordinary Ark dismissal", async () => { + const host = document.body.appendChild(document.createElement("div")); + const [open, setOpen] = createSignal(false); + dispose = render(() => <> + + + , host); + host.querySelector("button")!.click(); + await vi.waitFor(() => expect(host.querySelector('section[role="dialog"]')?.getAttribute("aria-labelledby")).toBeTruthy()); + await new Promise(resolve => requestAnimationFrame(() => resolve(undefined))); + host.querySelector('section[role="dialog"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); + await vi.waitFor(() => expect(host.querySelector('[role="dialog"]')).toBeNull()); + expect(open()).toBe(false); +}); + describe("createStatusPill", () => { const pill = createStatusPill({ tones: { key: { active: "success", revoked: "danger" }, role: { owner: "brand" } }, diff --git a/packages/dds-solid/src/__tests__/ssr.test.tsx b/packages/dds-solid/src/__tests__/ssr.test.tsx index 89df0f6..96f1596 100644 --- a/packages/dds-solid/src/__tests__/ssr.test.tsx +++ b/packages/dds-solid/src/__tests__/ssr.test.tsx @@ -34,6 +34,13 @@ it("does not evaluate browser-only focus targets or additional dialog containers expect(html).toContain("Scoped SSR"); }); +it("server-renders a requested native section dialog with DDS labels", () => { + const native = { contentAs: "section" as const }; + const html = renderToString(() => ); + expect(html).toMatch(/]*role="dialog"[^>]*id="native-section"/); + expect(html).toContain('aria-labelledby="native-section-title"'); +}); + it("server-renders ConsoleShell without the client-only hydration claim", () => { const nav = [{ label: "Build", items: [{ id: "projects", href: "/dashboard/projects", label: "Projects" }] }]; const labels = { skip: "Skip to content", menuOpen: "Open menu", menuClose: "Close menu", nav: "Dashboard navigation", badge: "{count} pending" }; diff --git a/packages/dds-solid/src/dialog.tsx b/packages/dds-solid/src/dialog.tsx index 953d2f3..a2c93f8 100644 --- a/packages/dds-solid/src/dialog.tsx +++ b/packages/dds-solid/src/dialog.tsx @@ -1,8 +1,9 @@ import { Dialog as ArkDialog, useDialog } from "@ark-ui/solid/dialog"; +import { usePresenceContext } from "@ark-ui/solid/presence"; import { FocusTrap } from "@zag-js/focus-trap"; import { ariaHidden } from "@zag-js/aria-hidden"; import { createEffect, createMemo, createSignal, createUniqueId, onCleanup, Show, type JSX } from "solid-js"; -import { isServer, Portal } from "solid-js/web"; +import { Dynamic, isServer, Portal } from "solid-js/web"; import { classes } from "./utils"; export const focusable = [ @@ -23,6 +24,7 @@ export interface DialogProps { class?: string; id?: string; role?: "dialog" | "alertdialog"; + contentAs?: "div" | "section"; portal?: boolean; portalMount?: HTMLElement; unstyled?: boolean; @@ -30,7 +32,7 @@ export interface DialogProps { titleClass?: string; descriptionClass?: string; actionsClass?: string; - contentProps?: Omit, "children" | "class" | "id" | "role" | "ref">; + contentProps?: Omit, "children" | "class" | "id" | "role" | "ref">; initialFocus?: () => HTMLElement | null | undefined; finalFocus?: () => HTMLElement | null | undefined; restoreFocus?: boolean; @@ -51,7 +53,7 @@ export interface DialogFrameParts { export function Dialog(props: DialogProps) { const generated = createUniqueId(); const id = () => props.id ?? `dds-dialog-${generated}`; - const [panel, setPanel] = createSignal(); + const [panel, setPanel] = createSignal(); const api = useDialog(() => ({ id: id(), open: props.open, defaultOpen: props.defaultOpen, onOpenChange: (details) => props.onOpenChange?.(details.open), @@ -104,8 +106,20 @@ export function Dialog(props: DialogProps) { }); }); const cls = (base: string, extra?: string) => classes(props.unstyled ? undefined : base, extra); - const Content = () => + const Content = () => { + const presence = usePresenceContext(); + const nativeContent = (merge: unknown) => ) => JSX.HTMLAttributes)({})} + ref={(element: HTMLElement) => { + // Ark's asChild merger omits refs. Register both its presence node and + // DDS's shared focus container on the actual native content element. + presence().ref(element); + setPanel(element); + }} + />; + return {(() => { const parts: DialogFrameParts = { @@ -118,6 +132,7 @@ export function Dialog(props: DialogProps) { })()} ; + }; return }> ; diff --git a/packages/site-kit/src/solid/__tests__/consent.test.tsx b/packages/site-kit/src/solid/__tests__/consent.test.tsx index 2fa87f8..7e61a43 100644 --- a/packages/site-kit/src/solid/__tests__/consent.test.tsx +++ b/packages/site-kit/src/solid/__tests__/consent.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment-options {"url": "https://example.test/ko/pricing?utm=1"} import axe from "axe-core"; import { render } from "solid-js/web"; -import { afterEach, beforeEach, expect, it } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { CONSENT_MESSAGES_KO, createConsentManager, type ConsentManager, type ConsentRecord } from "../../core/consent.mjs"; import { ConsentBanner, SiteFooter } from "../index"; @@ -123,9 +123,12 @@ it("the settings show necessary as information and analytics as an unticked swit expect(footer.map((element) => element.textContent)).toEqual(["취소", "선택 저장"]); expect(new Set(footer.map((element) => element.className)).size).toBe(1); toggle.click(); + // Separate browser click events have a microtask checkpoint; Zag applies + // the native checked-state event there before the user presses Save. + await flush(); button(panel, "선택 저장").click(); await flush(); - expect(dialog()).toBeNull(); + await vi.waitFor(() => expect(dialog()).toBeNull()); expect(bar()).toBeNull(); expect(records.map((record) => record.action)).toEqual(["grant"]); expect(googleScripts()).toHaveLength(1); @@ -145,6 +148,7 @@ it("the footer's 쿠키 설정 re-opens the settings to withdraw", async () => { const toggle = dialog()!.querySelector('input[role="switch"]')!; expect(toggle.checked).toBe(true); toggle.click(); + await flush(); button(dialog()!, "선택 저장").click(); await flush(); expect(records.map((record) => record.action)).toEqual(["withdraw"]); @@ -157,9 +161,11 @@ it("Escape closes the settings without saving", async () => { mount(); button(bar()!, "설정").click(); await flush(); + // Keyboard input begins after the modal's deferred initial focus is ready. + await vi.waitFor(() => expect(dialog()?.contains(document.activeElement)).toBe(true)); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); await flush(); - expect(dialog()).toBeNull(); + await vi.waitFor(() => expect(dialog()).toBeNull()); expect(bar()).not.toBeNull(); expect(records).toEqual([]); }); @@ -169,9 +175,10 @@ it("취소 closes the settings without saving, like Escape", async () => { button(bar()!, "설정").click(); await flush(); dialog()!.querySelector('input[role="switch"]')!.click(); + await flush(); button(dialog()!, "취소").click(); await flush(); - expect(dialog()).toBeNull(); + await vi.waitFor(() => expect(dialog()).toBeNull()); expect(bar()).not.toBeNull(); expect(records).toEqual([]); expect(document.cookie).not.toContain("site_consent"); diff --git a/packages/site-kit/vitest.config.ts b/packages/site-kit/vitest.config.ts index de6014d..969a1c6 100644 --- a/packages/site-kit/vitest.config.ts +++ b/packages/site-kit/vitest.config.ts @@ -10,6 +10,9 @@ export default defineConfig({ }, test: { environment: "jsdom", + // The DDS source alias reaches Ark's browser JSX; keep it in Vite's + // Solid transformation pipeline instead of handing .jsx to Node. + server: { deps: { inline: [/\/@ark-ui\/solid\//] } }, include: [ "src/solid/__tests__/a11y.test.tsx", "src/solid/__tests__/locale-menu.test.tsx", diff --git a/packages/site-kit/vitest.ssr.config.ts b/packages/site-kit/vitest.ssr.config.ts index abac723..aabe268 100644 --- a/packages/site-kit/vitest.ssr.config.ts +++ b/packages/site-kit/vitest.ssr.config.ts @@ -13,6 +13,8 @@ export default defineConfig({ test: { environment: "node", + // Transform the DDS/Ark JSX chain with the SSR Solid compiler. + server: { deps: { inline: [/\/@ark-ui\/solid\//, /\/@devslab\/dds-solid\//] } }, include: ["src/solid/__tests__/ssr.test.tsx", "src/solid/__tests__/tanstack-head.ssr.test.tsx"], setupFiles: [], }, diff --git a/scripts/fixtures/solid-ark-consumer.mjs b/scripts/fixtures/solid-ark-consumer.mjs index c75a692..cfa412e 100644 --- a/scripts/fixtures/solid-ark-consumer.mjs +++ b/scripts/fixtures/solid-ark-consumer.mjs @@ -3,6 +3,13 @@ import { Button, Checkbox, Dialog, Field, RadioGroup, Select, Switch, Tabs, TabL export const events = []; +export function NativeSectionApp() { + return createComponent(Dialog, { + defaultOpen: true, contentAs: "section", id: "native-section", title: "Native section", + get children() { return createComponent(Button, { children: "Section action" }); }, + }); +} + export function App() { const [open, setOpen] = createSignal(false); return createComponent(ToastProvider, { diff --git a/scripts/verify-solid-release.mjs b/scripts/verify-solid-release.mjs index 8942756..b53bb75 100644 --- a/scripts/verify-solid-release.mjs +++ b/scripts/verify-solid-release.mjs @@ -124,16 +124,19 @@ if (diagnostics.length) throw new Error('fresh consumer hydration diagnostics: ' await writeFile(join(temp, "ark-ssr.mjs"), ` import { generateHydrationScript, renderToString } from "solid-js/web"; import { createComponent } from "solid-js"; -import { App } from "./ark-app.mjs"; -process.stdout.write(JSON.stringify({ bootstrap: generateHydrationScript(), html: renderToString(() => createComponent(App, {})) })); +import { App, NativeSectionApp } from "./ark-app.mjs"; +const component = process.argv[2] === "section" ? NativeSectionApp : App; +process.stdout.write(JSON.stringify({ bootstrap: generateHydrationScript(), html: renderToString(() => createComponent(component, {})) })); `); await writeFile(join(temp, "ark-ssr.json"), runNode([join(temp, "ark-ssr.mjs")], temp)); + await writeFile(join(temp, "section-ssr.json"), runNode([join(temp, "ark-ssr.mjs"), "section"], temp)); await writeFile(join(temp, "ark-client-app.mjs"), (await readFile(join(temp, "ark-app.mjs"), "utf8")).replace('"@devslab/dds-solid"', JSON.stringify(browserEntry))); await writeFile(join(temp, "ark-hydrate.mjs"), ` import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { JSDOM } from "jsdom"; -const { bootstrap, html } = JSON.parse(await readFile(new URL("./ark-ssr.json", import.meta.url), "utf8")); +const sectionMode = process.argv[2] === "section"; +const { bootstrap, html } = JSON.parse(await readFile(new URL(sectionMode ? "./section-ssr.json" : "./ark-ssr.json", import.meta.url), "utf8")); const dom = new JSDOM('' + bootstrap + '
' + html + '
', { runScripts: "dangerously", pretendToBeVisual: true }); for (const key of ["window", "document", "Node", "Element", "HTMLElement", "HTMLInputElement", "HTMLFormElement", "SVGElement", "Document", "ShadowRoot", "MutationObserver", "navigator", "CustomEvent", "getComputedStyle", "requestAnimationFrame", "cancelAnimationFrame"]) { const value = dom.window[key]; @@ -142,14 +145,25 @@ for (const key of ["window", "document", "Node", "Element", "HTMLElement", "HTML Object.defineProperty(globalThis, "_$HY", { value: dom.window._$HY, configurable: true, writable: true }); const { hydrate } = await import("solid-js/web"); const { createComponent } = await import("solid-js"); -const { App, events } = await import("./ark-client-app.mjs"); +const { App, NativeSectionApp, events } = await import("./ark-client-app.mjs"); const host = document.querySelector("#root"); const input = host.querySelector('input[name="agree"]'); +const section = host.querySelector('section[role="dialog"]'); +const title = host.querySelector('#native-section-title'); +const action = section?.querySelector("button"); const diagnostics = []; const warn = console.warn; const error = console.error; console.warn = (...values) => diagnostics.push(values.join(" ")); console.error = (...values) => diagnostics.push(values.join(" ")); -const dispose = hydrate(() => createComponent(App, {}), host); +const dispose = hydrate(() => createComponent(sectionMode ? NativeSectionApp : App, {}), host); await new Promise(resolve => setTimeout(resolve, 40)); +if (sectionMode) { + assert.ok(section, "Native section was absent from server HTML"); + assert.equal(host.querySelector('section[role="dialog"]'), section, "Hydration replaced the native section"); + assert.equal(host.querySelector('#native-section-title'), title, "Hydration replaced the labelled title"); + assert.equal(section.querySelector("button"), action, "Hydration replaced the section action"); + assert.equal(section.id, "native-section"); + assert.equal(section.getAttribute("aria-labelledby"), "native-section-title"); +} else { assert.equal(host.querySelector('input[name="agree"]'), input, "Hydration replaced the server input"); assert.equal(input.checked, true); input.click(); @@ -165,11 +179,13 @@ assert.ok(host.querySelector('[role="dialog"]'), "Hydrated dialog did not open") [...host.querySelectorAll("button")].find(button => button.textContent === "Close confirmation").click(); await new Promise(resolve => setTimeout(resolve, 40)); assert.equal(host.querySelector('[role="dialog"]'), null); +} dispose(); console.warn = warn; console.error = error; assert.deepEqual(diagnostics, []); dom.window.close(); `); runNode(["--conditions=browser", join(temp, "ark-hydrate.mjs")], temp); + runNode(["--conditions=browser", join(temp, "ark-hydrate.mjs"), "section"], temp); console.log("dds-solid pack, publish dry-run, and fresh consumer import, SSR, and hydration passed"); } finally { await rm(temp, { recursive: true, force: true });