first commit
This commit is contained in:
45
src/components/display/accordion/Accordion.tsx
Normal file
45
src/components/display/accordion/Accordion.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import * as RadixAccordion from "@radix-ui/react-accordion";
|
||||
import clsx from "clsx";
|
||||
// import "./accordion.css";
|
||||
|
||||
|
||||
export type AccordionItemData = {
|
||||
value: string;
|
||||
title: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Accordion(props: {
|
||||
items: AccordionItemData[];
|
||||
type?: "single" | "multiple";
|
||||
defaultValue?: string | string[];
|
||||
collapsible?: boolean;
|
||||
className?: string;
|
||||
chevron?: React.ReactNode;
|
||||
}) {
|
||||
const { items, type = "single", defaultValue, collapsible = true, className, chevron } = props;
|
||||
|
||||
return (
|
||||
<RadixAccordion.Root
|
||||
className={clsx("ui-acc", className)}
|
||||
type={type as any}
|
||||
defaultValue={defaultValue as any}
|
||||
collapsible={collapsible}
|
||||
>
|
||||
{items.map((it) => (
|
||||
<RadixAccordion.Item key={it.value} className="ui-acc__item" value={it.value}>
|
||||
<RadixAccordion.Header>
|
||||
<RadixAccordion.Trigger className="ui-acc__trigger">
|
||||
<span className="ui-acc__title">{it.title}</span>
|
||||
<span className="ui-acc__chevron" aria-hidden>
|
||||
{chevron ?? "▾"}
|
||||
</span>
|
||||
</RadixAccordion.Trigger>
|
||||
</RadixAccordion.Header>
|
||||
<RadixAccordion.Content className="ui-acc__content">{it.content}</RadixAccordion.Content>
|
||||
</RadixAccordion.Item>
|
||||
))}
|
||||
</RadixAccordion.Root>
|
||||
);
|
||||
}
|
||||
36
src/components/display/accordion/accordion.css
Normal file
36
src/components/display/accordion/accordion.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.ui-acc{
|
||||
border: 1px solid var(--ui-border);
|
||||
background: var(--ui-panel);
|
||||
border-radius: var(--ui-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ui-acc__item{ border-top: 1px solid var(--ui-border); }
|
||||
.ui-acc__item:first-child{ border-top: 0; }
|
||||
|
||||
.ui-acc__trigger{
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--ui-fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-acc__trigger:hover{ background: rgba(255,255,255,0.04); }
|
||||
:root[data-theme="light"] .ui-acc__trigger:hover{ background: rgba(17,19,24,0.04); }
|
||||
|
||||
.ui-acc__title{ font-weight: 800; }
|
||||
.ui-acc__chevron{ color: var(--ui-muted); transition: transform .12s ease; }
|
||||
|
||||
.ui-acc__trigger[data-state="open"] .ui-acc__chevron{ transform: rotate(180deg); }
|
||||
|
||||
.ui-acc__content{
|
||||
padding: 0 12px 12px 12px;
|
||||
color: var(--ui-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
29
src/components/display/avatar/Avatar.tsx
Normal file
29
src/components/display/avatar/Avatar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./avatar.css";
|
||||
|
||||
|
||||
export type AvatarSize = 20 | 24 | 32 | 40 | 48;
|
||||
|
||||
export type AvatarProps = {
|
||||
size?: AvatarSize;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
initials?: string;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export function Avatar({
|
||||
size = 32,
|
||||
src,
|
||||
alt,
|
||||
initials,
|
||||
className,
|
||||
...rest
|
||||
}: AvatarProps) {
|
||||
return (
|
||||
<span className={clsx("ui-avatar", className)} data-size={String(size)} {...rest}>
|
||||
{src ? <img src={src} alt={alt ?? ""} loading="lazy" /> : (initials ?? "?")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
26
src/components/display/avatar/avatar.css
Normal file
26
src/components/display/avatar/avatar.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.ui-avatar{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ui-border);
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--ui-fg);
|
||||
font-weight: 800;
|
||||
user-select: none;
|
||||
}
|
||||
:root[data-theme="light"] .ui-avatar{ background: rgba(17,19,24,0.06); }
|
||||
|
||||
.ui-avatar img{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui-avatar[data-size="20"]{ width: 20px; height: 20px; font-size: 10px; }
|
||||
.ui-avatar[data-size="24"]{ width: 24px; height: 24px; font-size: 11px; }
|
||||
.ui-avatar[data-size="32"]{ width: 32px; height: 32px; font-size: 12px; }
|
||||
.ui-avatar[data-size="40"]{ width: 40px; height: 40px; font-size: 14px; }
|
||||
.ui-avatar[data-size="48"]{ width: 48px; height: 48px; font-size: 16px; }
|
||||
38
src/components/display/badge/Badge.tsx
Normal file
38
src/components/display/badge/Badge.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./badge.css";
|
||||
|
||||
|
||||
export type BadgeTone = "neutral" | "primary" | "danger";
|
||||
export type BadgeVariant = "solid" | "soft" | "outline";
|
||||
export type BadgeSize = "sm" | "md";
|
||||
|
||||
export type BadgeProps = React.HTMLAttributes<HTMLSpanElement> & {
|
||||
tone?: BadgeTone;
|
||||
variant?: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
dot?: boolean;
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
variant = "soft",
|
||||
size = "md",
|
||||
dot,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx("ui-badge", className)}
|
||||
data-tone={tone}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
{...rest}
|
||||
>
|
||||
{dot ? <span className="ui-badge__dot" aria-hidden /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
58
src/components/display/badge/badge.css
Normal file
58
src/components/display/badge/badge.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.ui-badge{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 0 10px;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-badge[data-size="sm"]{ height: 18px; padding: 0 8px; font-size: 11px; }
|
||||
.ui-badge[data-size="md"]{ height: 22px; }
|
||||
|
||||
/* variants */
|
||||
.ui-badge[data-variant="solid"]{
|
||||
background: var(--ui-fg);
|
||||
color: var(--ui-bg);
|
||||
}
|
||||
.ui-badge[data-variant="soft"]{
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--ui-fg);
|
||||
border-color: rgba(255,255,255,0.06);
|
||||
}
|
||||
:root[data-theme="light"] .ui-badge[data-variant="soft"]{
|
||||
background: rgba(17,19,24,0.06);
|
||||
border-color: rgba(17,19,24,0.08);
|
||||
}
|
||||
.ui-badge[data-variant="outline"]{
|
||||
background: transparent;
|
||||
color: var(--ui-fg);
|
||||
border-color: var(--ui-border);
|
||||
}
|
||||
|
||||
/* tones */
|
||||
.ui-badge[data-tone="primary"][data-variant="solid"]{ background: var(--ui-primary); color: var(--ui-primary-fg); }
|
||||
.ui-badge[data-tone="danger"][data-variant="solid"]{ background: var(--ui-danger); color: var(--ui-danger-fg); }
|
||||
|
||||
.ui-badge[data-tone="primary"][data-variant="soft"]{
|
||||
background: color-mix(in oklab, var(--ui-primary) 20%, transparent);
|
||||
border-color: color-mix(in oklab, var(--ui-primary) 30%, transparent);
|
||||
}
|
||||
.ui-badge[data-tone="danger"][data-variant="soft"]{
|
||||
background: color-mix(in oklab, var(--ui-danger) 18%, transparent);
|
||||
border-color: color-mix(in oklab, var(--ui-danger) 28%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge__dot{
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.9;
|
||||
}
|
||||
27
src/components/display/banner/Banner.tsx
Normal file
27
src/components/display/banner/Banner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./banner.css";
|
||||
|
||||
|
||||
export type BannerTone = "neutral" | "primary" | "danger";
|
||||
|
||||
export function Banner(props: {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
tone?: BannerTone;
|
||||
className?: string;
|
||||
}) {
|
||||
const { title, description, icon, actions, tone = "primary", className } = props;
|
||||
return (
|
||||
<div className={clsx("ui-banner", className)} data-tone={tone}>
|
||||
{icon ? <div className="ui-banner__icon" aria-hidden>{icon}</div> : null}
|
||||
<div className="ui-banner__body">
|
||||
<h4 className="ui-banner__title">{title}</h4>
|
||||
{description ? <p className="ui-banner__desc">{description}</p> : null}
|
||||
{actions ? <div className="ui-banner__actions">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/display/banner/banner.css
Normal file
29
src/components/display/banner/banner.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.ui-banner{
|
||||
background: var(--ui-panel);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius);
|
||||
box-shadow: var(--ui-shadow);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ui-banner__icon{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--ui-primary) 20%, transparent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ui-banner__body{ flex: 1; display: grid; gap: 6px; }
|
||||
.ui-banner__title{ margin: 0; font-weight: 800; font-size: 14px; }
|
||||
.ui-banner__desc{ margin: 0; color: var(--ui-muted); font-size: 13px; }
|
||||
.ui-banner__actions{ display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
|
||||
|
||||
.ui-banner[data-tone="danger"] .ui-banner__icon{
|
||||
background: color-mix(in oklab, var(--ui-danger) 20%, transparent);
|
||||
}
|
||||
16
src/components/display/content-badge/ContentBadge.tsx
Normal file
16
src/components/display/content-badge/ContentBadge.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { Badge, type BadgeProps } from "../badge/Badge";
|
||||
// import "./content-badge.css";
|
||||
|
||||
|
||||
export type ContentBadgePosition = "tl" | "tr" | "bl" | "br";
|
||||
|
||||
export function ContentBadge(props: BadgeProps & { position?: ContentBadgePosition; className?: string }) {
|
||||
const { position = "tl", className, ...rest } = props;
|
||||
return (
|
||||
<div className={clsx("ui-contentbadge", className)} data-pos={position}>
|
||||
<Badge {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/components/display/content-badge/content-badge.css
Normal file
9
src/components/display/content-badge/content-badge.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.ui-contentbadge{
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
.ui-contentbadge[data-pos="tr"]{ left: auto; right: 10px; }
|
||||
.ui-contentbadge[data-pos="bl"]{ top: auto; bottom: 10px; }
|
||||
.ui-contentbadge[data-pos="br"]{ top: auto; bottom: 10px; left: auto; right: 10px; }
|
||||
40
src/components/display/content-card/ContentCard.tsx
Normal file
40
src/components/display/content-card/ContentCard.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ImageBase } from "../image-base/ImageBase";
|
||||
// import "./content-card.css";
|
||||
|
||||
|
||||
export function ContentCard(props: {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
meta?: React.ReactNode;
|
||||
media?: { src: string; alt?: string; ratio?: number; overlay?: React.ReactNode };
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { title, subtitle, meta, media, href, onClick, className } = props;
|
||||
const clickable = Boolean(href || onClick);
|
||||
const Comp: any = href ? "a" : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={clsx("ui-contentcard", className)}
|
||||
data-clickable={clickable ? "true" : "false"}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
>
|
||||
{media ? (
|
||||
<div className="ui-contentcard__media">
|
||||
<ImageBase src={media.src} alt={media.alt} ratio={media.ratio ?? (16 / 9)} overlay={media.overlay} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="ui-contentcard__body">
|
||||
<div className="ui-contentcard__title">{title}</div>
|
||||
{subtitle ? <div className="ui-contentcard__subtitle">{subtitle}</div> : null}
|
||||
{meta ? <div className="ui-contentcard__meta">{meta}</div> : null}
|
||||
</div>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
18
src/components/display/content-card/content-card.css
Normal file
18
src/components/display/content-card/content-card.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.ui-contentcard{
|
||||
border-radius: var(--ui-radius);
|
||||
border: 1px solid var(--ui-border);
|
||||
background: var(--ui-panel);
|
||||
box-shadow: var(--ui-shadow);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: grid;
|
||||
}
|
||||
.ui-contentcard[data-clickable="true"]{ cursor: pointer; }
|
||||
.ui-contentcard[data-clickable="true"]:active{ transform: translateY(1px); }
|
||||
|
||||
.ui-contentcard__media{ position: relative; }
|
||||
.ui-contentcard__body{ padding: 12px; display: grid; gap: 6px; }
|
||||
.ui-contentcard__title{ font-weight: 900; }
|
||||
.ui-contentcard__subtitle{ color: var(--ui-muted); font-size: 13px; }
|
||||
.ui-contentcard__meta{ color: var(--ui-muted); font-size: 12px; }
|
||||
39
src/components/display/counter/Counter.tsx
Normal file
39
src/components/display/counter/Counter.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./counter.css";
|
||||
|
||||
|
||||
export type CounterTone = "danger" | "primary" | "neutral";
|
||||
export type CounterVariant = "solid" | "soft" | "outline";
|
||||
export type CounterSize = "sm" | "md";
|
||||
|
||||
export type CounterProps = React.HTMLAttributes<HTMLSpanElement> & {
|
||||
value: number;
|
||||
max?: number; // e.g. 99 -> "99+"
|
||||
tone?: CounterTone;
|
||||
variant?: CounterVariant;
|
||||
size?: CounterSize;
|
||||
};
|
||||
|
||||
export function Counter({
|
||||
value,
|
||||
max = 99,
|
||||
tone = "danger",
|
||||
variant = "solid",
|
||||
size = "md",
|
||||
className,
|
||||
...rest
|
||||
}: CounterProps) {
|
||||
const text = value > max ? `${max}+` : String(value);
|
||||
return (
|
||||
<span
|
||||
className={clsx("ui-counter", className)}
|
||||
data-tone={tone}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
{...rest}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
31
src/components/display/counter/counter.css
Normal file
31
src/components/display/counter/counter.css
Normal file
@@ -0,0 +1,31 @@
|
||||
.ui-counter{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-counter[data-variant="solid"]{ background: var(--ui-danger); color: var(--ui-danger-fg); }
|
||||
.ui-counter[data-variant="soft"]{
|
||||
background: color-mix(in oklab, var(--ui-danger) 18%, transparent);
|
||||
border-color: color-mix(in oklab, var(--ui-danger) 28%, transparent);
|
||||
color: var(--ui-fg);
|
||||
}
|
||||
.ui-counter[data-variant="outline"]{ background: transparent; border-color: var(--ui-border); color: var(--ui-fg); }
|
||||
|
||||
.ui-counter[data-size="sm"]{ min-width: 18px; height: 18px; font-size: 11px; }
|
||||
.ui-counter[data-size="md"]{ min-width: 20px; height: 20px; }
|
||||
|
||||
.ui-counter[data-tone="primary"][data-variant="solid"]{ background: var(--ui-primary); color: var(--ui-primary-fg); }
|
||||
.ui-counter[data-tone="primary"][data-variant="soft"]{
|
||||
background: color-mix(in oklab, var(--ui-primary) 18%, transparent);
|
||||
border-color: color-mix(in oklab, var(--ui-primary) 28%, transparent);
|
||||
}
|
||||
31
src/components/display/gallery/Gallery.tsx
Normal file
31
src/components/display/gallery/Gallery.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ImageBase } from "../image-base/ImageBase";
|
||||
// import "./gallery.css";
|
||||
|
||||
|
||||
export type GalleryItem = {
|
||||
id: string;
|
||||
src: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export function Gallery(props: {
|
||||
items: GalleryItem[];
|
||||
cols?: 2 | 3 | 4;
|
||||
ratio?: number;
|
||||
onItemClick?: (id: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { items, cols = 3, ratio = 1, onItemClick, className } = props;
|
||||
|
||||
return (
|
||||
<div className={clsx("ui-gallery", className)} data-cols={String(cols)}>
|
||||
{items.map((it) => (
|
||||
<div key={it.id} onClick={() => onItemClick?.(it.id)} style={{ cursor: onItemClick ? "pointer" : "default" }}>
|
||||
<ImageBase src={it.src} alt={it.alt} ratio={ratio} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/components/display/gallery/gallery.css
Normal file
11
src/components/display/gallery/gallery.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.ui-gallery{
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.ui-gallery[data-cols="2"]{ grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.ui-gallery[data-cols="3"]{ grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
.ui-gallery[data-cols="4"]{ grid-template-columns: repeat(4, minmax(0,1fr)); }
|
||||
|
||||
@media (max-width: 560px){
|
||||
.ui-gallery{ grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
}
|
||||
35
src/components/display/grid-avatar/GridAvatar.tsx
Normal file
35
src/components/display/grid-avatar/GridAvatar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { Avatar, type AvatarSize } from "../avatar/Avatar";
|
||||
// import "./grid-avatar.css";
|
||||
|
||||
|
||||
export type GridAvatarItem = {
|
||||
id: string;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
src?: string;
|
||||
initials?: string;
|
||||
};
|
||||
|
||||
export function GridAvatar(props: {
|
||||
items: GridAvatarItem[];
|
||||
cols?: 2 | 3 | 4;
|
||||
size?: AvatarSize;
|
||||
className?: string;
|
||||
}) {
|
||||
const { items, cols = 2, size = 32, className } = props;
|
||||
return (
|
||||
<div className={clsx("ui-gridavatar", className)} data-cols={String(cols)}>
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="ui-gridavatar__cell">
|
||||
<Avatar size={size} src={it.src} initials={it.initials} />
|
||||
<div className="ui-gridavatar__label">
|
||||
<div className="ui-gridavatar__title">{it.title}</div>
|
||||
{it.subtitle ? <div className="ui-gridavatar__subtitle">{it.subtitle}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/components/display/grid-avatar/grid-avatar.css
Normal file
12
src/components/display/grid-avatar/grid-avatar.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.ui-gridavatar{
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.ui-gridavatar[data-cols="2"]{ grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.ui-gridavatar[data-cols="3"]{ grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
.ui-gridavatar[data-cols="4"]{ grid-template-columns: repeat(4, minmax(0,1fr)); }
|
||||
|
||||
.ui-gridavatar__cell{ display: flex; align-items: center; gap: 10px; }
|
||||
.ui-gridavatar__label{ display: grid; gap: 2px; }
|
||||
.ui-gridavatar__title{ font-weight: 700; }
|
||||
.ui-gridavatar__subtitle{ font-size: 12px; color: var(--ui-muted); }
|
||||
40
src/components/display/horizontal-cell/HorizontalCell.tsx
Normal file
40
src/components/display/horizontal-cell/HorizontalCell.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ImageBase } from "../image-base/ImageBase";
|
||||
// import "./horizontal-cell.css";
|
||||
|
||||
|
||||
export function HorizontalCell(props: {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
image?: { src: string; alt?: string; ratio?: number };
|
||||
width?: number;
|
||||
clickable?: boolean;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { title, subtitle, image, width = 160, clickable, href, onClick, className } = props;
|
||||
const Comp: any = href ? "a" : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={clsx("ui-hcell", className)}
|
||||
data-clickable={clickable ? "true" : "false"}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
style={{ ["--ui-hcell-w" as any]: `${width}px` } as React.CSSProperties}
|
||||
>
|
||||
{image ? (
|
||||
<div className="ui-hcell__media">
|
||||
<ImageBase src={image.src} alt={image.alt} ratio={image.ratio ?? 1} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="ui-hcell__body">
|
||||
<div className="ui-hcell__title">{title}</div>
|
||||
{subtitle ? <div className="ui-hcell__subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
19
src/components/display/horizontal-cell/horizontal-cell.css
Normal file
19
src/components/display/horizontal-cell/horizontal-cell.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.ui-hcell{
|
||||
width: var(--ui-hcell-w, 160px);
|
||||
border-radius: var(--ui-radius);
|
||||
border: 1px solid var(--ui-border);
|
||||
background: var(--ui-panel);
|
||||
box-shadow: var(--ui-shadow);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ui-hcell__media{ width: 100%; }
|
||||
.ui-hcell__body{ padding: 10px 10px 12px 10px; display: grid; gap: 4px; }
|
||||
.ui-hcell__title{ font-weight: 800; font-size: 13px; }
|
||||
.ui-hcell__subtitle{ font-size: 12px; color: var(--ui-muted); }
|
||||
|
||||
.ui-hcell[data-clickable="true"]{ cursor: pointer; }
|
||||
.ui-hcell[data-clickable="true"]:active{ transform: translateY(1px); }
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./horizontal-scroll.css";
|
||||
|
||||
|
||||
export function HorizontalScroll(props: React.PropsWithChildren<{ gap?: number; className?: string }>) {
|
||||
const { gap = 12, className, children } = props;
|
||||
return (
|
||||
<div className={clsx("ui-hscroll", className)} style={{ ["--ui-hscroll-gap" as any]: `${gap}px` } as React.CSSProperties}>
|
||||
<div className="ui-hscroll__inner">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.ui-hscroll{
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.ui-hscroll__inner{
|
||||
display: flex;
|
||||
gap: var(--ui-hscroll-gap, 12px);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.ui-hscroll::-webkit-scrollbar{ height: 8px; }
|
||||
.ui-hscroll::-webkit-scrollbar-thumb{ background: rgba(255,255,255,0.12); border-radius: 999px; }
|
||||
:root[data-theme="light"] .ui-hscroll::-webkit-scrollbar-thumb{ background: rgba(17,19,24,0.12); }
|
||||
36
src/components/display/image-base/ImageBase.tsx
Normal file
36
src/components/display/image-base/ImageBase.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./image-base.css";
|
||||
|
||||
|
||||
export type ImageFit = "cover" | "contain";
|
||||
|
||||
export function ImageBase(props: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
ratio?: number; // width/height, e.g. 16/9
|
||||
fit?: ImageFit;
|
||||
overlay?: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const { src, alt, ratio = 16 / 9, fit = "cover", overlay, className, style } = props;
|
||||
const paddingBottom = `${(1 / ratio) * 100}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("ui-imagebase", className)}
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
["--ui-image-ratio" as any]: paddingBottom,
|
||||
["--ui-image-fit" as any]: fit,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="ui-imagebase__ratio" />
|
||||
<img src={src} alt={alt ?? ""} loading="lazy" />
|
||||
{overlay ? <div className="ui-imagebase__overlay">{overlay}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/components/display/image-base/image-base.css
Normal file
31
src/components/display/image-base/image-base.css
Normal file
@@ -0,0 +1,31 @@
|
||||
.ui-imagebase{
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: var(--ui-radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ui-border);
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
:root[data-theme="light"] .ui-imagebase{ background: rgba(17,19,24,0.04); }
|
||||
|
||||
.ui-imagebase__ratio{
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: var(--ui-image-ratio, 56.25%); /* 16:9 default */
|
||||
}
|
||||
|
||||
.ui-imagebase img,
|
||||
.ui-imagebase video{
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--ui-image-fit, cover);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui-imagebase__overlay{
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
14
src/components/display/image/Image.tsx
Normal file
14
src/components/display/image/Image.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { ImageBase, type ImageFit } from "../image-base/ImageBase";
|
||||
|
||||
export function Image(props: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
ratio?: number;
|
||||
fit?: ImageFit;
|
||||
overlay?: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return <ImageBase {...props} />;
|
||||
}
|
||||
17
src/components/display/index.ts
Normal file
17
src/components/display/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export * from "./badge/Badge";
|
||||
export * from "./counter/Counter";
|
||||
export * from "./avatar/Avatar";
|
||||
export * from "./users-stack/UsersStack";
|
||||
export * from "./grid-avatar/GridAvatar";
|
||||
export * from "./banner/Banner";
|
||||
export * from "./placeholder/Placeholder";
|
||||
export * from "./image-base/ImageBase";
|
||||
export * from "./image/Image";
|
||||
export * from "./horizontal-scroll/HorizontalScroll";
|
||||
export * from "./horizontal-cell/HorizontalCell";
|
||||
export * from "./info-row/InfoRow";
|
||||
export * from "./mini-info-cell/MiniInfoCell";
|
||||
export * from "./gallery/Gallery";
|
||||
export * from "./content-badge/ContentBadge";
|
||||
export * from "./content-card/ContentCard";
|
||||
export * from "./accordion/Accordion";
|
||||
18
src/components/display/info-row/InfoRow.tsx
Normal file
18
src/components/display/info-row/InfoRow.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./info-row.css";
|
||||
|
||||
|
||||
export function InfoRow(props: {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { label, value, className } = props;
|
||||
return (
|
||||
<div className={clsx("ui-inforow", className)}>
|
||||
<div className="ui-inforow__label">{label}</div>
|
||||
<div className="ui-inforow__value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/components/display/info-row/info-row.css
Normal file
8
src/components/display/info-row/info-row.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.ui-inforow{
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.ui-inforow__label{ color: var(--ui-muted); font-size: 13px; }
|
||||
.ui-inforow__value{ font-weight: 700; }
|
||||
34
src/components/display/mini-info-cell/MiniInfoCell.tsx
Normal file
34
src/components/display/mini-info-cell/MiniInfoCell.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./mini-info-cell.css";
|
||||
|
||||
|
||||
export function MiniInfoCell(props: {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
before?: React.ReactNode;
|
||||
after?: React.ReactNode;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { title, subtitle, before, after, href, onClick, className } = props;
|
||||
const clickable = Boolean(href || onClick);
|
||||
const Comp: any = href ? "a" : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={clsx("ui-minicell", className)}
|
||||
data-clickable={clickable ? "true" : "false"}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
>
|
||||
{before ? <div className="ui-minicell__before" aria-hidden>{before}</div> : null}
|
||||
<div className="ui-minicell__body">
|
||||
<div className="ui-minicell__title">{title}</div>
|
||||
{subtitle ? <div className="ui-minicell__subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
{after ? <div className="ui-minicell__after">{after}</div> : null}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
17
src/components/display/mini-info-cell/mini-info-cell.css
Normal file
17
src/components/display/mini-info-cell/mini-info-cell.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.ui-minicell{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.ui-minicell[data-clickable="true"]{ cursor: pointer; }
|
||||
.ui-minicell[data-clickable="true"]:hover{ background: rgba(255,255,255,0.04); }
|
||||
:root[data-theme="light"] .ui-minicell[data-clickable="true"]:hover{ background: rgba(17,19,24,0.04); }
|
||||
|
||||
.ui-minicell__before{ display: inline-flex; align-items: center; justify-content: center; }
|
||||
.ui-minicell__body{ flex: 1; display: grid; gap: 2px; }
|
||||
.ui-minicell__title{ font-weight: 700; }
|
||||
.ui-minicell__subtitle{ font-size: 12px; color: var(--ui-muted); }
|
||||
.ui-minicell__after{ display: inline-flex; align-items: center; justify-content: center; color: var(--ui-muted); }
|
||||
22
src/components/display/placeholder/Placeholder.tsx
Normal file
22
src/components/display/placeholder/Placeholder.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./placeholder.css";
|
||||
|
||||
|
||||
export function Placeholder(props: {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { title, description, icon, actions, className } = props;
|
||||
return (
|
||||
<div className={clsx("ui-placeholder", className)}>
|
||||
{icon ? <div className="ui-placeholder__icon" aria-hidden>{icon}</div> : null}
|
||||
<h3 className="ui-placeholder__title">{title}</h3>
|
||||
{description ? <p className="ui-placeholder__desc">{description}</p> : null}
|
||||
{actions ? <div className="ui-placeholder__actions">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/components/display/placeholder/placeholder.css
Normal file
25
src/components/display/placeholder/placeholder.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.ui-placeholder{
|
||||
background: var(--ui-panel);
|
||||
border: 1px dashed color-mix(in oklab, var(--ui-border) 70%, transparent);
|
||||
border-radius: var(--ui-radius);
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.ui-placeholder__icon{
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
:root[data-theme="light"] .ui-placeholder__icon{ background: rgba(17,19,24,0.06); }
|
||||
|
||||
.ui-placeholder__title{ margin: 0; font-weight: 900; }
|
||||
.ui-placeholder__desc{ margin: 0; color: var(--ui-muted); }
|
||||
.ui-placeholder__actions{ display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
38
src/components/display/rich-cell/RichCell.tsx
Normal file
38
src/components/display/rich-cell/RichCell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
// import "./rich-cell.css";
|
||||
|
||||
|
||||
export function RichCell(props: {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
bottom?: React.ReactNode;
|
||||
before?: React.ReactNode;
|
||||
after?: React.ReactNode;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { title, subtitle, bottom, before, after, href, onClick, className } = props;
|
||||
const clickable = Boolean(href || onClick);
|
||||
const Comp: any = href ? "a" : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={clsx("ui-richcell", className)}
|
||||
data-clickable={clickable ? "true" : "false"}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
>
|
||||
{before ? <div className="ui-richcell__before" aria-hidden>{before}</div> : null}
|
||||
<div className="ui-richcell__body">
|
||||
<div>
|
||||
<div className="ui-richcell__title">{title}</div>
|
||||
{subtitle ? <div className="ui-richcell__subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
{bottom ? <div className="ui-richcell__bottom">{bottom}</div> : null}
|
||||
</div>
|
||||
{after ? <div className="ui-richcell__after">{after}</div> : null}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
18
src/components/display/rich-cell/rich-cell.css
Normal file
18
src/components/display/rich-cell/rich-cell.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.ui-richcell{
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.ui-richcell[data-clickable="true"]{ cursor: pointer; }
|
||||
.ui-richcell[data-clickable="true"]:hover{ background: rgba(255,255,255,0.04); }
|
||||
:root[data-theme="light"] .ui-richcell[data-clickable="true"]:hover{ background: rgba(17,19,24,0.04); }
|
||||
|
||||
.ui-richcell__before{ display: inline-flex; }
|
||||
.ui-richcell__body{ flex: 1; display: grid; gap: 6px; min-width: 0; }
|
||||
.ui-richcell__title{ font-weight: 800; }
|
||||
.ui-richcell__subtitle{ color: var(--ui-muted); font-size: 13px; }
|
||||
.ui-richcell__bottom{ display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.ui-richcell__after{ display: inline-flex; align-items: center; justify-content: center; color: var(--ui-muted); }
|
||||
35
src/components/display/users-stack/UsersStack.tsx
Normal file
35
src/components/display/users-stack/UsersStack.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { Avatar, type AvatarSize } from "../avatar/Avatar";
|
||||
// import "./users-stack.css";
|
||||
|
||||
|
||||
export type UsersStackUser = {
|
||||
id: string;
|
||||
src?: string;
|
||||
initials?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export type UsersStackProps = {
|
||||
users: UsersStackUser[];
|
||||
size?: AvatarSize;
|
||||
maxVisible?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function UsersStack({ users, size = 32, maxVisible = 3, className }: UsersStackProps) {
|
||||
const visible = users.slice(0, maxVisible);
|
||||
const rest = Math.max(0, users.length - visible.length);
|
||||
|
||||
return (
|
||||
<div className={clsx("ui-usersstack", className)}>
|
||||
{visible.map((u) => (
|
||||
<span key={u.id} className="ui-usersstack__item">
|
||||
<Avatar size={size} src={u.src} alt={u.alt} initials={u.initials} />
|
||||
</span>
|
||||
))}
|
||||
{rest > 0 ? <span className="ui-usersstack__more">+{rest}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/display/users-stack/users-stack.css
Normal file
24
src/components/display/users-stack/users-stack.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.ui-usersstack{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ui-usersstack__item{
|
||||
margin-left: -8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.ui-usersstack__item:first-child{ margin-left: 0; }
|
||||
.ui-usersstack__more{
|
||||
margin-left: -8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ui-border);
|
||||
background: rgba(255,255,255,0.08);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--ui-fg);
|
||||
user-select: none;
|
||||
}
|
||||
:root[data-theme="light"] .ui-usersstack__more{ background: rgba(17,19,24,0.06); }
|
||||
Reference in New Issue
Block a user