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 26 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a63e7e6
vertical orientation works alright, horizontal has issues
jouni Nov 27, 2024
09c71c8
remove commented code
jouni Nov 27, 2024
2898c52
Update card.html
jouni Nov 27, 2024
e92f5dd
Update card.html
jouni Nov 27, 2024
34afedc
keep media as the first child
jouni Dec 2, 2024
8edd4e3
experiment with custom properties (no horizontal version)
jouni Dec 2, 2024
4d3f26a
robust prototype with slots and shadow parts
jouni Dec 4, 2024
899fa65
Merge branch 'main' into card-proto
jouni Dec 5, 2024
789bf99
improve dev playground
jouni Dec 10, 2024
ae35afe
switch cover-media <-> stretch-media variant names
jouni Dec 13, 2024
54ef765
fix material theme
jouni Dec 13, 2024
7cc9f34
fix: run mutation observer once when connected
jouni Dec 13, 2024
e986226
fix a couple of sonarcloud issues
jouni Dec 16, 2024
6561311
update visual test for Lumo
jouni Dec 17, 2024
d64f975
remove duplicate visual test
jouni Dec 17, 2024
559d68a
update visual test for Material
jouni Dec 17, 2024
14c0248
use Lumo-specific component imports in Lumo tests
jouni Dec 17, 2024
a56433e
fix CSS lint error
jouni Dec 17, 2024
ca48b34
remove TODOs
jouni Dec 17, 2024
596edd5
First draft of JSDoc
jouni Dec 18, 2024
ba98bec
prefix internal custom props with underscore
jouni Dec 18, 2024
1ac1a93
refactor: use slotchange listener instead of MutationObserver
jouni Dec 18, 2024
b603031
test: update screenshots
web-padawan Dec 19, 2024
36a1cc7
test: import badge-global in card visual test
web-padawan Dec 19, 2024
e9bad8b
test: remove Lumo custom CSS properties from Material test
web-padawan Dec 19, 2024
972e502
test: cleanup card visual test, use local image (#8369)
web-padawan Dec 19, 2024
73a11a4
fix: restore styles for hidden attribute
web-padawan Dec 19, 2024
9a7c9dc
docs: add protected JSDoc annotations
web-padawan Dec 19, 2024
dc8d1d2
refactor: add box-sizing border-box to card styles
web-padawan Dec 20, 2024
50e0fa0
test: update visual test screenshots
web-padawan Dec 20, 2024
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.

315 changes: 309 additions & 6 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,11 +57,233 @@ class Card extends ElementMixin(ThemableMixin(PolylitMixin(LitElement))) {
static get styles() {
return css`
:host {
display: flex;
flex-direction: column;
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(: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([hidden]) {
display: none !important;
web-padawan marked this conversation as resolved.
Show resolved Hide resolved
: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 +294,62 @@ 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"]'));
}

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);
}
}

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
Loading