First stab at with-*ssr-slots

- Infrastructure in base class
- Sample implementation in Card
- Docs
This commit is contained in:
Lea Verou
2025-01-30 16:19:40 -08:00
parent 25cb96aa30
commit e436c02c07
6 changed files with 142 additions and 26 deletions

View File

@@ -6,7 +6,7 @@ icon: card
---
```html {.example}
<wa-card with-image with-footer class="card-overview">
<wa-card class="card-overview">
<img
slot="image"
src="https://images.unsplash.com/photo-1559209172-0ff8f6d49ff7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80"
@@ -61,10 +61,10 @@ Basic cards aren't very exciting, but they can display any content you want them
### Card with Header
Headers can be used to display titles and more.
If using SSR, you need to also use the `with-header` attribute to add a header to the card (if not, it is added automatically).
If using SSR, you need to also use the `ssr-slots` attribute to add a header to the card (if not, it is added automatically).
```html {.example}
<wa-card with-header class="card-header">
<wa-card ssr-slots="header" class="card-header">
<div slot="header">
Header Title
<wa-icon-button name="gear" variant="solid" label="Settings"></wa-icon-button>
@@ -97,10 +97,10 @@ If using SSR, you need to also use the `with-header` attribute to add a header t
### Card with Footer
Footers can be used to display actions, summaries, or other relevant content.
If using SSR, you need to also use the `with-footer` attribute to add a footer to the card (if not, it is added automatically).
If using SSR, you need to also use the `ssr-slots` attribute to add a footer to the card (if not, it is added automatically).
```html {.example}
<wa-card with-footer class="card-footer">
<wa-card ssr-slots="footer" class="card-footer">
This card has a footer. You can put all sorts of things in it!
<div slot="footer">
@@ -125,10 +125,10 @@ If using SSR, you need to also use the `with-footer` attribute to add a footer t
### Images
Card images are displayed atop the card and will stretch to fit.
If using SSR, you need to also use the `with-image` attribute to add an image to the card (if not, it is added automatically).
If using SSR, you need to also use the `ssr-slots` attribute to add an image to the card (if not, it is added automatically).
```html {.example}
<wa-card with-image class="card-image">
<wa-card ssr-slots="image" class="card-image">
<img
slot="image"
src="https://images.unsplash.com/photo-1547191783-94d5f8f6d8b1?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=400&q=80"
@@ -150,7 +150,7 @@ Use the `size` attribute to change a card's size.
```html {.example}
<div class="wa-stack">
<wa-card with-footer size="small">
<wa-card ssr-slots=footer size="small">
This is a small card.
<footer slot="footer" class="wa-flank">
@@ -159,7 +159,7 @@ Use the `size` attribute to change a card's size.
</footer>
</wa-card>
<wa-card with-footer size="medium">
<wa-card ssr-slots=footer size="medium">
This is a medium card (default).
<footer slot="footer" class="wa-flank">
@@ -168,7 +168,7 @@ Use the `size` attribute to change a card's size.
</footer>
</wa-card>
<wa-card with-footer size="large">
<wa-card ssr-slots=footer size="large">
This is a large card.
<footer slot="footer" class="wa-flank">

View File

@@ -65,6 +65,35 @@ All Web Awesome components that get rendered for SSR will receive the `did-ssr`
This can help if you need some styling prior to the element connecting.
### The `ssr-slots` Attribute
When using certain slots in a component rendered through SSR, you may need to use the `ssr-slots` attribute
to declare which slots have content.
E.g. when using a `<wa-card>` component without SSR, you can do this:
```html
<wa-card>
<img src="cat.jpg" slot="image">
<header slot=header>Card Header</header>
Card body
<footer slot=footer>Card Footer</footer>
</wa-card>
```
However, when server-side rendering the very same card, you need to duplicate the information about what is in the slots in the `ssr-slots` attribute:
```html
<wa-card ssr-slots="image header footer">
<img src="cat.jpg" slot="image">
<header slot=header>Card Header</header>
Card body
<footer slot=footer>Card Footer</footer>
</wa-card>
```
We are hoping to eventually be able to remove this requirement.
### Timing Issues
Before setting any properties on your frontend, it is important to first wait for the element to be defined and then wait for its first update to complete.
@@ -116,4 +145,4 @@ Here are some known issues and things we're still working on.
- `@shoelace-style/localize` (our localization library) has no way to set a language currently so it always falls back to `en`.
- `<wa-icon>` has no fallback if there's no JS besides a blank `<svg>`. There's perhaps some backend mechanisms we can use to fetch. But requires altering APIs. Should also have a way to set height / widths, but we don't want to increase pain for SSR users.
- `<wa-qr-code>` QR Code will not error on the backend and will render a blank canvas at the appropriate size, but will not render the canvas until the client component connects.
- `setBasePath` and `kit codes` may need reconfiguring to work with SSR.
- `setBasePath` and `kit codes` may need reconfiguring to work with SSR.

View File

@@ -21,7 +21,7 @@
}
.image,
:host(:not([with-image])) .header {
:host(:not([ssr-slots~='image'])) .header {
border-start-start-radius: var(--inner-border-radius);
border-start-end-radius: var(--inner-border-radius);
}
@@ -56,8 +56,8 @@
padding: var(--spacing);
}
:host(:not([with-header])) .header,
:host(:not([with-footer])) .footer,
:host(:not([with-image])) .image {
:host(:not([ssr-slots~='header'])) .header,
:host(:not([ssr-slots~='footer'])) .footer,
:host(:not([ssr-slots~='image'])) .image {
display: none;
}

View File

@@ -1,5 +1,6 @@
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import sizeStyles from '../../styles/utilities/size.css';
import styles from './card.css';
@@ -31,21 +32,29 @@ export default class WaCard extends WebAwesomeElement {
/** The component's size. Will be inherited by any descendants with a `size` attribute. */
@property({ reflect: true, initial: 'medium' }) size: 'small' | 'medium' | 'large' | 'inherit' = 'inherit';
/** Renders the card with a header. Only needed for SSR, otherwise is automatically added. */
@property({ attribute: 'with-header', type: Boolean }) withHeader = false;
/** Renders the card with an image. Only needed for SSR, otherwise is automatically added. */
@property({ attribute: 'with-image', type: Boolean }) withImage = false;
/** Renders the card with a footer. Only needed for SSR, otherwise is automatically added. */
@property({ attribute: 'with-footer', type: Boolean }) withFooter = false;
static SSR_SLOTS = ['image', 'header', 'footer'];
render() {
return html`
<slot name="image" part="image" class="image"></slot>
<slot name="header" part="header" class="header"></slot>
<slot
name="image"
part="image"
class="${classMap({ image: true, 'has-slotted': this.hasSlotted.has('image') })}"
@slotchange=${this.slotUpdate}
></slot>
<slot
name="header"
part="header"
class="${classMap({ header: true, 'has-slotted': this.hasSlotted.has('header') })}"
@slotchange=${this.slotUpdate}
></slot>
<slot part="body" class="body"></slot>
<slot name="footer" part="footer" class="footer"></slot>
<slot
name="footer"
part="footer"
class="${classMap({ footer: true, 'has-slotted': this.hasSlotted.has('footer') })}"
@slotchange=${this.slotUpdate}
></slot>
`;
}
}

View File

@@ -0,0 +1,18 @@
import type { ComplexAttributeConverter } from 'lit';
export const setConverter: ComplexAttributeConverter<Set<string> | null, Set<string> | null> = {
fromAttribute(value): Set<string> | null {
if (value === null) {
return null;
}
return new Set(value.split(/\s+/));
},
toAttribute: value => {
if (value === null) {
return null;
}
return [...(value as Set<string>)].join(' ');
},
};

View File

@@ -2,6 +2,7 @@ import type { CSSResult, CSSResultGroup, PropertyDeclaration, PropertyValues } f
import { LitElement, defaultConverter, isServer, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import componentStyles from '../styles/shadow/component.css';
import { setConverter } from './converters.js';
// Augment Lit's module
declare module 'lit' {
@@ -40,6 +41,20 @@ export default class WebAwesomeElement extends LitElement {
@property() dir: string;
@property() lang: string;
@property({
reflect: true,
attribute: 'ssr-slots',
converter: setConverter,
})
ssrSlots: Set<string> | null = null;
/**
* All slots whose slotted status we need to know on the root for SSR.
* Subclasses are expected to override this with their own list of slots.
*/
static SSR_SLOTS: string[] = [];
protected hasSlotted = new Set();
/**
* One or more styles for the elements own shadow DOM.
* Shared component styles will automatically be added.
@@ -72,6 +87,13 @@ export default class WebAwesomeElement extends LitElement {
internals: ElementInternals;
connectedCallback(): void {
super.connectedCallback();
let Self = this.constructor as typeof WebAwesomeElement;
this.ssrSlots = new Set(Self.SSR_SLOTS.filter(name => this.slotUpdate(name)));
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (!this.#hasRecordedInitialProperties) {
(this.constructor as typeof WebAwesomeElement).elementProperties.forEach(
@@ -169,6 +191,44 @@ export default class WebAwesomeElement extends LitElement {
return this.hasStatesSupport() ? this.internals.states.has(state) : false;
}
protected async slotUpdate(target: Event | HTMLSlotElement | string) {
await this.updateComplete;
let slot = target;
if (target instanceof Event) {
slot = target.target as HTMLSlotElement;
} else if (typeof target === 'string') {
slot = this.shadowRoot!.querySelector(`slot[name="${target}"]`) as HTMLSlotElement;
} else {
slot = slot as HTMLSlotElement;
}
if (!slot) {
return;
}
const slotName = slot.name;
const hasSlotted = slot.assignedNodes().length > 0;
let previousHasSlotted = this.hasSlotted.has(slotName);
if (previousHasSlotted === hasSlotted) {
let ssrSlots = new Set(this.ssrSlots);
if (hasSlotted) {
ssrSlots.add(slotName);
} else {
ssrSlots.delete(slotName);
}
this.ssrSlots = ssrSlots;
}
slot.classList.toggle('has-slotted', hasSlotted);
return hasSlotted;
}
getComputed(prop: PropertyKey) {
let value = this[prop as keyof this];
if (value !== 'inherit') {