-
Notifications
You must be signed in to change notification settings - Fork 83
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
base: main
Are you sure you want to change the base?
[feat] Card slots #8233
Changes from all commits
03b759c
af7e9c4
4b085d9
476f73b
0a6dcd2
906c67e
bf28c91
d5b3bad
7b0359b
52103aa
a441654
3ce3ebd
809aea2
66b8b4b
53a81fa
ebf16f9
32ea420
88ec3be
c457bff
8f0f7f7
46a67f0
97c0048
28eb9d4
e6f28c1
c97da29
e6307c9
d7f7e8b
f43304f
8f4e7ec
a93ebfd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
`; | ||
} | ||
|
||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if we want to provide |
||
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])')); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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); | ||
} | ||
Comment on lines
+346
to
+351
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It could be unexpected to have host attributes on some browsers and not on others (as also mentioned above). If we don't want to have the attributes in place for all browsers, an alternative could be to wrap the shadow root content inside an element with |
||
} | ||
|
||
/** @protected */ | ||
disconnectedCallback() { | ||
if (this.__boundSlotChangeListener) { | ||
this.shadowRoot.removeEventListener('slotchange', this.__boundSlotChangeListener); | ||
} | ||
super.disconnectedCallback(); | ||
} | ||
Comment on lines
+354
to
360
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There shouldn't be a need to remove these local listeners. If we take the wrapper approach suggested above, it could use |
||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: could run
npx prettier --write dev/card.html
for this