diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index 79133525cd..7697a365e8 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -198,6 +198,17 @@ export namespace Components { "type"?: CalloutType; } // @beta + export interface LimelCard { + "actions"?: Array; + "clickable": boolean; + "heading"?: string; + "icon"?: string | Icon; + "image"?: Image_2; + "orientation": 'landscape' | 'portrait'; + "subheading"?: string; + "value"?: string; + } + // @beta export interface LimelChart { "accessibleItemsLabel"?: string; "accessibleLabel"?: string; @@ -985,6 +996,10 @@ namespace JSX_2 { "limel-button-group": LimelButtonGroup; // (undocumented) "limel-callout": LimelCallout; + // Warning: (ae-incompatible-release-tags) The symbol ""limel-card"" is marked as @public, but its signature references "JSX_2" which is marked as @beta + // + // (undocumented) + "limel-card": LimelCard; // Warning: (ae-incompatible-release-tags) The symbol ""limel-chart"" is marked as @public, but its signature references "JSX_2" which is marked as @beta // // (undocumented) @@ -1167,6 +1182,18 @@ namespace JSX_2 { "type"?: CalloutType; } // @beta + interface LimelCard { + "actions"?: Array; + "clickable"?: boolean; + "heading"?: string; + "icon"?: string | Icon; + "image"?: Image_2; + "onActionSelected"?: (event: LimelCardCustomEvent) => void; + "orientation"?: 'landscape' | 'portrait'; + "subheading"?: string; + "value"?: string; + } + // @beta interface LimelChart { "accessibleItemsLabel"?: string; "accessibleLabel"?: string; @@ -1839,6 +1866,16 @@ export interface LimelButtonGroupCustomEvent extends CustomEvent { target: HTMLLimelButtonGroupElement; } +// Warning: (ae-missing-release-tag) "LimelCardCustomEvent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface LimelCardCustomEvent extends CustomEvent { + // (undocumented) + detail: T; + // (undocumented) + target: HTMLLimelCardElement; +} + // Warning: (ae-missing-release-tag) "LimelCheckboxCustomEvent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2249,10 +2286,12 @@ export interface ListItem { } // @public -export interface ListSeparator { +interface ListSeparator { separator: true; text?: string; } +export { ListSeparator } +export { ListSeparator as ListSeparator1 } // @public export type ListType = 'selectable' | 'radio' | 'checkbox'; diff --git a/src/components/card/card.scss b/src/components/card/card.scss new file mode 100644 index 0000000000..4b159c702b --- /dev/null +++ b/src/components/card/card.scss @@ -0,0 +1,148 @@ +/** +* @prop --card-heading-color: color of the heading. Defaults to `--contrast-1100`; +* @prop --card-subheading-color: color of the sub heading. Defaults to `--contrast-1000`; +* @prop --card-border-radius: border radius of the card. Defaults to `0.95rem`; +* @prop --card-background-color: background color of the card. +*/ + +@use '../../style/mixins'; + +$default-border-radius: 0.95rem; + +* { + box-sizing: border-box; + min-width: 0; + min-height: 0; +} + +:host(limel-card) { + display: flex; + border-radius: var(--card-border-radius, $default-border-radius); +} + +section { + box-sizing: border-box; + @include mixins.visualize-keyboard-focus; + + display: flex; + gap: 0.5rem; + + flex-direction: column; + :host(limel-card[orientation='landscape']) & { + flex-direction: row; + } + + border-radius: var(--card-border-radius, $default-border-radius); + border: 1px solid rgb(var(--contrast-500)); + + padding: 0.25rem; + background-color: var( + --card-background-color, + var(--lime-elevated-surface-background-color) + ); + + :host(limel-card[clickable]:not([disabled='true']):not([disabled])) & { + @include mixins.is-flat-clickable( + $background-color: + var( + --card-background-color, + var(--lime-elevated-surface-background-color) + ), + $background-color--hovered: + var( + --card-background-color, + var(--lime-elevated-surface-background-color) + ) + ); + } + + :host(limel-card[clickable]:hover) & { + border-color: transparent; + } +} + +.body { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +img { + transition: filter 0.6s ease; + object-fit: cover; + border-radius: calc( + var(--card-border-radius, $default-border-radius) / 1.4 + ); + :host(limel-card[orientation='portrait']) & { + width: 100%; + } + + :host(limel-card[orientation='landscape']) & { + max-width: 40%; + height: 100%; + } + + :host(limel-card:hover) & { + transition-duration: 0.2s; + filter: saturate(1.3); + } +} + +limel-markdown { + padding: 0.5rem 0.75rem; +} + +header { + display: flex; + justify-content: center; + + gap: 0.5rem; + + padding: 0.25rem 0.75rem; + :host(limel-card[orientation='landscape']) & { + padding-top: 0.5rem; + } + + &:has(limel-icon) { + padding-left: 0.25rem; + } + + .headings { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + limel-icon { + flex-shrink: 0; + width: 2rem; + } + + h1 { + font-size: 1.125rem; + font-weight: 500; + color: var(--card-heading-color, rgb(var(--contrast-1100))); + letter-spacing: -0.03125rem; // 0.5px + } + + h2 { + font-size: 0.875rem; + font-weight: 400; + color: var(--card-subheading-color, rgb(var(--contrast-1000))); + } + + h1, + h2 { + word-break: break-word; + hyphens: auto; + -webkit-hyphens: auto; + margin: 0; + } +} + +limel-action-bar { + padding: 0.5rem; + margin-left: auto; +} diff --git a/src/components/card/card.tsx b/src/components/card/card.tsx new file mode 100644 index 0000000000..1322c02c7f --- /dev/null +++ b/src/components/card/card.tsx @@ -0,0 +1,193 @@ +import { Component, h, Prop, Event, EventEmitter } from '@stencil/core'; +import { Image } from '../../global/shared-types/image.types'; +import { Icon } from '../../global/shared-types/icon.types'; +import { isItem } from '../action-bar/isItem'; +import { getIconName } from '../icon/get-icon-props'; +import { ListSeparator } from '../../global/shared-types/separator.types'; +import { ActionBarItem } from '../action-bar/action-bar.types'; + +/** + * Card is a component that displays content about a single topic, + * in a structured way. It can contain a header, and some supporting media such + * as an image or an icon, a body of text, or optional actions. + * + * @exampleComponent limel-example-card-basic + * @exampleComponent limel-example-card-image + * @exampleComponent limel-example-card-actions + * @exampleComponent limel-example-card-clickable + * @exampleComponent limel-example-card-orientation + * @exampleComponent limel-example-card-slot + * @exampleComponent limel-example-card-styling + * @beta + */ +@Component({ + tag: 'limel-card', + shadow: true, + styleUrl: 'card.scss', +}) +export class Card { + /** + * Heading of the card, + * to provide a short title about the context. + */ + @Prop({ reflect: true }) + public heading?: string; + + /** + * Subheading of the card + * to provide a short description of the context. + */ + @Prop({ reflect: true }) + public subheading?: string; + + /** + * A hero image to display in the card, + * to enrich the content with visual information. + */ + @Prop() + public image?: Image; + + /** + * An icon, to display along with the heading and subheading. + */ + @Prop({ reflect: true }) + public icon?: string | Icon; + + /** + * The content of the card. + * Supports markdown, to provide a rich text experience. + */ + @Prop() + public value?: string; + + /** + * Actions to display in the card, + * to provide the user with options to interact with the content. + */ + @Prop() + public actions?: Array = []; + + /** + * When true, improve the accessibility of the component and hints the user + * that the card can be interacted width. + */ + @Prop({ reflect: true }) + public clickable: boolean = false; + + /** + * The orientation of the card, + * specially useful when the card has an image. + */ + @Prop({ reflect: true }) + public orientation: 'landscape' | 'portrait' = 'portrait'; + + /** + * Fired when a action bar item has been clicked. + */ + @Event() + public actionSelected: EventEmitter; + + public render() { + return ( +
+ {this.renderImage()} +
+ {this.renderHeader()} + {this.renderSlot()} + {this.renderValue()} + {this.renderActionBar()} +
+
+ ); + } + + private renderImage() { + if (!this.image) { + return; + } + + return {this.image.alt}; + } + + private renderHeader() { + if (!this.heading && !this.subheading && !this.icon) { + return; + } + + return ( +
+ {this.renderIcon()} +
+ {this.renderHeading()} + {this.renderSubheading()} +
+
+ ); + } + + private renderIcon() { + const icon = getIconName(this.icon); + const color = + typeof this.icon !== 'string' ? this.icon?.color : undefined; + + if (!icon) { + return; + } + + return ( + + ); + } + + private renderHeading() { + if (!this.heading) { + return; + } + + return

{this.heading}

; + } + + private renderSubheading() { + if (!this.subheading) { + return; + } + + return

{this.subheading}

; + } + + private renderSlot() { + return ; + } + + private renderValue() { + return ; + } + + private handleActionSelect = ( + event: CustomEvent, + ) => { + event.stopPropagation(); + if (isItem(event.detail)) { + this.actionSelected.emit(event.detail); + } + }; + + private renderActionBar() { + if (!this.actions.length) { + return; + } + + return ( + + ); + } +} diff --git a/src/components/card/examples/card-actions.tsx b/src/components/card/examples/card-actions.tsx new file mode 100644 index 0000000000..1639e35bd8 --- /dev/null +++ b/src/components/card/examples/card-actions.tsx @@ -0,0 +1,58 @@ +import { Component, h, State } from '@stencil/core'; +import { ActionBarItem, ListSeparator } from '@limetech/lime-elements'; +/** + * Card with actions + * An array of actions can be provided to the card, to allow the user to interact with the content. + * + * :::note + * Even though cards allow displaying multiple actions, + * use this possibility sparingly, and remember that these UI elements are + * meant to be entry points to other contexts, + * in which detailed information is displayed, and more complex actions + * are possible to do. + * ::: + */ + +@Component({ + shadow: true, + tag: 'limel-example-card-actions', + styleUrl: 'card-basic.scss', +}) +export class CardActionsExample { + @State() + private actions: Array = [ + { + text: 'Learn more', + }, + { + text: 'Get tickets', + icon: { + name: 'two_tickets', + color: 'rgb(var(--color-blue-default))', + }, + }, + ]; + + public render() { + const image = { + src: 'https://images.unsplash.com/photo-1515017804404-92b19fdfe6ac?q=80&w=2525&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + alt: 'A bird-eye view picture of a tennis court with a net in the middle.', + }; + + return ( + + ); + } + + private handleSelected = (event: CustomEvent) => { + event.stopPropagation(); + console.log(event.detail); + }; +} diff --git a/src/components/card/examples/card-basic.scss b/src/components/card/examples/card-basic.scss new file mode 100644 index 0000000000..a23b2e4bea --- /dev/null +++ b/src/components/card/examples/card-basic.scss @@ -0,0 +1 @@ +@import './card-resizable-container'; diff --git a/src/components/card/examples/card-basic.tsx b/src/components/card/examples/card-basic.tsx new file mode 100644 index 0000000000..30a926b840 --- /dev/null +++ b/src/components/card/examples/card-basic.tsx @@ -0,0 +1,33 @@ +import { Component, h } from '@stencil/core'; +/** + * Basic example + * Cards can be used to show some information in a static manner, + * for instance when displaying a grid of cards, each of which is + * providing a brief summary of a topic. + * + * However, the most common use cases of these UI components is to + * provide a media-rich and interactive experience to the user, + * which you can see in next examples. + */ +@Component({ + shadow: true, + tag: 'limel-example-card-basic', + styleUrl: 'card-basic.scss', +}) +export class CardBasicsExample { + public render() { + const icon = { + name: '-lime-logo-elements', + title: 'Logo of Lime Elements', + }; + + return ( + + ); + } +} diff --git a/src/components/card/examples/card-clickable.tsx b/src/components/card/examples/card-clickable.tsx new file mode 100644 index 0000000000..5d3c2002dc --- /dev/null +++ b/src/components/card/examples/card-clickable.tsx @@ -0,0 +1,42 @@ +import { Component, h } from '@stencil/core'; +/** + * Clickable example + * Sometimes you want to make the entire surface of the card to be clickable, + * for example to navigate the user to another page or show more information. + * + * For such scenarios, make sure to set the `clickable` property to `true`. + * This will alter the visual style to properly communicate hover effects and cursor styles to the card. + * + * :::important + * It might not be a good idea to combine clickable cards with actions, as it can confuse the user. + * ::: + */ +@Component({ + shadow: true, + tag: 'limel-example-card-clickable', + styleUrl: 'card-basic.scss', +}) +export class CardClickableExample { + public render() { + const image = { + src: 'https://images.unsplash.com/photo-1494232410401-ad00d5433cfa?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + alt: 'A picture of an old cassette tape', + loading: 'lazy' as 'lazy', + }; + + return ( + + ); + } + + private handleClick = () => { + console.log('Card clicked'); + }; +} diff --git a/src/components/card/examples/card-image.tsx b/src/components/card/examples/card-image.tsx new file mode 100644 index 0000000000..e8e429c6a3 --- /dev/null +++ b/src/components/card/examples/card-image.tsx @@ -0,0 +1,38 @@ +import { Component, h } from '@stencil/core'; +/** + * Featuring a hero image + * The content of the cards should be organized to allow users to + * easily scan and quickly find relevant and actionable information. + * This is especially important because cards are often used in a grid layout, + * in which many cards are usually present. + * + * Elements like text and images should clearly indicate information hierarchy. + * + * :::note + * - The height and aspect ratio of the image affects the layout of the card. + * - Remember to provide a meaningful alt text, to improve accessibility + * ::: + */ +@Component({ + shadow: true, + tag: 'limel-example-card-image', + styleUrl: 'card-basic.scss', +}) +export class CardImageExample { + public render() { + const image = { + src: 'https://unsplash.it/800/800/?random', + alt: 'Remember to provide a meaningful alt text, to improve accessibility', + loading: 'lazy' as 'lazy', + }; + + return ( + + ); + } +} diff --git a/src/components/card/examples/card-nested-component.scss b/src/components/card/examples/card-nested-component.scss new file mode 100644 index 0000000000..e462af439f --- /dev/null +++ b/src/components/card/examples/card-nested-component.scss @@ -0,0 +1,20 @@ +:host(limel-example-card-nested-component) { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 1rem; +} + +limel-slider { + width: 100%; +} + +:host(limel-example-card-nested-component.on-pink-background) { + --lime-primary-color: rgb(var(--color-purple-default)); + + limel-action-bar { + --action-bar-background-color: transparent; + --action-bar-item-text-color: rgb(var(--color-white)); + } +} diff --git a/src/components/card/examples/card-nested-component.tsx b/src/components/card/examples/card-nested-component.tsx new file mode 100644 index 0000000000..0149c100db --- /dev/null +++ b/src/components/card/examples/card-nested-component.tsx @@ -0,0 +1,49 @@ +import { Component, h, State } from '@stencil/core'; +import { ActionBarItem, ListSeparator } from '@limetech/lime-elements'; + +@Component({ + tag: 'limel-example-card-nested-component', + shadow: true, + styleUrl: 'card-nested-component.scss', +}) +export class CardNestedComponentExample { + @State() + private actionBarItems: Array = [ + { + text: 'Previous', + icon: '-lime-filter-previous', + iconOnly: true, + }, + { + text: 'Play', + icon: 'play', + iconOnly: true, + }, + { + text: 'Next', + icon: '-lime-filter-next', + iconOnly: true, + }, + { separator: true }, + { + text: 'Repeat', + icon: 'repeat_one', + iconOnly: true, + }, + { + text: 'Shuffle', + icon: 'shuffle', + iconOnly: true, + }, + ]; + + public render() { + return [ + , + , + ]; + } +} diff --git a/src/components/card/examples/card-orientation.tsx b/src/components/card/examples/card-orientation.tsx new file mode 100644 index 0000000000..f44e17e117 --- /dev/null +++ b/src/components/card/examples/card-orientation.tsx @@ -0,0 +1,36 @@ +import { Component, h } from '@stencil/core'; +/** + * Using the `orientation` prop + * The `orientation` prop can be used to change the layout of the card, + * and is specially useful when the card is displaying images. + * + * By default, the card has a `portrait` orientation, which will render the + * image on top of the content, filling the entire width of the card. + * However, when it is changed to `landscape`, the image will be displayed + * to the left of the content, filling the entire height of the card, + * and maximum width of 40% of the card. + */ +@Component({ + shadow: true, + tag: 'limel-example-card-orientation', + styleUrl: 'card-basic.scss', +}) +export class CardOrientationExample { + public render() { + const image = { + src: 'https://images.unsplash.com/photo-1484755560615-a4c64e778a6c?q=80&w=2778&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + alt: 'A picture of a girl, listening to music with headphones', + loading: 'lazy' as 'lazy', + }; + + return ( + + ); + } +} diff --git a/src/components/card/examples/card-resizable-container.scss b/src/components/card/examples/card-resizable-container.scss new file mode 100644 index 0000000000..ba17d65028 --- /dev/null +++ b/src/components/card/examples/card-resizable-container.scss @@ -0,0 +1,35 @@ +:host { + display: block; + position: relative; + resize: both; + overflow: auto; + + box-sizing: border-box; + + min-width: 10rem; + width: 20rem; + max-width: 100%; + + min-height: 5rem; + height: auto; + max-height: 50rem; + + padding: 1rem 1rem 3rem 1rem; + border: 0.125rem dashed rgb(var(--contrast-500)); + + border-radius: 0.5rem; + + &::after { + content: 'Resize me ⤵'; + font-size: 0.75rem; + position: absolute; + right: 0.25rem; + bottom: 0.25rem; + } +} + +:host(limel-example-card-orientation), +:host(limel-example-card-slot), +:host(limel-example-card-styling) { + width: 100%; +} diff --git a/src/components/card/examples/card-slot.tsx b/src/components/card/examples/card-slot.tsx new file mode 100644 index 0000000000..6f8495dd10 --- /dev/null +++ b/src/components/card/examples/card-slot.tsx @@ -0,0 +1,32 @@ +import { Component, h } from '@stencil/core'; +/** + * Nesting a component in the card + * You can nest any component inside the card, to provide a more complex + * and interactive experience to the user. + */ +@Component({ + shadow: true, + tag: 'limel-example-card-slot', + styleUrl: 'card-basic.scss', +}) +export class CardSlotExample { + public render() { + const image = { + src: 'https://images.unsplash.com/photo-1484755560615-a4c64e778a6c?q=80&w=2778&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + alt: 'A picture of a girl, listening to music with headphones', + loading: 'lazy' as 'lazy', + }; + + return ( + + + + ); + } +} diff --git a/src/components/card/examples/card-styling.scss b/src/components/card/examples/card-styling.scss new file mode 100644 index 0000000000..d54cfa5465 --- /dev/null +++ b/src/components/card/examples/card-styling.scss @@ -0,0 +1,12 @@ +@import './card-resizable-container'; + +limel-card { + --card-border-radius: 1.25rem; + --card-background-color: rgb(var(--color-pink-light)); + --card-heading-color: rgb(var(--color-yellow-default)); + --card-subheading-color: rgb(var(--color-yellow-lighter)); + + color: rgb( + var(--color-pink-lighter) + ); //overrides the default body text color +} diff --git a/src/components/card/examples/card-styling.tsx b/src/components/card/examples/card-styling.tsx new file mode 100644 index 0000000000..ecbfb3fac7 --- /dev/null +++ b/src/components/card/examples/card-styling.tsx @@ -0,0 +1,35 @@ +import { Component, h } from '@stencil/core'; +/** + * Styling + * The component offers a few styling options in form of custom CSS variables, + * to make it fit better in different contexts. + */ +@Component({ + shadow: true, + tag: 'limel-example-card-styling', + styleUrl: 'card-styling.scss', +}) +export class CardStylingExample { + public render() { + const image = { + src: 'https://images.unsplash.com/photo-1484755560615-a4c64e778a6c?q=80&w=2778&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + alt: 'A picture of a girl, listening to music with headphones', + loading: 'lazy' as 'lazy', + }; + + return ( + + + + ); + } +}