Skip to content

Commit d1519ee

Browse files
feat: added new modal title and description components
1 parent a7309f2 commit d1519ee

File tree

10 files changed

+176
-3
lines changed

10 files changed

+176
-3
lines changed

.changeset/eight-spiders-brake.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
'@qwik-ui/headless': patch
3+
---
4+
5+
fix: modal does not close unless the dialog backdrop is clicked (including dangling content)
6+
7+
fix: polyfilled popovers render correctly inside of modals.
8+
9+
fix: nested modals will now close the current modal when the backdrop is clicked.
10+
11+
fix: nested modals will now close the current modal when the escape key is pressed.
12+
13+
fix: select does not execute code until interaction (including core).
14+
15+
tests: larger test suite for modals.
16+
17+
deprecated: `ModalHeader`, `ModalContent`, `ModalFooter` have been deprecated, as they do not pose significant a11y advantages.
18+
19+
feat: Two new Modal component pieces. `ModalTitle` and `ModalDescription`. These help give our modal an accessible name and supporting description (optional).
20+
21+
feat: Modal now uses the following CSS as a default inside of an @layer
22+
23+
```css
24+
@layer qwik-ui {
25+
/* browsers automatically set an interesting max-width and max-height for dialogs
26+
https://twitter.com/t3dotgg/status/1774350919133691936
27+
*/
28+
dialog:modal {
29+
max-width: unset;
30+
max-height: unset;
31+
}
32+
}
33+
```
34+
35+
The default browser styles:
36+
37+
![alt text](image.png)
38+
39+
Make it difficult to style a dialog element full-screen, which has led to some confusion recently both in this repo and across the web. The above change strips the responsible browser styles from the dialog eleemnt (which is used by Qwik UI's modal component).
40+
41+
> For more info, feel free to check out the link in the code snippet above.
42+
43+
> Note: In the future, we intend to use the dot notation for the `Modal` component.
44+
45+
> Note: In the future, we intend to change the modal API to include a trigger. The proposed API is as follows:
46+
47+
### Syntax Proposal
48+
49+
```tsx
50+
<Modal.Root>
51+
<Modal.Trigger>Trigger</Modal.Trigger>
52+
<Modal.Content>
53+
{' '}
54+
{/* This is the current <Modal /> */}
55+
<Modal.Title>Edit Profile</Modal.Title>
56+
<Modal.Description>You can update your profile here.</Modal.Description>
57+
</Modal.Content>
58+
</Modal.Root>
59+
```
60+
61+
Let us know your thoughts on this potential API change in the Qwik UI discord!

.changeset/image.png

82.3 KB
Loading

apps/website/src/routes/docs/headless/modal/examples/hero.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
2-
import { Modal } from '@qwik-ui/headless';
2+
import { Modal, ModalTitle, ModalDescription } from '@qwik-ui/headless';
33
import styles from '../snippets/modal.css?inline';
44

55
export default component$(() => {
@@ -12,7 +12,29 @@ export default component$(() => {
1212
Open Modal
1313
</button>
1414
<Modal class="modal" bind:show={isOpen}>
15-
Modal Content
15+
<ModalTitle>Edit Profile</ModalTitle>
16+
<ModalDescription>
17+
You can update your profile here. Hit the save button when finished.
18+
</ModalDescription>
19+
<label for="name">Name</label>
20+
<input
21+
class="mt-2 rounded-base bg-background px-4 py-[10px] text-foreground"
22+
id="name"
23+
type="text"
24+
placeholder="John Doe"
25+
/>
26+
<label for="email">Email</label>
27+
<input id="email" type="text" placeholder="[email protected]" />
28+
<footer>
29+
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
30+
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
31+
</footer>
32+
<button
33+
onClick$={() => (isOpen.value = false)}
34+
class="absolute right-6 top-[26px]"
35+
>
36+
Close
37+
</button>
1638
</Modal>
1739
</>
1840
);

packages/kit-headless/src/components/modal/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export * from './modal';
22
export * from './modal-content';
33
export * from './modal-footer';
44
export * from './modal-header';
5+
export * from './modal-title';
6+
export * from './modal-description';
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { createContextId } from '@builder.io/qwik';
2+
3+
export const modalContextId = createContextId<ModalContext>('qui-modal');
4+
5+
export type ModalContext = {
6+
// core state
7+
localId: string;
8+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Slot, component$, useContext } from '@builder.io/qwik';
2+
import { modalContextId } from './modal-context';
3+
4+
export const ModalDescription = component$(() => {
5+
const context = useContext(modalContextId);
6+
7+
const descriptionId = `${context.localId}-description`;
8+
9+
return (
10+
<p id={descriptionId}>
11+
<Slot />
12+
</p>
13+
);
14+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Slot, component$, useContext } from '@builder.io/qwik';
2+
import { modalContextId } from './modal-context';
3+
4+
export const ModalTitle = component$(() => {
5+
const context = useContext(modalContextId);
6+
7+
const titleId = `${context.localId}-title`;
8+
9+
return (
10+
<h2 id={titleId}>
11+
<Slot />
12+
</h2>
13+
);
14+
});

packages/kit-headless/src/components/modal/modal.driver.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ export function createTestDriver<T extends DriverLocator>(rootLocator: T) {
88
};
99

1010
const getTrigger = () => {
11-
return rootLocator.getByRole('button');
11+
return rootLocator.getByRole('button', { name: 'Open Modal' });
12+
};
13+
14+
const getTitle = () => {
15+
return rootLocator.getByRole('heading');
16+
};
17+
18+
const getDescription = () => {
19+
return rootLocator.getByRole('paragraph');
1220
};
1321

1422
const openModal = async () => {
@@ -24,5 +32,7 @@ export function createTestDriver<T extends DriverLocator>(rootLocator: T) {
2432
getModal,
2533
getTrigger,
2634
openModal,
35+
getTitle,
36+
getDescription,
2737
};
2838
}

packages/kit-headless/src/components/modal/modal.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,31 @@ test.describe('Nested Modals', () => {
256256
await expect(d.getModal()).toBeVisible();
257257
});
258258
});
259+
260+
test.describe('A11y', () => {
261+
test(`GIVEN a modal
262+
WHEN the modal is rendered
263+
THEN it should have an accessible name`, async ({ page }) => {
264+
const { driver: d } = await setup(page, 'hero');
265+
266+
await d.openModal();
267+
268+
const titleId = await d.getTitle().getAttribute('id');
269+
270+
await expect(d.getModal()).toHaveAttribute('aria-labelledby', `${titleId}`);
271+
});
272+
273+
test(`GIVEN a modal
274+
WHEN an optional description is added
275+
THEN the modal's aria-describedby should point to the description`, async ({
276+
page,
277+
}) => {
278+
const { driver: d } = await setup(page, 'hero');
279+
280+
await d.openModal();
281+
282+
const descriptionId = await d.getDescription().getAttribute('id');
283+
284+
await expect(d.getModal()).toHaveAttribute('aria-describedby', `${descriptionId}`);
285+
});
286+
});

packages/kit-headless/src/components/modal/modal.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ import {
99
useStyles$,
1010
useTask$,
1111
sync$,
12+
useId,
13+
useContextProvider,
1214
} from '@builder.io/qwik';
1315

16+
import { ModalContext, modalContextId } from './modal-context';
17+
1418
import styles from './modal.css?inline';
1519
import { useModal } from './use-modal';
1620

@@ -34,6 +38,7 @@ export const Modal = component$((props: ModalProps) => {
3438
} = useModal();
3539

3640
const modalRef = useSignal<HTMLDialogElement>();
41+
const localId = useId();
3742

3843
const { 'bind:show': showSig } = props;
3944

@@ -97,9 +102,18 @@ export const Modal = component$((props: ModalProps) => {
97102
}
98103
});
99104

105+
const context: ModalContext = {
106+
localId,
107+
};
108+
109+
useContextProvider(modalContextId, context);
110+
100111
return (
101112
<dialog
102113
{...props}
114+
id={`${localId}-root`}
115+
aria-labelledby={`${localId}-title`}
116+
aria-describedby={`${localId}-description`}
103117
data-state={showSig.value ? 'open' : 'closed'}
104118
role={props.alert === true ? 'alertdialog' : 'dialog'}
105119
ref={modalRef}

0 commit comments

Comments
 (0)