Skip to content

Commit

Permalink
Improve ability to extends Dialog (#1576)
Browse files Browse the repository at this point in the history
  • Loading branch information
jpzwarte authored Oct 7, 2024
1 parent ebe4c8a commit b3619c7
Show file tree
Hide file tree
Showing 7 changed files with 242 additions and 97 deletions.
16 changes: 16 additions & 0 deletions .changeset/shy-hats-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@sl-design-system/dialog': minor
---

Improve ability to `extends Dialog`

This change improves the ability to extend the Dialog component by splitting the `render()` method into smaller methods. This makes it easier to override specific parts of the Dialog component:

- `renderHeader(title: string, subtitle: string)`
- `renderBody()`
- `renderFooter()`
- `renderActions()`

The `renderHeader` method is slightly different. If all you want to do is add a title or subtitle to the header, you can override the method and call `return super.renderHeader('My title', 'My subtitle')`.

To be clear: the above API is only meant to be used when you are *extending* the `Dialog` class. If you are using the `<sl-dialog>` element in your HTML, than you can still use the `header`, `body`, and `footer` slots as before.
54 changes: 25 additions & 29 deletions examples/lit/src/form-in-dialog/form-in-dialog.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { Button } from '@sl-design-system/button';
import { type ScopedElementsMap } from '@open-wc/scoped-elements/lit-element.js';
import { Dialog } from '@sl-design-system/dialog';
import { Form, FormController, FormField, FormValidationErrors } from '@sl-design-system/form';
import { TextField } from '@sl-design-system/text-field';
import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit';
import { query } from 'lit/decorators.js';
import { type CSSResultGroup, type TemplateResult, html } from 'lit';
import styles from './form-in-dialog.scss.js';

export class FormInDialog extends ScopedElementsMixin(LitElement) {
export class FormInDialog extends Dialog {
/** @internal */
static get scopedElements(): ScopedElementsMap {
static override get scopedElements(): ScopedElementsMap {
return {
'sl-button': Button,
'sl-dialog': Dialog,
...super.scopedElements,
'sl-form': Form,
'sl-form-field': FormField,
'sl-form-validation-errors': FormValidationErrors,
Expand All @@ -26,36 +23,35 @@ export class FormInDialog extends ScopedElementsMixin(LitElement) {
/** Controller for managing form state. */
#form = new FormController(this);

/** The dialog component. */
@query('sl-dialog') dialog!: Dialog;
override renderHeader(): TemplateResult {
return super.renderHeader('Form in dialog');
}

override render(): TemplateResult {
override renderBody(): TemplateResult {
return html`
<sl-dialog>
<span slot="title">Title</span>
<sl-form>
<sl-form-field label="First name">
<sl-text-field autofocus name="firstName" required></sl-text-field>
</sl-form-field>
<sl-form-field label="Last name">
<sl-text-field name="lastName" required></sl-text-field>
</sl-form-field>
<sl-form-validation-errors .controller=${this.#form}></sl-form-validation-errors>
</sl-form>
<sl-button sl-dialog-close fill="ghost" slot="actions">Cancel</sl-button>
<sl-button @click=${this.#onSave} slot="actions" variant="primary">Save</sl-button>
</sl-dialog>
<sl-form>
<sl-form-field label="First name">
<sl-text-field autofocus name="firstName" required></sl-text-field>
</sl-form-field>
<sl-form-field label="Last name">
<sl-text-field name="lastName" required></sl-text-field>
</sl-form-field>
<sl-form-validation-errors .controller=${this.#form}></sl-form-validation-errors>
</sl-form>
`;
}

showModal(): void {
this.dialog.showModal();
override renderActions(): TemplateResult {
return html`
<sl-button sl-dialog-close fill="ghost" slot="actions">Cancel</sl-button>
<sl-button @click=${this.#onSave} slot="actions" variant="primary">Save</sl-button>
`;
}

#onSave(): void {
if (this.#form.reportValidity()) {
this.dialog.close();
this.close();
}
}
}
1 change: 0 additions & 1 deletion grid-poc
Submodule grid-poc deleted from a55a9c
16 changes: 11 additions & 5 deletions packages/components/dialog/src/dialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,21 @@ dialog[open]:not([closing]) {
gap: var(--_heading-gap);
}

slot[name='title']::slotted(*) {
slot[name='title'] {
font: var(--_title-font);
margin: 0;
text-wrap: balance;

&::slotted(*) {
margin: 0;
text-wrap: balance;
}
}

slot[name='subtitle']::slotted(*) {
slot[name='subtitle'] {
font: var(--_subtitle-font);
margin: 0;

&::slotted(*) {
margin: 0;
}
}

slot[name='header-actions'] sl-button-bar {
Expand Down
87 changes: 86 additions & 1 deletion packages/components/dialog/src/dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, fixture } from '@open-wc/testing';
import { type Button } from '@sl-design-system/button';
import { sendKeys } from '@web/test-runner-commands';
import { html } from 'lit';
import { type LitElement, type TemplateResult, html } from 'lit';
import { spy, stub } from 'sinon';
import '../register.js';
import { Dialog } from './dialog.js';
Expand Down Expand Up @@ -321,4 +321,89 @@ describe('sl-dialog', () => {
expect(dialog.close).not.to.have.been.called;
});
});

describe('inheritance', () => {
beforeEach(() => {
try {
customElements.define('inherited-dialog', class extends Dialog {});
} catch {
// empty
}
});

it('should call renderHeader during render', async () => {
const renderHeader = spy(Dialog.prototype, 'renderHeader');

await fixture(html`<inherited-dialog></inherited-dialog>`);

expect(renderHeader).to.have.been.calledOnce;
});

it('should render the given title and subtitle passed to renderHeader()', async () => {
customElements.define(
'inherited-dialog-with-custom-title',
class extends Dialog {
override renderHeader(): TemplateResult {
return super.renderHeader('Title', 'Subtitle');
}
}
);

const el: LitElement = await fixture(
html`<inherited-dialog-with-custom-title></inherited-dialog-with-custom-title>`
);

const title = el.renderRoot.querySelector('slot[name="title"]');
expect(title).to.exist;
expect(title).to.have.text('Title');

const subtitle = el.renderRoot.querySelector('slot[name="subtitle"]');
expect(subtitle).to.exist;
expect(subtitle).to.have.text('Subtitle');
});

it('should call renderBody during render', async () => {
const renderBody = spy(Dialog.prototype, 'renderBody');

await fixture(html`<inherited-dialog></inherited-dialog>`);

expect(renderBody).to.have.been.calledOnce;
});

it('should call renderFooter during render', async () => {
const renderFooter = spy(Dialog.prototype, 'renderFooter');

await fixture(html`<inherited-dialog></inherited-dialog>`);

expect(renderFooter).to.have.been.calledOnce;
});

it('should call renderActions during render', async () => {
const renderActions = spy(Dialog.prototype, 'renderActions');

await fixture(html`<inherited-dialog></inherited-dialog>`);

expect(renderActions).to.have.been.calledOnce;
});

it('should render the actions into the actions slot', async () => {
customElements.define(
'inherited-dialog-with-custom-actions',
class extends Dialog {
override renderActions(): TemplateResult {
return html`<sl-button>Custom action</sl-button>`;
}
}
);

const el: LitElement = await fixture(
html`<inherited-dialog-with-custom-actions></inherited-dialog-with-custom-actions>`
);

const button = el.renderRoot.querySelector('sl-button');
expect(button).to.exist;
expect(button).to.have.text('Custom action');
expect(button?.parentElement).to.match('slot[name="actions"]');
});
});
});
48 changes: 24 additions & 24 deletions packages/components/dialog/src/dialog.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,30 +74,6 @@ export const Basic: Story = {
}
};

export const CloseButton: Story = {};

export const All: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// See https://storybook.js.org/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
await userEvent.click(canvas.getByTestId('button'));
},
render: () => {
const onClick = (event: Event & { target: HTMLElement }): void => {
(event.target.nextElementSibling as Dialog).showModal();
};

return html` <sl-button fill="outline" size="md" @click=${onClick} data-testid="button">Show Dialog</sl-button>
<sl-dialog close-button disable-cancel>
<span slot="title">Title</span>
<span slot="subtitle">Subtitle</span>
Body text
<sl-button slot="actions" fill="ghost" variant="default" sl-dialog-close autofocus>Cancel</sl-button>
<sl-button slot="actions" variant="primary" sl-dialog-close>Action</sl-button>
</sl-dialog>`;
}
};

export const DisableCancel: Story = {
args: {
body: 'You cannot close me by pressing the Escape key, or clicking the backdrop. This dialog also has no close button. The only way to close it is by clicking one of the action buttons.',
Expand Down Expand Up @@ -199,3 +175,27 @@ export const CustomComponent: Story = {
`;
}
};

export const All: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// See https://storybook.js.org/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
await userEvent.click(canvas.getByTestId('button'));
},
render: () => {
const onClick = (event: Event & { target: HTMLElement }): void => {
(event.target.nextElementSibling as Dialog).showModal();
};

return html`
<sl-button fill="outline" size="md" @click=${onClick} data-testid="button">Show Dialog</sl-button>
<sl-dialog close-button disable-cancel>
<span slot="title">Title</span>
<span slot="subtitle">Subtitle</span>
Body text
<sl-button slot="actions" fill="ghost" variant="default" sl-dialog-close autofocus>Cancel</sl-button>
<sl-button slot="actions" variant="primary" sl-dialog-close>Action</sl-button>
</sl-dialog>
`;
}
};
Loading

0 comments on commit b3619c7

Please sign in to comment.