Files
WRNexusJS/packages/ui/components/Card.wrn
T

674 lines
21 KiB
Plaintext

component Card {
outputs {
click(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
action(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
navigate(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
dismiss(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
load(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
error(payload: { error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)
}
props {
title: string = "Card title"
subtitle: string = ""
description: string = ""
header: string = ""
footer: string = ""
imageSrc: string = ""
imageAlt: string = ""
imagePosition: string = "top"
actionLabel: string = ""
actionHref: string = ""
headerActions: unknown[] = []
navigation: unknown[] = []
activeNav: string = ""
mobileNavigation: boolean = false
alertTitle: string = ""
alertDescription: string = ""
empty: boolean = false
emptyTitle: string = "No data to show"
emptyIcon: string = "icon-[lucide--inbox]"
items: unknown[] = []
size: string = "md"
color: string = "primary"
variant: string = "default"
layout: string = "vertical"
align: string = "left"
hover: string = "none"
scrollable: boolean = false
maxHeight: string = "18rem"
dismissible: boolean = false
ariaLabel: string = ""
class: string = ""
}
state dismissed: boolean = false
functions {
// Outputs must go through output.*; a CustomEvent dispatched on the root
// bubbles past every parent @binding without being seen, because the
// runtime keeps parent handlers in a registry only the output proxy reads.
client function dispatchCardNavigation(sourceEvent, item, index) {
output.navigate({ component: "Card", item: item, index: index })
}
client function dispatchCardNavigationValue(sourceEvent) {
output.navigate({
component: "Card",
value: sourceEvent.currentTarget.value
})
}
client function dispatchCardHeaderAction(sourceEvent, action, index) {
output.action({ component: "Card", action: action, index: index })
}
client function dismissCard() {
dismissed = true
output.dismiss({ component: "Card", title: title })
}
}
view {
<div
{...attrs}
data-ui-component="Card"
data-wrn-card
data-size='{size}'
data-color='{color}'
data-variant='{variant}'
data-layout='{layout}'
data-hover='{hover}'
data-align='{align}'
class='wire-next wire-next--card wire-next--color-{color} wire-next--size-{size} wire-next--variant-{variant} {class}'
>
{#if items.length > 0}
<div class="wire-next__card-group">
{#each items as item, itemIndex}
<article
class="wire-next__card-panel"
aria-label='{item.ariaLabel || item.title || item.label || "Card item"}'
data-index='{itemIndex}'
>
{#if item.imageSrc || item.image}
<div class="wire-next__card-media">
<img
src='{item.imageSrc || item.image}'
alt='{item.imageAlt || item.alt || ""}'
loading='{item.loading || "lazy"}'
decoding="async"
@load='output.load({ item: item, index: itemIndex })'
@error='output.error({ item: item, index: itemIndex })'
/>
</div>
{/if}
<div class="wire-next__card-content">
{#if item.title || item.label}
<h3>{item.title || item.label}</h3>
{/if}
{#if item.subtitle}
<p class="wire-next__card-subtitle">{item.subtitle}</p>
{/if}
{#if item.description}
<p class="wire-next__card-description">{item.description}</p>
{/if}
{#if item.actionLabel || item.href}
<a
href='{item.actionHref || item.href || "#"}'
class="wire-next__card-action"
@click='output.action({ item: item, index: itemIndex })'
>
<span>{item.actionLabel || "Learn more"}</span>
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
</a>
{/if}
</div>
</article>
{/each}
</div>
{:else}
<article
class="wire-next__card-panel"
aria-label='{ariaLabel || title || "Card"}'
hidden='{dismissed}'
>
{#if imageSrc && imagePosition === "overlay"}
<div class="wire-next__card-overlay">
<img
src='{imageSrc}'
alt='{imageAlt}'
loading="lazy"
decoding="async"
@load='output.load({ src: imageSrc })'
@error='output.error({ src: imageSrc })'
/>
<div class="wire-next__card-overlay-shade" aria-hidden="true"></div>
</div>
{/if}
{#if imageSrc && (imagePosition === "top" || imagePosition === "left")}
<div class="wire-next__card-media wire-next__card-media--{imagePosition}">
<img
src='{imageSrc}'
alt='{imageAlt}'
loading="lazy"
decoding="async"
@load='output.load({ src: imageSrc })'
@error='output.error({ src: imageSrc })'
/>
</div>
{/if}
<div class="wire-next__card-content">
{#if header || title || subtitle || headerActions.length > 0 || dismissible}
<header class="wire-next__card-header">
<div class="wire-next__card-heading">
{#if header}
<p class="wire-next__card-eyebrow">{header}</p>
{/if}
{#if title}
<h3>{title}</h3>
{/if}
{#if subtitle}
<p class="wire-next__card-subtitle">{subtitle}</p>
{/if}
</div>
{#if headerActions.length > 0 || dismissible}
<div class="wire-next__card-header-actions">
{#each headerActions as headerAction, actionIndex}
<button
type="button"
aria-label='{headerAction.ariaLabel || headerAction.label || "Card action"}'
@click='dispatchCardHeaderAction(event, headerAction, actionIndex)'
>
{#if headerAction.icon}
<span class='{headerAction.icon}' aria-hidden="true"></span>
{/if}
{#if headerAction.label && !headerAction.icon}
<span>{headerAction.label}</span>
{/if}
</button>
{/each}
{#if dismissible}
<button
type="button"
aria-label="Dismiss card"
@click='dismissCard(event)'
>
<span class="icon-[lucide--x]" aria-hidden="true"></span>
</button>
{/if}
</div>
{/if}
</header>
{/if}
{#if navigation.length > 0}
<nav aria-label='{ariaLabel || title || "Card navigation"}' class="wire-next__card-navigation">
{#if mobileNavigation}
<select
class="wire-next__card-navigation-select"
aria-label='{ariaLabel || title || "Card navigation"}'
@change='dispatchCardNavigationValue(event)'
>
{#each navigation as navItem}
<option
value='{navItem.value || navItem.href || navItem.label}'
selected='{activeNav === navItem.value || activeNav === navItem.href}'
>
{navItem.label}
</option>
{/each}
</select>
{/if}
<div class="wire-next__card-tabs" data-mobile-navigation='{mobileNavigation}'>
{#each navigation as navItem, navIndex}
<button
type="button"
aria-pressed='{activeNav === navItem.value || activeNav === navItem.href}'
@click='dispatchCardNavigation(event, navItem, navIndex)'
>
{#if navItem.icon}
<span class='{navItem.icon}' aria-hidden="true"></span>
{/if}
<span>{navItem.label}</span>
{#if navItem.badge}
<span class="wire-next__card-tab-badge">{navItem.badge}</span>
{/if}
</button>
{/each}
</div>
</nav>
{/if}
{#if alertTitle || alertDescription}
<div class="wire-next__card-alert" role="status">
{#if alertTitle}
<strong>{alertTitle}</strong>
{/if}
{#if alertDescription}
<p>{alertDescription}</p>
{/if}
</div>
{/if}
<div
class="wire-next__card-body"
data-scrollable='{scrollable}'
style='max-height: {scrollable ? maxHeight : "none"};'
>
{#if empty}
<div class="wire-next__card-empty">
<span class='{emptyIcon}' aria-hidden="true"></span>
<p>{emptyTitle}</p>
</div>
{:else}
{#if description}
<p class="wire-next__card-description">{description}</p>
{/if}
<slot></slot>
{/if}
</div>
{#if footer || actionLabel}
<footer class="wire-next__card-footer">
{#if footer}
<span>{footer}</span>
{/if}
{#if actionLabel}
<a
href='{actionHref || "#"}'
class="wire-next__card-action"
@click='output.action({ href: actionHref, label: actionLabel })'
>
<span>{actionLabel}</span>
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
</a>
{/if}
</footer>
{/if}
</div>
{#if imageSrc && (imagePosition === "bottom" || imagePosition === "right")}
<div class="wire-next__card-media wire-next__card-media--{imagePosition}">
<img
src='{imageSrc}'
alt='{imageAlt}'
loading="lazy"
decoding="async"
@load='output.load({ src: imageSrc })'
@error='output.error({ src: imageSrc })'
/>
</div>
{/if}
</article>
{/if}
</div>
}
style {
.wire-next__card-media--overlay {
aspect-ratio: 16 / 9;
min-height: 18rem;
}
.wire-next__card-media--overlay > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
@media (max-width: 480px) {
.wire-next__card-media--overlay {
aspect-ratio: 4 / 3;
min-height: 16rem;
}
}
.wire-next--card {
--wire-card-color: var(--wire-component-color, var(--wire-color-primary));
--wire-card-padding: 1.25rem;
display: block;
width: 100%;
color: var(--wire-color-text);
}
.wire-next--card[data-size="sm"] {
--wire-card-padding: 0.85rem;
}
.wire-next--card[data-size="lg"] {
--wire-card-padding: 1.75rem;
}
.wire-next__card-panel {
position: relative;
display: flex;
overflow: hidden;
flex-direction: column;
width: 100%;
min-width: 0;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-md);
background: var(--wire-color-surface);
box-shadow: var(--wire-shadow-1);
transition:
transform var(--wire-motion-base) var(--wire-ease-emphasized),
border-color var(--wire-motion-base) ease,
box-shadow var(--wire-motion-base) var(--wire-ease-emphasized);
}
.wire-next--card[data-variant="top-border"] > .wire-next__card-panel,
.wire-next--card[data-variant="accent"] > .wire-next__card-panel {
border-top: 4px solid var(--wire-card-color);
}
.wire-next--card[data-hover="raise"] > .wire-next__card-panel:hover {
border-color: color-mix(in srgb, var(--wire-card-color) 38%, var(--wire-color-border));
box-shadow: var(--wire-shadow-3);
transform: translateY(-4px);
}
.wire-next__card-header,
.wire-next__card-footer {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding: 0.85rem var(--wire-card-padding);
background: var(--wire-color-surface-2);
color: var(--wire-color-muted);
font-size: 0.875rem;
}
.wire-next__card-header {
border-bottom: 1px solid var(--wire-color-border);
}
.wire-next__card-footer {
margin-top: auto;
border-top: 1px solid var(--wire-color-border);
}
.wire-next__card-header-actions {
display: inline-flex;
gap: 0.2rem;
align-items: center;
}
.wire-next__card-header-actions button {
display: inline-grid;
width: 2rem;
height: 2rem;
border: 0;
border-radius: var(--wire-radius-sm);
background: transparent;
color: var(--wire-color-muted);
cursor: pointer;
place-items: center;
}
.wire-next__card-header-actions button:hover {
background: color-mix(in srgb, var(--wire-card-color) 12%, transparent);
color: var(--wire-card-color);
}
.wire-next__card-header-actions button:focus-visible {
outline: 2px solid var(--wire-card-color);
outline-offset: 1px;
}
.wire-next__card-media {
position: relative;
overflow: hidden;
min-height: 10rem;
background: var(--wire-color-surface-2);
}
.wire-next__card-media > img {
display: block;
width: 100%;
height: 100%;
min-height: inherit;
object-fit: cover;
transition: transform 420ms var(--wire-ease-emphasized);
}
.wire-next--card[data-hover="image"] .wire-next__card-panel:hover .wire-next__card-media > img {
transform: scale(1.06);
}
.wire-next__card-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: clamp(1.25rem, 4vw, 2rem);
background: linear-gradient(
180deg,
rgb(2 6 23 / 0.9) 0%,
rgb(2 6 23 / 0.68) 38%,
rgb(2 6 23 / 0.34) 58%,
rgb(2 6 23 / 0.86) 100%
);
color: white;
text-shadow: 0 1px 2px rgb(0 0 0 / 0.42);
}
.wire-next__card-overlay h3 {
color: white;
font-size: clamp(1.1rem, 2.2vw, 1.35rem);
}
.wire-next__card-overlay p {
max-width: 48rem;
color: #fff;
font-weight: 500;
line-height: 1.6;
text-shadow:
0 1px 2px rgb(0 0 0 / 0.9),
0 2px 8px rgb(0 0 0 / 0.62);
}
.wire-next__card-overlay .wire-next__card-subtitle {
color: rgb(255 255 255 / 0.78);
}
.wire-next__card-overlay small {
margin-top: auto;
color: rgb(255 255 255 / 0.88);
font-weight: 600;
text-shadow: 0 1px 2px rgb(0 0 0 / 0.55);
}
.wire-next__card-body {
display: grid;
gap: 0.65rem;
min-width: 0;
padding: var(--wire-card-padding);
}
.wire-next__card-body[data-scrollable="true"] {
max-height: var(--wire-card-max-height);
overflow: auto;
overscroll-behavior: contain;
scrollbar-color: var(--wire-card-color) transparent;
scrollbar-width: thin;
}
.wire-next__card-body h3,
.wire-next__card-body p,
.wire-next__card-overlay h3,
.wire-next__card-overlay p {
margin: 0;
}
.wire-next__card-body p {
color: var(--wire-color-muted);
line-height: 1.65;
}
.wire-next__card-subtitle {
color: var(--wire-color-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.wire-next__card-action {
display: inline-flex;
gap: 0.25rem;
align-items: center;
width: fit-content;
margin-top: 0.2rem;
color: var(--wire-card-color);
font-weight: 700;
text-decoration: none;
}
.wire-next__card-action:hover {
text-decoration: underline;
text-underline-offset: 0.2em;
}
.wire-next__card-navigation {
background: var(--wire-color-surface-2);
}
.wire-next__card-tabs {
display: flex;
overflow-x: auto;
}
.wire-next__card-tabs button {
min-height: 3rem;
flex: 1 0 auto;
padding-inline: 1rem;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--wire-color-muted);
cursor: pointer;
font-weight: 700;
}
.wire-next__card-tabs button[data-active="true"] {
border-color: var(--wire-card-color);
color: var(--wire-color-text);
}
.wire-next__card-navigation-select {
display: none;
width: calc(100% - 2rem);
min-height: 2.75rem;
margin: 1rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-sm);
background: var(--wire-color-surface);
color: var(--wire-color-text);
}
.wire-next__card-alert {
display: flex;
gap: 0.35rem;
padding: 0.8rem var(--wire-card-padding);
background: color-mix(in srgb, var(--wire-card-color) 14%, var(--wire-color-surface-2));
color: var(--wire-color-text);
font-size: 0.875rem;
}
.wire-next__card-empty {
display: grid;
min-height: 12rem;
color: var(--wire-color-muted);
place-content: center;
place-items: center;
}
.wire-next__card-empty > span {
margin-bottom: 0.75rem;
font-size: 2.25rem;
}
.wire-next--card[data-align="center"] .wire-next__card-body {
text-align: center;
place-items: center;
}
.wire-next--card[data-layout="horizontal"] > .wire-next__card-panel {
display: grid;
grid-template-columns: minmax(12rem, 42%) minmax(0, 1fr);
}
.wire-next--card[data-layout="horizontal"] .wire-next__card-media {
grid-row: 1 / span 4;
min-height: 100%;
}
.wire-next__card-group {
display: grid;
grid-template-columns: repeat(var(--wire-card-columns, 3), minmax(0, 1fr));
}
.wire-next__card-group > .wire-next__card-panel {
border-radius: 0;
box-shadow: none;
}
.wire-next__card-group > .wire-next__card-panel:not(:first-child) {
margin-inline-start: -1px;
}
.wire-next__card-group > .wire-next__card-panel:first-child {
border-start-start-radius: var(--wire-radius-md);
border-end-start-radius: var(--wire-radius-md);
}
.wire-next__card-group > .wire-next__card-panel:last-child {
border-start-end-radius: var(--wire-radius-md);
border-end-end-radius: var(--wire-radius-md);
}
@media (max-width: 768px) {
.wire-next--card[data-layout="horizontal"] > .wire-next__card-panel {
display: flex;
}
.wire-next--card[data-layout="horizontal"] .wire-next__card-media {
min-height: 12rem;
}
.wire-next__card-group {
grid-template-columns: 1fr;
}
.wire-next__card-group > .wire-next__card-panel:not(:first-child) {
margin-top: -1px;
margin-inline-start: 0;
}
.wire-next__card-group > .wire-next__card-panel {
border-radius: 0;
}
.wire-next__card-group > .wire-next__card-panel:first-child {
border-start-start-radius: var(--wire-radius-md);
border-start-end-radius: var(--wire-radius-md);
}
.wire-next__card-group > .wire-next__card-panel:last-child {
border-end-start-radius: var(--wire-radius-md);
border-end-end-radius: var(--wire-radius-md);
}
}
@media (max-width: 480px) {
.wire-next__card-tabs:has(+ .wire-next__card-navigation-select) {
display: none;
}
.wire-next__card-navigation-select {
display: block;
}
}
}
}