Skip to content

Commit c84b10b

Browse files
committed
feat(): new brick: eo-card-box
1 parent 3ded77b commit c84b10b

File tree

8 files changed

+393
-13
lines changed

8 files changed

+393
-13
lines changed

bricks/basic/docs/eo-card-box.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
卡片项容器
2+
3+
## Examples
4+
5+
### Basic
6+
7+
```yaml preview
8+
brick: eo-card-box
9+
children:
10+
- brick: eo-avatar
11+
slot: avatar
12+
properties:
13+
icon:
14+
lib: easyops
15+
icon: account
16+
color: var(--theme-blue-color)
17+
bgColor: var(--theme-blue-background)
18+
- brick: span
19+
slot: title
20+
properties:
21+
textContent: Hello
22+
- brick: span
23+
slot: description
24+
properties:
25+
textContent: World
26+
- brick: eo-tag-list
27+
slot: footer
28+
properties:
29+
size: small
30+
list:
31+
- text: IT 资源管理
32+
key: IT_resource_management
33+
color: gray
34+
- text: 资源套餐
35+
key: resource_package
36+
color: gray
37+
- text: 存储设备
38+
key: storage_device
39+
color: gray
40+
```

bricks/basic/src/avatar/index.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import React, {
99
import { createDecorators } from "@next-core/element";
1010
import { ReactNextElement, wrapBrick } from "@next-core/react-element";
1111
import "@next-core/theme";
12-
import styleText from "./styles.shadow.css";
1312
import type {
1413
GeneralIcon,
1514
GeneralIconProps,
1615
} from "@next-bricks/icons/general-icon";
1716
import classNames from "classnames";
17+
import { isNil, omitBy } from "lodash";
1818
import { WrappedSlResizeObserver } from "./sl-resize-observer.js";
19+
import styleText from "./styles.shadow.css";
1920

2021
const { defineElement, property } = createDecorators();
2122

@@ -35,6 +36,8 @@ export interface AvatarProps {
3536
name?: string;
3637
bordered?: boolean;
3738
showName?: boolean;
39+
color?: string;
40+
bgColor?: string;
3841
}
3942

4043
/**
@@ -78,6 +81,12 @@ class EoAvatar extends ReactNextElement implements AvatarProps {
7881
})
7982
accessor icon: GeneralIconProps | undefined;
8083

84+
/** 图标颜色 */
85+
@property() accessor color: string | undefined;
86+
87+
/** 图标背景色 */
88+
@property() accessor bgColor: string | undefined;
89+
8190
/**
8291
* 用户名
8392
*/
@@ -110,14 +119,25 @@ class EoAvatar extends ReactNextElement implements AvatarProps {
110119
name={this.name}
111120
bordered={this.bordered}
112121
showName={this.showName}
122+
color={this.color}
123+
bgColor={this.bgColor}
113124
/>
114125
);
115126
}
116127
}
117128

118-
export function EoAvatarComponent(props: AvatarProps) {
119-
const { shape, size, src, alt, icon, name, bordered } = props;
120-
129+
export function EoAvatarComponent({
130+
shape,
131+
size,
132+
src,
133+
alt,
134+
icon,
135+
name,
136+
bordered,
137+
showName: propShowName,
138+
color,
139+
bgColor,
140+
}: AvatarProps) {
121141
const avatarNodeRef = useRef<HTMLSpanElement>(null);
122142
const textNodeRef = useRef<HTMLSpanElement>(null);
123143

@@ -233,10 +253,15 @@ export function EoAvatarComponent(props: AvatarProps) {
233253
ref={avatarNodeRef}
234254
part={`avatar avatar-${type}`}
235255
title={name}
256+
style={
257+
type === "icon"
258+
? omitBy({ color, backgroundColor: bgColor }, isNil)
259+
: undefined
260+
}
236261
>
237262
{avatarNode}
238263
</span>
239-
{props.showName && <span className="name">{name}</span>}
264+
{propShowName && <span className="name">{name}</span>}
240265
</>
241266
);
242267
}

bricks/basic/src/bootstrap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ import "./data-providers/set-timeout.js";
4949
import "./dropdown-select/index.js";
5050
import "./loading-container/index.js";
5151
import "./custom-processors/smartDisplayForEvaluableString.js";
52+
import "./card-box/index.js";
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, test, expect, jest } from "@jest/globals";
2+
import { act } from "react-dom/test-utils";
3+
import { createHistory } from "@next-core/runtime";
4+
import "../link/index";
5+
import "./";
6+
import type { CardBox } from "./index.js";
7+
8+
jest.mock("@next-core/theme", () => ({}));
9+
10+
createHistory();
11+
12+
describe("eo-card-box", () => {
13+
test("basic usage", async () => {
14+
const element = document.createElement("eo-card-box") as CardBox;
15+
element.href = "/test";
16+
17+
const slots = ["avatar", "title", "description", "footer"];
18+
for (const slot of slots) {
19+
const slotElement = document.createElement("div");
20+
slotElement.slot = slot;
21+
element.appendChild(slotElement);
22+
}
23+
24+
act(() => {
25+
document.body.appendChild(element);
26+
});
27+
28+
expect(
29+
element.shadowRoot?.querySelector("eo-link")?.getAttribute("href")
30+
).toBe("/test");
31+
expect(
32+
element.shadowRoot
33+
?.querySelector("eo-link")
34+
?.classList.contains("clickable")
35+
).toBe(true);
36+
37+
for (const slot of slots) {
38+
expect(
39+
element.shadowRoot
40+
?.querySelector(`.${slot}`)
41+
?.classList.contains("hidden")
42+
).toBe(false);
43+
}
44+
45+
// Remove all children
46+
await act(async () => {
47+
element.replaceChildren();
48+
});
49+
50+
for (const slot of slots) {
51+
expect(
52+
element.shadowRoot
53+
?.querySelector(`.${slot}`)
54+
?.classList.contains("hidden")
55+
).toBe(true);
56+
}
57+
58+
// Unset href
59+
element.href = undefined;
60+
await act(async () => {
61+
// wait
62+
});
63+
expect(
64+
element.shadowRoot?.querySelector("eo-link")?.getAttribute("href")
65+
).toBe(null);
66+
expect(
67+
element.shadowRoot
68+
?.querySelector("eo-link")
69+
?.classList.contains("clickable")
70+
).toBe(false);
71+
72+
act(() => {
73+
document.body.removeChild(element);
74+
});
75+
});
76+
});
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { createDecorators } from "@next-core/element";
3+
import { ReactNextElement, wrapBrick } from "@next-core/react-element";
4+
import classNames from "classnames";
5+
import "@next-core/theme";
6+
import type {
7+
ExtendedLocationDescriptor,
8+
Link,
9+
LinkProps,
10+
Target,
11+
} from "../link";
12+
import styleText from "./styles.shadow.css";
13+
14+
const WrappedLink = wrapBrick<Link, LinkProps>("eo-link");
15+
16+
const { defineElement, property } = createDecorators();
17+
18+
export interface CardBoxProps
19+
extends Pick<LinkProps, "url" | "href" | "target"> {
20+
// Define props here.
21+
}
22+
23+
/**
24+
* 卡片项容器
25+
*
26+
* @slot avatar - 头像
27+
* @slot title - 标题
28+
* @slot description - 描述
29+
* @slot footer - 底部
30+
*/
31+
export
32+
@defineElement("eo-card-box", {
33+
styleTexts: [styleText],
34+
})
35+
class CardBox extends ReactNextElement implements CardBoxProps {
36+
/**
37+
* 链接地址
38+
*/
39+
@property({
40+
attribute: false,
41+
})
42+
accessor url: ExtendedLocationDescriptor | undefined;
43+
44+
/**
45+
* 设置 `href` 时将使用原生 `<a>` 标签,通常用于外链的跳转
46+
*/
47+
@property() accessor href: string | undefined;
48+
49+
/**
50+
* 链接跳转目标
51+
*/
52+
@property() accessor target: Target | undefined;
53+
54+
/** 是否铺满容器 */
55+
@property({ type: Boolean, render: false })
56+
accessor fillContainer: boolean | undefined;
57+
58+
render() {
59+
return (
60+
<CardBoxComponent url={this.url} href={this.href} target={this.target} />
61+
);
62+
}
63+
}
64+
65+
export interface CardBoxComponentProps extends CardBoxProps {
66+
// Define react event handlers here.
67+
}
68+
69+
export function CardBoxComponent({ url, href, target }: CardBoxComponentProps) {
70+
const slotsRef = useRef(new Map<string, HTMLSlotElement | null>());
71+
const [slotsMap, setSlotsMap] = useState(() => new Map<string, boolean>());
72+
73+
useEffect(() => {
74+
const slots = ["avatar", "title", "description", "footer"];
75+
const disposables: (() => void)[] = [];
76+
77+
for (const slot of slots) {
78+
const slotElement = slotsRef.current.get(slot);
79+
if (slotElement) {
80+
const onSlotChange = () => {
81+
setSlotsMap((prev) => {
82+
const prevHas = prev.get(slot) ?? false;
83+
const currentHas = slotElement.assignedElements().length > 0;
84+
return prevHas === currentHas
85+
? prev
86+
: new Map(prev).set(slot, currentHas);
87+
});
88+
};
89+
slotElement.addEventListener("slotchange", onSlotChange);
90+
onSlotChange();
91+
disposables.push(() => {
92+
slotElement.removeEventListener("slotchange", onSlotChange);
93+
});
94+
}
95+
}
96+
97+
return () => {
98+
disposables.forEach((dispose) => dispose());
99+
};
100+
});
101+
102+
const refCallbackFactory =
103+
(slot: string) => (element: HTMLSlotElement | null) => {
104+
slotsRef.current.set(slot, element);
105+
};
106+
107+
const getSlotContainerClassName = (slot: string) => {
108+
return classNames(slot, { hidden: !slotsMap.get(slot) });
109+
};
110+
111+
return (
112+
<WrappedLink
113+
className={classNames("box", { clickable: !!(url || href) })}
114+
type="plain"
115+
url={url}
116+
href={href}
117+
target={target}
118+
>
119+
{/* <div className="header">
120+
<slot name="header" />
121+
</div>
122+
<div className="cover">
123+
<slot name="cover" />
124+
</div> */}
125+
<div className="body">
126+
<div className={getSlotContainerClassName("avatar")}>
127+
<slot name="avatar" ref={refCallbackFactory("avatar")} />
128+
</div>
129+
<div className="detail">
130+
<div className={getSlotContainerClassName("title")}>
131+
<slot name="title" ref={refCallbackFactory("title")} />
132+
</div>
133+
<div className={getSlotContainerClassName("description")}>
134+
<slot name="description" ref={refCallbackFactory("description")} />
135+
</div>
136+
</div>
137+
</div>
138+
<div className={getSlotContainerClassName("footer")}>
139+
<slot name="footer" ref={refCallbackFactory("footer")} />
140+
</div>
141+
</WrappedLink>
142+
);
143+
}

0 commit comments

Comments
 (0)