Skip to content

Commit 918196a

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

File tree

8 files changed

+433
-13
lines changed

8 files changed

+433
-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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
const classNames = [
19+
"avatar",
20+
"title",
21+
"description",
22+
"content",
23+
"footer",
24+
"body",
25+
"detail",
26+
];
27+
for (const slot of slots) {
28+
const slotElement = document.createElement("div");
29+
slotElement.slot = slot;
30+
element.appendChild(slotElement);
31+
}
32+
33+
act(() => {
34+
document.body.appendChild(element);
35+
});
36+
37+
expect(
38+
element.shadowRoot?.querySelector("eo-link")?.getAttribute("href")
39+
).toBe("/test");
40+
expect(
41+
element.shadowRoot
42+
?.querySelector("eo-link")
43+
?.classList.contains("clickable")
44+
).toBe(true);
45+
46+
for (const className of classNames) {
47+
expect(
48+
element.shadowRoot
49+
?.querySelector(`.${className}`)
50+
?.classList.contains("hidden")
51+
).toBe(false);
52+
}
53+
54+
// Remove all children
55+
await act(async () => {
56+
element.replaceChildren();
57+
});
58+
59+
for (const className of classNames) {
60+
expect(
61+
element.shadowRoot
62+
?.querySelector(`.${className}`)
63+
?.classList.contains("hidden")
64+
).toBe(true);
65+
}
66+
67+
// Unset href
68+
element.href = undefined;
69+
await act(async () => {
70+
// wait
71+
});
72+
expect(
73+
element.shadowRoot?.querySelector("eo-link")?.getAttribute("href")
74+
).toBe(null);
75+
expect(
76+
element.shadowRoot
77+
?.querySelector("eo-link")
78+
?.classList.contains("clickable")
79+
).toBe(false);
80+
81+
act(() => {
82+
document.body.removeChild(element);
83+
});
84+
});
85+
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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 - 内容区
30+
* @slot footer - 底部
31+
*/
32+
export
33+
@defineElement("eo-card-box", {
34+
styleTexts: [styleText],
35+
})
36+
class CardBox extends ReactNextElement implements CardBoxProps {
37+
/**
38+
* 链接地址
39+
*/
40+
@property({
41+
attribute: false,
42+
})
43+
accessor url: ExtendedLocationDescriptor | undefined;
44+
45+
/**
46+
* 设置 `href` 时将使用原生 `<a>` 标签,通常用于外链的跳转
47+
*/
48+
@property() accessor href: string | undefined;
49+
50+
/**
51+
* 链接跳转目标
52+
*/
53+
@property() accessor target: Target | undefined;
54+
55+
/** 是否铺满容器 */
56+
@property({ type: Boolean, render: false })
57+
accessor fillContainer: boolean | undefined;
58+
59+
render() {
60+
return (
61+
<CardBoxComponent url={this.url} href={this.href} target={this.target} />
62+
);
63+
}
64+
}
65+
66+
export interface CardBoxComponentProps extends CardBoxProps {
67+
// Define react event handlers here.
68+
}
69+
70+
export function CardBoxComponent({ url, href, target }: CardBoxComponentProps) {
71+
const slotsRef = useRef(new Map<string, HTMLSlotElement | null>());
72+
const [slotsMap, setSlotsMap] = useState(() => new Map<string, boolean>());
73+
74+
useEffect(() => {
75+
const slots = ["avatar", "title", "description", "", "footer"];
76+
const disposables: (() => void)[] = [];
77+
78+
for (const slot of slots) {
79+
const slotElement = slotsRef.current.get(slot);
80+
if (slotElement) {
81+
const onSlotChange = () => {
82+
setSlotsMap((prev) => {
83+
const prevHas = prev.get(slot) ?? false;
84+
const currentHas = slotElement.assignedNodes().length > 0;
85+
return prevHas === currentHas
86+
? prev
87+
: new Map(prev).set(slot, currentHas);
88+
});
89+
};
90+
slotElement.addEventListener("slotchange", onSlotChange);
91+
onSlotChange();
92+
disposables.push(() => {
93+
slotElement.removeEventListener("slotchange", onSlotChange);
94+
});
95+
}
96+
}
97+
98+
return () => {
99+
disposables.forEach((dispose) => dispose());
100+
};
101+
});
102+
103+
const refCallbackFactory =
104+
(slot: string) => (element: HTMLSlotElement | null) => {
105+
slotsRef.current.set(slot, element);
106+
};
107+
108+
const getSlotContainerClassName = (slot: string, className?: string) => {
109+
return classNames(className ?? slot, { hidden: !slotsMap.get(slot) });
110+
};
111+
112+
const getSlotsContainerClassName = (slots: string[], className: string) => {
113+
return classNames(className, {
114+
hidden: slots.every((slot) => !slotsMap.get(slot)),
115+
});
116+
};
117+
118+
return (
119+
<WrappedLink
120+
className={classNames("box", { clickable: !!(url || href) })}
121+
type="plain"
122+
url={url}
123+
href={href}
124+
target={target}
125+
>
126+
{/* <div className="header">
127+
<slot name="header" />
128+
</div>
129+
<div className="cover">
130+
<slot name="cover" />
131+
</div> */}
132+
<div
133+
className={getSlotsContainerClassName(
134+
["avatar", "title", "description"],
135+
"body"
136+
)}
137+
>
138+
<div className={getSlotContainerClassName("avatar")}>
139+
<slot name="avatar" ref={refCallbackFactory("avatar")} />
140+
</div>
141+
<div
142+
className={getSlotsContainerClassName(
143+
["title", "description"],
144+
"detail"
145+
)}
146+
>
147+
<div className={getSlotContainerClassName("title")}>
148+
<slot name="title" ref={refCallbackFactory("title")} />
149+
</div>
150+
<div className={getSlotContainerClassName("description")}>
151+
<slot name="description" ref={refCallbackFactory("description")} />
152+
</div>
153+
</div>
154+
</div>
155+
<div className={getSlotContainerClassName("", "content")}>
156+
<slot ref={refCallbackFactory("")} />
157+
</div>
158+
<div className={getSlotContainerClassName("footer")}>
159+
<slot name="footer" ref={refCallbackFactory("footer")} />
160+
</div>
161+
</WrappedLink>
162+
);
163+
}

0 commit comments

Comments
 (0)