Skip to content

Commit 2c2aa0a

Browse files
author
Fabien MARIE-LOUISE
committed
feat(dialog): add createDialog
1 parent 82266a8 commit 2c2aa0a

26 files changed

+525
-11
lines changed

packages/dialog/CHANGELOG.md

Whitespace-only changes.

packages/dialog/LICENSE.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Solid Aria Working Group
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

packages/dialog/README.md

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<p>
2+
<img width="100%" src="https://assets.solidjs.com/banner?type=Aria&background=tiles&project=Dialog" alt="Solid Aria - Dialog">
3+
</p>
4+
5+
# @solid-aria/dialog
6+
7+
[![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg?style=for-the-badge&logo=pnpm)](https://pnpm.io/)
8+
[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/)
9+
[![size](https://img.shields.io/bundlephobia/minzip/@solid-aria/dialog?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-aria/dialog)
10+
[![version](https://img.shields.io/npm/v/@solid-aria/dialog?style=for-the-badge)](https://www.npmjs.com/package/@solid-aria/dialog)
11+
[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-aria#contribution-process)
12+
13+
Dialog is an overlay shown above other content in an application.
14+
15+
## Installation
16+
17+
```bash
18+
npm install @solid-aria/dialog
19+
# or
20+
yarn add @solid-aria/dialog
21+
# or
22+
pnpm add @solid-aria/dialog
23+
```
24+
25+
## Changelog
26+
27+
All notable changes are described in the [CHANGELOG.md](./CHANGELOG.md) file.

packages/dialog/dev/index.html

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="theme-color" content="#000000" />
7+
<title>Solid App</title>
8+
</head>
9+
<body>
10+
<noscript>You need to enable JavaScript to run this app.</noscript>
11+
<div id="root"></div>
12+
<script src="./index.tsx" type="module"></script>
13+
</body>
14+
</html>

packages/dialog/dev/index.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { render } from "solid-js/web";
2+
3+
function App() {
4+
return <div>Hello Solid Aria!</div>;
5+
}
6+
7+
render(() => <App />, document.getElementById("root") as HTMLDivElement);

packages/dialog/dev/vite.config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { viteConfig } from "../../../vite.config";
2+
3+
export default viteConfig;

packages/dialog/jest.config.cjs

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const baseJest = require("../../jest.config.cjs");
2+
3+
module.exports = {
4+
...baseJest
5+
};

packages/dialog/package.json

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"name": "@solid-aria/dialog",
3+
"version": "0.0.0",
4+
"private": false,
5+
"description": "Primitives for building accessible dialog component.",
6+
"keywords": [
7+
"solid",
8+
"aria",
9+
"headless",
10+
"design",
11+
"system",
12+
"components"
13+
],
14+
"homepage": "https://github.com/solidjs-community/solid-aria/tree/main/packages/dialog#readme",
15+
"bugs": {
16+
"url": "https://github.com/solidjs-community/solid-aria/issues"
17+
},
18+
"repository": {
19+
"type": "git",
20+
"url": "git+https://github.com/solidjs-community/solid-aria.git"
21+
},
22+
"license": "MIT",
23+
"author": "Fabien Marie-Louise <[email protected]>",
24+
"contributors": [],
25+
"sideEffects": false,
26+
"type": "module",
27+
"main": "dist/index.cjs",
28+
"module": "dist/index.js",
29+
"types": "dist/index.d.ts",
30+
"files": [
31+
"dist"
32+
],
33+
"scripts": {
34+
"build": "tsup",
35+
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
36+
"dev": "vite serve dev --host",
37+
"test": "jest --passWithNoTests",
38+
"test:watch": "jest --watch --passWithNoTests",
39+
"typecheck": "tsc --noEmit"
40+
},
41+
"dependencies": {
42+
"@solid-aria/focus": "workspace:^",
43+
"@solid-aria/types": "workspace:^",
44+
"@solid-aria/utils": "workspace:^"
45+
},
46+
"peerDependencies": {
47+
"@solid-primitives/utils": "^1.3.0",
48+
"solid-js": "^1.3.15"
49+
},
50+
"publishConfig": {
51+
"access": "public"
52+
},
53+
"primitive": {
54+
"name": "dialog",
55+
"stage": 0,
56+
"list": [],
57+
"category": ""
58+
}
59+
}

packages/dialog/src/createDialog.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { focusSafely } from "@solid-aria/focus";
2+
import { AriaLabelingProps, DOMElements, DOMProps } from "@solid-aria/types";
3+
import { createSlotId, filterDOMProps } from "@solid-aria/utils";
4+
import { Accessor, createEffect, createMemo, JSX, onCleanup } from "solid-js";
5+
6+
export interface AriaDialogProps extends DOMProps, AriaLabelingProps {
7+
/**
8+
* The accessibility role for the dialog.
9+
* @default 'dialog'
10+
*/
11+
role?: "dialog" | "alertdialog";
12+
}
13+
14+
export interface DialogAria<
15+
DialogElementType extends DOMElements,
16+
TitleElementType extends DOMElements
17+
> {
18+
/**
19+
* Props for the dialog container element.
20+
*/
21+
dialogProps: Accessor<JSX.IntrinsicElements[DialogElementType]>;
22+
23+
/**
24+
* Props for the dialog title element.
25+
*/
26+
titleProps: Accessor<JSX.IntrinsicElements[TitleElementType]>;
27+
}
28+
29+
/**
30+
* Provides the behavior and accessibility implementation for a dialog component.
31+
* A dialog is an overlay shown above other content in an application.
32+
*/
33+
export function createDialog<
34+
DialogElementType extends DOMElements = "div",
35+
TitleElementType extends DOMElements = "h3",
36+
RefElement extends HTMLElement = HTMLDivElement
37+
>(
38+
props: AriaDialogProps,
39+
ref: Accessor<RefElement | undefined>
40+
): DialogAria<DialogElementType, TitleElementType> {
41+
const defaultTitleId = createSlotId();
42+
43+
const titleId = createMemo(() => {
44+
return props["aria-label"] ? undefined : defaultTitleId();
45+
});
46+
47+
// Focus the dialog itself on mount, unless a child element is already focused.
48+
createEffect(() => {
49+
const dialogEl = ref();
50+
51+
if (dialogEl && !dialogEl.contains(document.activeElement)) {
52+
focusSafely(dialogEl);
53+
54+
// Safari on iOS does not move the VoiceOver cursor to the dialog
55+
// or announce that it has opened until it has rendered. A workaround
56+
// is to wait for half a second, then blur and re-focus the dialog.
57+
const timeoutId = setTimeout(() => {
58+
if (document.activeElement === dialogEl) {
59+
dialogEl.blur();
60+
focusSafely(dialogEl);
61+
}
62+
}, 500);
63+
64+
onCleanup(() => {
65+
clearTimeout(timeoutId);
66+
});
67+
}
68+
});
69+
70+
const domProps = createMemo(() => filterDOMProps(props, { labelable: true }));
71+
72+
// We do not use aria-modal due to a Safari bug which forces the first focusable element to be focused
73+
// on mount when inside an iframe, no matter which element we programmatically focus.
74+
// See https://bugs.webkit.org/show_bug.cgi?id=211934.
75+
// useModal sets aria-hidden on all elements outside the dialog, so the dialog will behave as a modal
76+
// even without aria-modal on the dialog itself.
77+
const dialogProps = createMemo(() => ({
78+
...domProps(),
79+
role: props.role ?? "dialog",
80+
tabIndex: -1,
81+
"aria-labelledby": props["aria-labelledby"] || titleId()
82+
}));
83+
84+
const titleProps = createMemo(() => ({
85+
id: titleId()
86+
}));
87+
88+
return { dialogProps, titleProps };
89+
}

packages/dialog/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./createDialog";
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { render, screen } from "solid-testing-library";
2+
3+
import { createDialog } from "../src";
4+
5+
function Example(props: any) {
6+
let ref: any;
7+
const { dialogProps } = createDialog(props, () => ref);
8+
9+
return (
10+
<div ref={ref} {...dialogProps()} data-testid="test">
11+
{props.children}
12+
</div>
13+
);
14+
}
15+
16+
describe("createDialog", () => {
17+
it('should have role="dialog" by default', () => {
18+
render(() => <Example />);
19+
20+
const el = screen.getByTestId("test");
21+
22+
expect(el).toHaveAttribute("role", "dialog");
23+
});
24+
25+
it('should accept role="alertdialog"', () => {
26+
render(() => <Example role="alertdialog" />);
27+
28+
const el = screen.getByTestId("test");
29+
30+
expect(el).toHaveAttribute("role", "alertdialog");
31+
});
32+
33+
it("should focus the overlay on mount", () => {
34+
render(() => <Example />);
35+
36+
const el = screen.getByTestId("test");
37+
38+
expect(el).toHaveAttribute("tabIndex", "-1");
39+
40+
expect(document.activeElement).toBe(el);
41+
});
42+
43+
// it("should not focus the overlay if something inside is auto focused", () => {
44+
// render(() => (
45+
// <Example>
46+
// <input data-testid="input" autofocus />
47+
// </Example>
48+
// ));
49+
50+
// const input = screen.getByTestId("input");
51+
52+
// expect(document.activeElement).toBe(input);
53+
// });
54+
});

packages/dialog/tsconfig.json

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"include": ["./src", "./test", "./dev"],
4+
"exclude": ["node_modules", "./dist"]
5+
}

packages/dialog/tsup.config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import defaultConfig from "../../tsup.config";
2+
3+
export default defaultConfig;

packages/dialog/vite.config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { viteConfig } from "../../vite.config";
2+
3+
export default viteConfig;

packages/focus/src/focusSafely.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getInteractionModality } from "@solid-aria/interactions";
2+
import { focusWithoutScrolling, runAfterTransition } from "@solid-aria/utils";
3+
4+
/**
5+
* A utility function that focuses an element while avoiding undesired side effects such
6+
* as page scrolling and screen reader issues with CSS transitions.
7+
*/
8+
export function focusSafely(element: HTMLElement) {
9+
// If the user is interacting with a virtual cursor, e.g. screen reader, then
10+
// wait until after any animated transitions that are currently occurring on
11+
// the page before shifting focus. This avoids issues with VoiceOver on iOS
12+
// causing the page to scroll when moving focus if the element is transitioning
13+
// from off the screen.
14+
if (getInteractionModality() === "virtual") {
15+
const lastFocusedElement = document.activeElement;
16+
17+
runAfterTransition(() => {
18+
// If focus did not move and the element is still in the document, focus it.
19+
if (document.activeElement === lastFocusedElement && document.contains(element)) {
20+
focusWithoutScrolling(element);
21+
}
22+
});
23+
} else {
24+
focusWithoutScrolling(element);
25+
}
26+
}

packages/focus/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./createFocusable";
22
export * from "./createFocusRing";
3+
export * from "./focusSafely";
34
export * from "./FocusScope";
45
export * from "./isElementVisible";

packages/interactions/src/test-utils.ts

+32
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,35 @@
1+
/**
2+
* Enables reading pageX/pageY from fireEvent.mouse*(..., {pageX: ..., pageY: ...}).
3+
*/
4+
export function installMouseEvent() {
5+
beforeAll(() => {
6+
const oldMouseEvent = MouseEvent;
7+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
8+
// @ts-ignore
9+
global.MouseEvent = class FakeMouseEvent extends MouseEvent {
10+
_init: { pageX: number; pageY: number };
11+
constructor(name: any, init: any) {
12+
super(name, init);
13+
this._init = init;
14+
}
15+
get pageX() {
16+
return this._init.pageX;
17+
}
18+
get pageY() {
19+
return this._init.pageY;
20+
}
21+
};
22+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
23+
// @ts-ignore
24+
global.MouseEvent.oldMouseEvent = oldMouseEvent;
25+
});
26+
afterAll(() => {
27+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
28+
// @ts-ignore
29+
global.MouseEvent = global.MouseEvent.oldMouseEvent;
30+
});
31+
}
32+
133
export function installPointerEvent() {
234
beforeAll(() => {
335
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

0 commit comments

Comments
 (0)