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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions showcase/registry/I9kModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Emits:
Slots:
- default — the dialog body; it scrolls when taller than the viewport.
- footer — actions such as Cancel and Save, aligned to the end.
- after — under the dialog's card, on the backdrop, centred: a way out of the dialog's task, such as "Forgot your password?" under a sign-in form. It is inside the <dialog>, so it is reachable by keyboard while the modal is open.

Behavior: the parent owns \`open\`. The close button and Escape only request a close by emitting update:open(false); the modal closes when the parent sets \`open\` to false, and focus returns to the element that was focused when it opened.

Expand All @@ -50,6 +51,7 @@ Usage:
'Set `dismissible` to false while saving; Escape is then ignored and the close button is disabled.',
'The body and footer render only while open, so a form inside remounts on every opening; put `autofocus` on its first field.',
'The footer renders outside the body, so a submit button there needs `form="<id>"` pointing at the form in the body.',
'The `after` slot sits on the backdrop, not on the card: keep it to a short link, which reads on the dimmed page.',
],
demos: [
{
Expand Down
40 changes: 32 additions & 8 deletions src/components/I9kModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,25 +145,36 @@ onBeforeUnmount(() => {
the page's toaster does, above the backdrop. -->
<I9kToaster v-if="toaster" />
</div>
<!-- Under the box, still inside the <dialog>: in the top layer and the
focus order, and above the backdrop, but not part of the dialog's
own card. For a way out of the dialog's task, such as "Forgot your
password?" under a sign-in form. -->
<div v-if="open && $slots.after" class="i9k-modal__after"><slot name="after" /></div>
</dialog>
</template>

<style scoped>
/* The <dialog> is only the frame: the card is .i9k-modal__surface, so the
`after` slot can sit under the card, on the backdrop. */
.i9k-modal {
--i9k-modal-width: 40rem;

width: min(100% - 2 * var(--spacing-8), var(--i9k-modal-width));
max-height: calc(100dvh - 2 * var(--spacing-8));
/* Borders sit outside max-height, so .i9k-modal__surface can inherit it and still fit. */
box-sizing: content-box;
overflow: visible;
padding: 0;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--surface-raised-color);
box-shadow: var(--shadow-sm);
border: 0;
background: transparent;
color: var(--text-color);
}

/* A column, so the card shrinks (and its body scrolls) to leave room for the
`after` slot. Only while open: a closed <dialog> must stay display: none. */
.i9k-modal[open] {
display: flex;
flex-direction: column;
}

.i9k-modal--sm {
--i9k-modal-width: 28rem;
}
Expand All @@ -173,13 +184,26 @@ onBeforeUnmount(() => {
}

.i9k-modal::backdrop {
background: hsl(0 0% 0% / 0.5);
background: hsl(0 0% 0% / 0.75);
}

.i9k-modal__surface {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
max-height: inherit;
min-height: 0;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--surface-raised-color);
box-shadow: var(--shadow-sm);
}

.i9k-modal__after {
flex: none;
margin-block-start: var(--spacing-6);
/* On the backdrop, which is dark in both themes, so always light text
(links inherit it). */
color: var(--white-color);
text-align: center;
}

.i9k-modal__header {
Expand Down
20 changes: 20 additions & 0 deletions stories/I9kModal.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ export const WithFooter: Story = {
}),
};

export const WithAfter: Story = {
args: { title: 'Sign in', size: 'sm' },
render: (args) => ({
components: { I9kButton, I9kModal },
setup: () => {
const open = ref(false);
return { args, open };
},
template: `<div>
<I9kButton @click="open = true">Sign in</I9kButton>
<I9kModal v-bind="args" v-model:open="open">
<label>Email <input name="email" type="email" autofocus /></label>
<template #after>
<a href="#forgot">Forgot your password?</a>
</template>
</I9kModal>
</div>`,
}),
};

export const NotDismissible: Story = {
args: {
title: 'Saving module',
Expand Down
18 changes: 18 additions & 0 deletions tests/I9kModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,4 +316,22 @@ describe('I9kModal toaster host', () => {
expect(pageAlert()).toBe('');
});
});

it('renders the after slot inside the dialog, outside its card, only while open', async () => {
const wrapper = mountModal({}, { after: '<a href="#forgot">Forgot your password?</a>' });
expect(wrapper.find('a[href="#forgot"]').exists()).toBe(false);

await wrapper.setProps({ open: true });
await nextTick();
const link = wrapper.get('a[href="#forgot"]');
expect(link.element.closest('dialog')).toBe(wrapper.get('dialog').element);
expect(link.element.closest('.i9k-modal__surface')).toBeNull();
expect(link.element.closest('.i9k-modal__after')).not.toBeNull();
});

it('renders no after wrapper without the slot', async () => {
const wrapper = mountModal({ open: true });
await nextTick();
expect(wrapper.find('.i9k-modal__after').exists()).toBe(false);
});
});
Loading