diff --git a/bricks/basic/docs/eo-card-box.md b/bricks/basic/docs/eo-card-box.md new file mode 100644 index 000000000..001254eba --- /dev/null +++ b/bricks/basic/docs/eo-card-box.md @@ -0,0 +1,40 @@ +卡片项容器 + +## Examples + +### Basic + +```yaml preview +brick: eo-card-box +children: + - brick: eo-avatar + slot: avatar + properties: + icon: + lib: easyops + icon: account + color: var(--theme-blue-color) + bgColor: var(--theme-blue-background) + - brick: span + slot: title + properties: + textContent: Hello + - brick: span + slot: description + properties: + textContent: World + - brick: eo-tag-list + slot: footer + properties: + size: small + list: + - text: IT 资源管理 + key: IT_resource_management + color: gray + - text: 资源套餐 + key: resource_package + color: gray + - text: 存储设备 + key: storage_device + color: gray +``` diff --git a/bricks/basic/src/avatar/index.tsx b/bricks/basic/src/avatar/index.tsx index de5c108b4..57a4adda6 100644 --- a/bricks/basic/src/avatar/index.tsx +++ b/bricks/basic/src/avatar/index.tsx @@ -9,13 +9,14 @@ import React, { import { createDecorators } from "@next-core/element"; import { ReactNextElement, wrapBrick } from "@next-core/react-element"; import "@next-core/theme"; -import styleText from "./styles.shadow.css"; import type { GeneralIcon, GeneralIconProps, } from "@next-bricks/icons/general-icon"; import classNames from "classnames"; +import { isNil, omitBy } from "lodash"; import { WrappedSlResizeObserver } from "./sl-resize-observer.js"; +import styleText from "./styles.shadow.css"; const { defineElement, property } = createDecorators(); @@ -35,6 +36,8 @@ export interface AvatarProps { name?: string; bordered?: boolean; showName?: boolean; + color?: string; + bgColor?: string; } /** @@ -78,6 +81,12 @@ class EoAvatar extends ReactNextElement implements AvatarProps { }) accessor icon: GeneralIconProps | undefined; + /** 图标颜色 */ + @property() accessor color: string | undefined; + + /** 图标背景色 */ + @property() accessor bgColor: string | undefined; + /** * 用户名 */ @@ -110,14 +119,25 @@ class EoAvatar extends ReactNextElement implements AvatarProps { name={this.name} bordered={this.bordered} showName={this.showName} + color={this.color} + bgColor={this.bgColor} /> ); } } -export function EoAvatarComponent(props: AvatarProps) { - const { shape, size, src, alt, icon, name, bordered } = props; - +export function EoAvatarComponent({ + shape, + size, + src, + alt, + icon, + name, + bordered, + showName: propShowName, + color, + bgColor, +}: AvatarProps) { const avatarNodeRef = useRef(null); const textNodeRef = useRef(null); @@ -233,10 +253,15 @@ export function EoAvatarComponent(props: AvatarProps) { ref={avatarNodeRef} part={`avatar avatar-${type}`} title={name} + style={ + type === "icon" + ? omitBy({ color, backgroundColor: bgColor }, isNil) + : undefined + } > {avatarNode} - {props.showName && {name}} + {propShowName && {name}} ); } diff --git a/bricks/basic/src/base-bricks.ts b/bricks/basic/src/base-bricks.ts index 206a9ecdd..fe2f002e2 100644 --- a/bricks/basic/src/base-bricks.ts +++ b/bricks/basic/src/base-bricks.ts @@ -1,6 +1,6 @@ // Merge bricks -import "./button/index.js"; import "./link/index.js"; +import "./tooltip/index.js"; +import "./button/index.js"; import "./data-providers/show-dialog/show-dialog.js"; import "./data-providers/show-notification/show-notification.js"; -import "./tooltip/index.js"; diff --git a/bricks/basic/src/bootstrap.ts b/bricks/basic/src/bootstrap.ts index 4e4ef605d..d6a195b64 100644 --- a/bricks/basic/src/bootstrap.ts +++ b/bricks/basic/src/bootstrap.ts @@ -49,3 +49,4 @@ import "./data-providers/set-timeout.js"; import "./dropdown-select/index.js"; import "./loading-container/index.js"; import "./custom-processors/smartDisplayForEvaluableString.js"; +import "./card-box/index.js"; diff --git a/bricks/basic/src/card-box/index.spec.tsx b/bricks/basic/src/card-box/index.spec.tsx new file mode 100644 index 000000000..d2046c480 --- /dev/null +++ b/bricks/basic/src/card-box/index.spec.tsx @@ -0,0 +1,85 @@ +import { describe, test, expect, jest } from "@jest/globals"; +import { act } from "react-dom/test-utils"; +import { createHistory } from "@next-core/runtime"; +import "../link/index"; +import "./"; +import type { CardBox } from "./index.js"; + +jest.mock("@next-core/theme", () => ({})); + +createHistory(); + +describe("eo-card-box", () => { + test("basic usage", async () => { + const element = document.createElement("eo-card-box") as CardBox; + element.href = "/test"; + + const slots = ["avatar", "title", "description", "", "footer"]; + const classNames = [ + "avatar", + "title", + "description", + "content", + "footer", + "body", + "detail", + ]; + for (const slot of slots) { + const slotElement = document.createElement("div"); + slotElement.slot = slot; + element.appendChild(slotElement); + } + + act(() => { + document.body.appendChild(element); + }); + + expect( + element.shadowRoot?.querySelector("eo-link")?.getAttribute("href") + ).toBe("/test"); + expect( + element.shadowRoot + ?.querySelector("eo-link") + ?.classList.contains("clickable") + ).toBe(true); + + for (const className of classNames) { + expect( + element.shadowRoot + ?.querySelector(`.${className}`) + ?.classList.contains("hidden") + ).toBe(false); + } + + // Remove all children + await act(async () => { + element.replaceChildren(); + }); + + for (const className of classNames) { + expect( + element.shadowRoot + ?.querySelector(`.${className}`) + ?.classList.contains("hidden") + ).toBe(true); + } + + // Unset href + element.href = undefined; + await act(async () => { + // wait + }); + expect( + element.shadowRoot?.querySelector("eo-link")?.getAttribute("href") + ).toBe(null); + expect( + element.shadowRoot + ?.querySelector("eo-link") + ?.classList.contains("clickable") + ).toBe(false); + + act(() => { + document.body.removeChild(element); + }); + }); +}); diff --git a/bricks/basic/src/card-box/index.tsx b/bricks/basic/src/card-box/index.tsx new file mode 100644 index 000000000..9514ec6b9 --- /dev/null +++ b/bricks/basic/src/card-box/index.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useRef, useState } from "react"; +import { createDecorators } from "@next-core/element"; +import { ReactNextElement, wrapBrick } from "@next-core/react-element"; +import classNames from "classnames"; +import "@next-core/theme"; +import type { + ExtendedLocationDescriptor, + Link, + LinkProps, + Target, +} from "../link"; +import styleText from "./styles.shadow.css"; + +const WrappedLink = wrapBrick("eo-link"); + +const { defineElement, property } = createDecorators(); + +export interface CardBoxProps + extends Pick { + // Define props here. +} + +/** + * 卡片项容器 + * + * @slot avatar - 头像 + * @slot title - 标题 + * @slot description - 描述 + * @slot - 内容区 + * @slot footer - 底部 + */ +export +@defineElement("eo-card-box", { + styleTexts: [styleText], +}) +class CardBox extends ReactNextElement implements CardBoxProps { + /** + * 链接地址 + */ + @property({ + attribute: false, + }) + accessor url: ExtendedLocationDescriptor | undefined; + + /** + * 设置 `href` 时将使用原生 `` 标签,通常用于外链的跳转 + */ + @property() accessor href: string | undefined; + + /** + * 链接跳转目标 + */ + @property() accessor target: Target | undefined; + + /** 是否铺满容器 */ + @property({ type: Boolean, render: false }) + accessor fillContainer: boolean | undefined; + + render() { + return ( + + ); + } +} + +export interface CardBoxComponentProps extends CardBoxProps { + // Define react event handlers here. +} + +export function CardBoxComponent({ url, href, target }: CardBoxComponentProps) { + const slotsRef = useRef(new Map()); + const [slotsMap, setSlotsMap] = useState(() => new Map()); + + useEffect(() => { + const slots = ["avatar", "title", "description", "", "footer"]; + const disposables: (() => void)[] = []; + + for (const slot of slots) { + const slotElement = slotsRef.current.get(slot); + if (slotElement) { + const onSlotChange = () => { + setSlotsMap((prev) => { + const prevHas = prev.get(slot) ?? false; + const currentHas = slotElement.assignedNodes().length > 0; + return prevHas === currentHas + ? prev + : new Map(prev).set(slot, currentHas); + }); + }; + slotElement.addEventListener("slotchange", onSlotChange); + onSlotChange(); + disposables.push(() => { + slotElement.removeEventListener("slotchange", onSlotChange); + }); + } + } + + return () => { + disposables.forEach((dispose) => dispose()); + }; + }); + + const refCallbackFactory = + (slot: string) => (element: HTMLSlotElement | null) => { + slotsRef.current.set(slot, element); + }; + + const getSlotContainerClassName = (slot: string, className?: string) => { + return classNames(className ?? slot, { hidden: !slotsMap.get(slot) }); + }; + + const getSlotsContainerClassName = (slots: string[], className: string) => { + return classNames(className, { + hidden: slots.every((slot) => !slotsMap.get(slot)), + }); + }; + + return ( + + {/*
+ +
+
+ +
*/} +
+
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ ); +} diff --git a/bricks/basic/src/card-box/styles.shadow.css b/bricks/basic/src/card-box/styles.shadow.css new file mode 100644 index 000000000..d08eaf121 --- /dev/null +++ b/bricks/basic/src/card-box/styles.shadow.css @@ -0,0 +1,105 @@ +:host { + display: block; +} + +:host([hidden]) { + display: none; +} + +* { + box-sizing: border-box; +} + +.box { + display: block; + border: 1px solid var(--card-item-border-color); + border-radius: var(--larger-border-radius); + background: var(--card-item-bg); +} + +.box::part(link) { + display: flex; + flex-direction: column; +} + +.clickable::part(link) { + cursor: pointer; +} + +.clickable:hover { + border-color: transparent; + box-shadow: var(--card-item-hover-shadow); + background: var(--card-item-hover-bg); +} + +.body { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + flex: 1; + min-height: 0; +} + +.content { + padding: 0 16px 16px; +} + +.body.hidden + .content { + padding-top: 16px; +} + +.detail { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + gap: 3px; +} + +:host([fill-container]) { + height: 100%; + + .box, + .box::part(link) { + height: 100%; + } +} + +.title { + font-size: var(--normal-font-size); + font-weight: var(--font-weight-500); + color: var(--text-color-title); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.description { + font-size: var(--sub-title-font-size-small); + color: var(--text-color-secondary); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.footer { + padding: 0 16px 16px; +} + +.hidden { + display: none; +} + +/* https://github.com/w3c/csswg-drafts/issues/3559#issuecomment-1758459996 */ +@supports not (inset: 0) { + .avatar:not(.hidden) + .detail { + margin-left: 12px; + } + + .title:not(.hidden) + .description { + margin-top: 3px; + } +} diff --git a/bricks/basic/src/link/link.shadow.css b/bricks/basic/src/link/link.shadow.css index b7066e0dc..43d27a3b4 100644 --- a/bricks/basic/src/link/link.shadow.css +++ b/bricks/basic/src/link/link.shadow.css @@ -11,11 +11,6 @@ a { cursor: pointer; } -.plain { - cursor: inherit !important; - color: inherit !important; -} - .link { display: inline-flex; align-items: center; @@ -23,14 +18,19 @@ a { color: var(--antd-btn-link-color); } -.link:not(.disabled, .danger):hover { +.link:not(.disabled, .danger, .plain):hover { color: var(--antd-btn-link-hover-color); } -.link:not(.disabled, .danger):active { +.link:not(.disabled, .danger, .plain):active { color: var(--antd-btn-link-active-color); } +.plain { + cursor: inherit; + color: inherit; +} + .disabled { cursor: not-allowed; color: var(--color-disabled-text); diff --git a/shared/common-bricks/common-bricks.json b/shared/common-bricks/common-bricks.json index 2ebfd74cd..0843eb417 100644 --- a/shared/common-bricks/common-bricks.json +++ b/shared/common-bricks/common-bricks.json @@ -38,7 +38,8 @@ "eo-broadcast-channel", "eo-iframe", "eo-dropdown-select", - "eo-loading-container" + "eo-loading-container", + "eo-card-box" ], "icons": [ "eo-antd-icon",