Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] Card slots #8233

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
03b759c
vertical orientation works alright, horizontal has issues
jouni Nov 27, 2024
af7e9c4
remove commented code
jouni Nov 27, 2024
4b085d9
Update card.html
jouni Nov 27, 2024
476f73b
Update card.html
jouni Nov 27, 2024
0a6dcd2
keep media as the first child
jouni Dec 2, 2024
906c67e
experiment with custom properties (no horizontal version)
jouni Dec 2, 2024
bf28c91
robust prototype with slots and shadow parts
jouni Dec 4, 2024
d5b3bad
improve dev playground
jouni Dec 10, 2024
7b0359b
switch cover-media <-> stretch-media variant names
jouni Dec 13, 2024
52103aa
fix material theme
jouni Dec 13, 2024
a441654
fix: run mutation observer once when connected
jouni Dec 13, 2024
3ce3ebd
fix a couple of sonarcloud issues
jouni Dec 16, 2024
809aea2
update visual test for Lumo
jouni Dec 17, 2024
66b8b4b
remove duplicate visual test
jouni Dec 17, 2024
53a81fa
update visual test for Material
jouni Dec 17, 2024
ebf16f9
use Lumo-specific component imports in Lumo tests
jouni Dec 17, 2024
32ea420
fix CSS lint error
jouni Dec 17, 2024
88ec3be
remove TODOs
jouni Dec 17, 2024
c457bff
First draft of JSDoc
jouni Dec 18, 2024
8f0f7f7
prefix internal custom props with underscore
jouni Dec 18, 2024
46a67f0
refactor: use slotchange listener instead of MutationObserver
jouni Dec 18, 2024
97c0048
test: update screenshots
web-padawan Dec 19, 2024
28eb9d4
test: import badge-global in card visual test
web-padawan Dec 19, 2024
e6f28c1
test: remove Lumo custom CSS properties from Material test
web-padawan Dec 19, 2024
c97da29
test: cleanup card visual test, use local image (#8369)
web-padawan Dec 19, 2024
e6307c9
fix: restore styles for hidden attribute
web-padawan Dec 19, 2024
d7f7e8b
docs: add protected JSDoc annotations
web-padawan Dec 19, 2024
f43304f
refactor: add box-sizing border-box to card styles
web-padawan Dec 20, 2024
8f4e7ec
test: update visual test screenshots
web-padawan Dec 20, 2024
a93ebfd
test: update screenshots
web-padawan Jan 10, 2025
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
738 changes: 262 additions & 476 deletions dev/card.html

Large diffs are not rendered by default.

320 changes: 315 additions & 5 deletions packages/card/src/vaadin-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,40 @@ import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';

/**
* `<vaadin-card>` is a visual content container.
* `<vaadin-card>` is a versatile container for grouping related content and actions.
* It presents information in a structured and visually appealing manner, with
* customization options to fit various design requirements.
*
* ```html
* <vaadin-card>
* <div>Card content</div>
* <vaadin-card theme="outlined cover-media">
* <img slot="media" width="200" src="..." alt="">
* <div slot="title">Lapland</div>
* <div slot="subtitle">The Exotic North</div>
* <div>Lapland is the northern-most region of Finland and an active outdoor destination.</div>
* <vaadin-button slot="footer">Book Vacation</vaadin-button>
* </vaadin-card>
* ```
*
* ### Styling
*
* The following shadow DOM parts are available for styling:
*
* Part name | Description
* ----------|-------------
* `media` | The container for the media element (e.g., image, video, icon). Shown above of before the card content.
* `header` | The container for title and subtitle - or for custom header content - and header prefix and suffix elements.
* `content` | The container for the card content (usually text content).
* `footer` | The container for footer elements. This part is always at the bottom of the card.
*
* The following custom properties are available for styling:
*
* Custom property | Description | Default
* ----------------|-------------|-------------
* `--vaadin-card-padding` | The space between the card edge and its content. Needs to a unified value for all edges, i.e., a single length value. | `1em`
* `--vaadin-card-gap` | The space between content elements within the card. | `1em`
*
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
*
* @customElement
* @extends HTMLElement
* @mixes ElementMixin
Expand All @@ -31,12 +57,239 @@ class Card extends ElementMixin(ThemableMixin(PolylitMixin(LitElement))) {
static get styles() {
return css`
:host {
display: block;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: var(--_padding);
web-padawan marked this conversation as resolved.
Show resolved Hide resolved
gap: var(--_gap);
--_padding: var(--vaadin-card-padding, 1em);
--_gap: var(--vaadin-card-gap, 1em);
--_media: 0;
--_title: 0;
--_subtitle: 0;
--_header: max(var(--_header-prefix), var(--_title), var(--_subtitle), var(--_header-suffix));
--_header-prefix: 0;
--_header-suffix: 0;
--_content: 0;
--_footer: 0;
}

:host([hidden]) {
display: none !important;
}

:host(:not([theme~='horizontal'])) {
justify-content: space-between;
}

:host(:is(:has([slot='media']), [has-media])) {
--_media: 1;
}

:host(:is(:has([slot='title']), [has-title])) {
--_title: 1;
}

:host(:is(:has([slot='subtitle']), [has-subtitle])) {
--_subtitle: 1;
}

:host(:is(:has([slot='header']), [has-header])) {
--_header: 1;
--_title: 0;
--_subtitle: 0;
}

:host(:is(:has([slot='header-prefix']), [has-header-prefix])) {
--_header-prefix: 1;
}

:host(:is(:has([slot='header-suffix']), [has-header-suffix])) {
--_header-suffix: 1;
}

:host(:is(:has(> :not([slot])), [has-content])) {
--_content: 1;
}
Comment on lines +111 to +113
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this doesn't support plain text as card content, e.g. the following works:

<vaadin-card>
  <div>Content</div>
</vaadin-card>

But the following does not work (the content isn't visible):

<vaadin-card>Content</vaadin-card>


:host(:is(:has([slot='footer']), [has-footer])) {
--_footer: 1;
}

[part='media'],
[part='header'],
[part='content'],
[part='footer'] {
display: none;
}

:host(:is(:has([slot='media']), [has-media])) [part='media'],
:host(:is(:has(> :not([slot])), [has-content])) [part='content'] {
display: block;
}

:host(:is(:has([slot='footer']), [has-footer])) [part='footer'] {
display: flex;
gap: var(--_gap);
}

:host(
:is(
:has([slot='header']),
[has-header],
:has([slot='title']),
[has-title],
:has([slot='subtitle']),
[has-subtitle],
:has([slot='header-prefix']),
[has-header-prefix],
:has([slot='header-suffix']),
[has-header-suffix]
)
)
[part='header'] {
display: grid;
align-items: center;
gap: var(--_gap);
row-gap: 0;
}

[part='header'] {
margin-bottom: auto;
}

:host(:is(:has([slot='header-suffix']), [has-header-suffix])) [part='header'] {
grid-template-columns: 1fr auto;
}

:host(:is(:has([slot='header-prefix']), [has-header-prefix])) [part='header'] {
grid-template-columns: repeat(var(--_header-prefix), auto) 1fr;
}

slot {
border-radius: inherit;
}

::slotted([slot='header-prefix']) {
grid-column: 1;
grid-row: 1 / span calc(var(--_title) + var(--_subtitle));
}

::slotted([slot='header']),
::slotted([slot='title']) {
grid-column: calc(1 + var(--_header-prefix));
grid-row: 1;
}

::slotted([slot='subtitle']) {
grid-column: calc(1 + var(--_header-prefix));
grid-row: calc(1 + var(--_title));
}

::slotted([slot='header-suffix']) {
grid-column: calc(2 + var(--_header-prefix));
grid-row: 1 / span calc(var(--_title) + var(--_subtitle));
}

/* Horizontal */
:host([theme~='horizontal']) {
display: grid;
grid-template-columns: repeat(var(--_media), minmax(auto, max-content)) 1fr;
align-items: start;
}

:host([theme~='horizontal']:is(:has([slot='footer']), [has-footer])) {
grid-template-rows: 1fr auto;
}

:host([theme~='horizontal']:is(:has(> :not([slot])), [has-content])) {
grid-template-rows: repeat(var(--_header), auto) 1fr;
}

[part='media'] {
grid-column: 1;
grid-row: 1 / span calc(var(--_header) + var(--_content) + var(--_footer));
align-self: stretch;
border-radius: inherit;
}

[part='header'] {
grid-column: calc(1 + var(--_media));
grid-row: 1;
}

[part='content'] {
grid-column: calc(1 + var(--_media));
grid-row: calc(1 + var(--_header));
flex: auto;
min-height: 0;
}

[part='footer'] {
grid-column: calc(1 + var(--_media));
grid-row: calc(1 + var(--_header) + var(--_content));
border-radius: inherit;
}

:host([theme~='horizontal']) [part='footer'] {
align-self: end;
}

:host(:not([theme~='horizontal'])) ::slotted([slot='media']:is(img, video, svg)) {
max-width: 100%;
}

::slotted([slot='media']) {
vertical-align: middle;
}

:host(:is([theme~='cover-media'], [theme~='stretch-media']))
::slotted([slot='media']:is(img, video, svg, vaadin-icon)) {
width: 100%;
height: auto;
aspect-ratio: var(--vaadin-card-media-aspect-ratio, 16/9);
object-fit: cover;
}

:host([theme~='horizontal']:is([theme~='cover-media'], [theme~='stretch-media'])) {
grid-template-columns: repeat(var(--_media), minmax(auto, 0.5fr)) 1fr;
}

:host([theme~='horizontal']:is([theme~='cover-media'], [theme~='stretch-media']))
::slotted([slot='media']:is(img, video, svg, vaadin-icon)) {
height: 100%;
aspect-ratio: auto;
}

:host([theme~='cover-media']) ::slotted([slot='media']:is(img, video, svg, vaadin-icon)) {
margin-top: calc(var(--_padding) * -1);
margin-inline: calc(var(--_padding) * -1);
width: calc(100% + var(--_padding) * 2);
max-width: none;
border-radius: inherit;
border-end-end-radius: 0;
border-end-start-radius: 0;
}

:host([theme~='horizontal'][theme~='cover-media']) ::slotted([slot='media']:is(img, video, svg, vaadin-icon)) {
margin-inline-end: 0;
width: calc(100% + var(--_padding));
height: calc(100% + var(--_padding) * 2);
border-radius: inherit;
border-start-end-radius: 0;
border-end-end-radius: 0;
}

/* Scroller in content */
[part='content'] ::slotted(vaadin-scroller) {
margin-inline: calc(var(--_padding) * -1);
padding-inline: var(--_padding);
}

[part='content'] ::slotted(vaadin-scroller)::before,
[part='content'] ::slotted(vaadin-scroller)::after {
margin-inline: calc(var(--_padding) * -1);
}
`;
}

Expand All @@ -46,7 +299,64 @@ class Card extends ElementMixin(ThemableMixin(PolylitMixin(LitElement))) {

/** @protected */
render() {
return html`<slot></slot>`;
return html`
<div part="media">
<slot name="media"></slot>
</div>
<div part="header">
<slot name="header-prefix"></slot>
<slot name="header">
<slot name="title"></slot>
<slot name="subtitle"></slot>
</slot>
<slot name="header-suffix"></slot>
</div>
<div part="content">
<slot></slot>
</div>
<div part="footer">
<slot name="footer"></slot>
</div>
`;
}

/** @private */
_onSlotChange() {
// Chrome doesn't support `:host(:has())`, so we'll recreate that with custom attributes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we want to provide has- attributes for all browsers? This approach is widely used by existing components e.g. has-error-message etc and IMO it would make card styling easier.

this.toggleAttribute('has-media', this.querySelector(':scope > [slot="media"]'));
this.toggleAttribute('has-header', this.querySelector(':scope > [slot="header"]'));
this.toggleAttribute(
'has-title',
this.querySelector(':scope > [slot="title"]') && !this.querySelector(':scope > [slot="header"]'),
);
this.toggleAttribute(
'has-subtitle',
this.querySelector(':scope > [slot="subtitle"]') && !this.querySelector(':scope > [slot="header"]'),
);
this.toggleAttribute('has-header-prefix', this.querySelector(':scope > [slot="header-prefix"]'));
this.toggleAttribute('has-header-suffix', this.querySelector(':scope > [slot="header-suffix"]'));
this.toggleAttribute('has-content', this.querySelector(':scope > :not([slot])'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be probably updated to check for the actual slotted nodes as querySelector() doesn't return text nodes from the default slot. I could change the logic to use SlotObserver helper that we have since it provides the access to the list of current nodes using slot.assignedNodes({ flatten: true }).

this.toggleAttribute('has-footer', this.querySelector(':scope > [slot="footer"]'));
}

/** @protected */
connectedCallback() {
super.connectedCallback();

if (!CSS.supports('selector(:host(:has([slot])))')) {
if (!this.__boundSlotChangeListener) {
this.__boundSlotChangeListener = this._onSlotChange.bind(this);
}
this.shadowRoot.addEventListener('slotchange', this.__boundSlotChangeListener);
}
}

/** @protected */
disconnectedCallback() {
if (this.__boundSlotChangeListener) {
this.shadowRoot.removeEventListener('slotchange', this.__boundSlotChangeListener);
}
super.disconnectedCallback();
}
}

Expand Down
Binary file added packages/card/test/visual/card-image.avif
Binary file not shown.
Loading