@blocklet/ui-react 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,21 +41,23 @@ const Root = styled("div")`
41
41
  .footer-brand-logo {
42
42
  display: flex;
43
43
  align-items: center;
44
- margin-right: 16px;
44
+ margin-right: 12px;
45
45
  line-height: 1;
46
46
  img,
47
47
  svg {
48
48
  width: auto;
49
- height: 44px;
50
- max-height: 44px;
49
+ height: 40px;
50
+ max-height: 40px;
51
51
  }
52
52
  }
53
53
  .footer-brand-name {
54
- font-size: 16px;
55
- font-weight: bold;
54
+ font-size: 18px;
55
+ color: ${(props) => props.theme.palette.grey[900]};
56
56
  }
57
57
  .footer-brand-desc {
58
+ white-space: pre-line;
58
59
  margin-top: 16px;
60
+ color: #9397a1;
59
61
  }
60
62
 
61
63
  ${(props) => props.theme.breakpoints.down("sm")} {
@@ -1,12 +1,14 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import PropTypes from "prop-types";
4
+ import { useCreation } from "ahooks";
4
5
  import { styled } from "@arcblock/ux/lib/Theme";
5
6
  import { withErrorBoundary } from "react-error-boundary";
6
7
  import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
7
8
  import { ErrorFallback } from "@arcblock/ux/lib/ErrorBoundary";
8
9
  import { temp as colors } from "@arcblock/ux/lib/Colors";
9
10
  import omit from "lodash/omit";
11
+ import isFinite from "lodash/isFinite";
10
12
  import OverridableThemeProvider from "../common/overridable-theme-provider.js";
11
13
  import InternalFooter from "./internal-footer.js";
12
14
  import { mapRecursive } from "../utils.js";
@@ -24,12 +26,32 @@ function Footer({ meta, theme: themeOverrides, ...rest }) {
24
26
  return blocklet;
25
27
  }
26
28
  }, [meta]);
29
+ const productsNav = useCreation(() => {
30
+ return {
31
+ title: { en: "Products", zh: "\u4EA7\u54C1" },
32
+ section: ["footer"],
33
+ items: [
34
+ { title: "ArcSphere", link: `https://www.arcblock.io/content/tags/${locale}/arcsphere`, isNew: true },
35
+ { title: "DID Wallet", link: `https://www.didwallet.io/${locale}` },
36
+ { title: "DID Spaces", link: `https://www.didspaces.com/${locale}` },
37
+ { title: "DID Name Service", link: `https://www.didnames.io/${locale}` },
38
+ { title: "Blocklet Launcher", link: `https://launcher.arcblock.io/${locale}` },
39
+ { title: "Blocklet Server", link: `https://www.arcblock.io/content/collections/${locale}/blocklet-server` },
40
+ { title: "AIGNE", link: `https://www.aigne.io/${locale}` }
41
+ ]
42
+ };
43
+ }, [locale]);
27
44
  if (!formattedBlocklet.appName) {
28
45
  return null;
29
46
  }
30
47
  const { appLogo, appLogoRect, appName, appDescription, description, theme, copyright } = formattedBlocklet;
48
+ const navFooter = [...formattedBlocklet?.navigation?.footer ?? []];
49
+ const productsNavOrder = parseInt(window.blocklet?.USE_ARCBLOCK_THEME, 10);
50
+ if (isFinite(productsNavOrder)) {
51
+ navFooter.splice(productsNavOrder, 0, productsNav);
52
+ }
31
53
  const localized = {
32
- footerNav: getLocalizedNavigation(formattedBlocklet?.navigation?.footer, locale) || [],
54
+ footerNav: getLocalizedNavigation(navFooter, locale) || [],
33
55
  socialMedia: getLocalizedNavigation(formattedBlocklet?.navigation?.social, locale) || [],
34
56
  links: getLocalizedNavigation(formattedBlocklet?.navigation?.bottom, locale) || []
35
57
  };
@@ -29,7 +29,7 @@ function InternalFooter(props) {
29
29
  return brand ? /* @__PURE__ */ jsx(Brand, { ...brand }) : null;
30
30
  };
31
31
  const renderNavigation = () => {
32
- return navigation?.length ? /* @__PURE__ */ jsx(Links, { links: navigation }) : null;
32
+ return navigation?.length ? /* @__PURE__ */ jsx(Links, { links: navigation, columns: 3 }) : null;
33
33
  };
34
34
  const renderSocialMedia = () => {
35
35
  return socialMedia?.length ? /* @__PURE__ */ jsx(SocialMedia, { items: socialMedia }) : null;
@@ -2,14 +2,16 @@ export default StandardLayout;
2
2
  /**
3
3
  * footer standard layout
4
4
  */
5
- declare function StandardLayout({ elements, data, ...rest }: {
5
+ declare function StandardLayout({ elements, data, className, ...rest }: {
6
6
  [x: string]: any;
7
7
  elements: any;
8
8
  data: any;
9
+ className: any;
9
10
  }): import("react").JSX.Element;
10
11
  declare namespace StandardLayout {
11
12
  namespace propTypes {
12
13
  let elements: any;
13
14
  let data: any;
15
+ let className: any;
14
16
  }
15
17
  }
@@ -1,12 +1,38 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import PropTypes from "prop-types";
3
+ import clsx from "clsx";
3
4
  import Box from "@mui/material/Box";
5
+ import { grey } from "@mui/material/colors";
4
6
  import Container from "@mui/material/Container";
5
7
  import { styled } from "@arcblock/ux/lib/Theme";
6
8
  import Row from "./row.js";
7
- function StandardLayout({ elements, data, ...rest }) {
8
- return /* @__PURE__ */ jsx(Root, { ...rest, children: /* @__PURE__ */ jsxs(Container, { children: [
9
- /* @__PURE__ */ jsxs(
9
+ function StandardLayout({ elements, data, className, ...rest }) {
10
+ const withNavigation = !!data.navigation?.length;
11
+ let topSection = null;
12
+ if (withNavigation) {
13
+ topSection = /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", flexDirection: { xs: "column", md: "row" }, justifyContent: "space-between" }, children: [
14
+ /* @__PURE__ */ jsxs(
15
+ Box,
16
+ {
17
+ sx: {
18
+ flex: "1 1 auto",
19
+ paddingRight: { xs: 0, md: 3 },
20
+ display: "flex",
21
+ flexDirection: "column",
22
+ alignItems: { xs: "center", md: "flex-start" },
23
+ gap: 2,
24
+ pb: 3
25
+ },
26
+ children: [
27
+ /* @__PURE__ */ jsx(Box, { children: elements.brand }),
28
+ /* @__PURE__ */ jsx(Box, { lineHeight: 1, children: elements.socialMedia })
29
+ ]
30
+ }
31
+ ),
32
+ /* @__PURE__ */ jsx(Box, { sx: { mb: 3, borderTop: { xs: `1px solid ${grey[200]}`, md: 0 } }, children: elements.navigation })
33
+ ] });
34
+ } else {
35
+ topSection = /* @__PURE__ */ jsxs(
10
36
  Box,
11
37
  {
12
38
  sx: {
@@ -22,8 +48,10 @@ function StandardLayout({ elements, data, ...rest }) {
22
48
  /* @__PURE__ */ jsx(Box, { lineHeight: 1, children: elements.socialMedia })
23
49
  ]
24
50
  }
25
- ),
26
- !!data.navigation?.length && /* @__PURE__ */ jsx(Box, { sx: { mb: 6, pt: 3, borderTop: 1, borderColor: "grey.200" }, children: elements.navigation }),
51
+ );
52
+ }
53
+ return /* @__PURE__ */ jsx(Root, { ...rest, className: clsx({ "footer--with-navs": withNavigation }, className), children: /* @__PURE__ */ jsxs(Container, { children: [
54
+ topSection,
27
55
  /* @__PURE__ */ jsxs(Row, { sx: { pt: 3, borderTop: 1, borderColor: "grey.200" }, autoCenter: true, children: [
28
56
  elements.copyright,
29
57
  elements.links
@@ -38,7 +66,8 @@ StandardLayout.propTypes = {
38
66
  copyright: PropTypes.element,
39
67
  links: PropTypes.element
40
68
  }).isRequired,
41
- data: PropTypes.object.isRequired
69
+ data: PropTypes.object.isRequired,
70
+ className: PropTypes.string
42
71
  };
43
72
  const Root = styled("div")`
44
73
  padding: 32px 0 24px 0;
@@ -46,6 +75,14 @@ const Root = styled("div")`
46
75
  .footer-brand-desc {
47
76
  display: none;
48
77
  }
78
+ &.footer--with-navs {
79
+ ${(props) => props.theme.breakpoints.up("md")} {
80
+ .footer-brand-desc {
81
+ max-width: 360px;
82
+ display: block;
83
+ }
84
+ }
85
+ }
49
86
  && .footer-brand-logo {
50
87
  margin-right: 0;
51
88
  }
@@ -2,15 +2,17 @@
2
2
  * footer 中的 links (支持分组, 最多支持 2 级)
3
3
  * TODO: dark/light theme
4
4
  */
5
- declare function Links({ links, flowLayout, ...rest }: {
5
+ declare function Links({ links, flowLayout, columns, ...rest }: {
6
6
  [x: string]: any;
7
7
  links: any;
8
8
  flowLayout: any;
9
+ columns: any;
9
10
  }): import("react").JSX.Element | null;
10
11
  declare namespace Links {
11
12
  namespace propTypes {
12
13
  let links: any;
13
14
  let flowLayout: any;
15
+ let columns: any;
14
16
  }
15
17
  namespace defaultProps {
16
18
  let links_1: never[];
@@ -1,16 +1,19 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import PropTypes from "prop-types";
4
+ import { useCreation } from "ahooks";
5
+ import isInteger from "lodash/isInteger";
4
6
  import { styled } from "@arcblock/ux/lib/Theme";
5
7
  import clsx from "clsx";
6
8
  import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
7
9
  import Icon from "../Icon/index.js";
8
- export default function Links({ links, flowLayout, ...rest }) {
10
+ import useMobile from "../hooks/use-mobile.js";
11
+ import { splitNavColumns } from "../utils.js";
12
+ export default function Links({ links, flowLayout, columns, ...rest }) {
9
13
  const [activeIndex, setActiveIndex] = useState(-1);
10
- if (!links?.length) {
11
- return null;
12
- }
14
+ const isMobile = useMobile({ key: "md" });
13
15
  const isGroupMode = links.some((item) => item.items?.length);
16
+ const columnsLayout = !isMobile && isGroupMode && isInteger(columns) && columns > 1;
14
17
  const renderItem = ({ label, link, icon, render, props }) => {
15
18
  let result = label;
16
19
  if (render) {
@@ -23,45 +26,73 @@ export default function Links({ links, flowLayout, ...rest }) {
23
26
  result
24
27
  ] });
25
28
  };
29
+ const content = useCreation(() => {
30
+ if (!links?.length) {
31
+ return null;
32
+ }
33
+ if (flowLayout) {
34
+ return links.map((item, i) => /* @__PURE__ */ jsx("span", { className: "footer-links-item", children: renderItem(item) }, i));
35
+ }
36
+ if (columnsLayout) {
37
+ return splitNavColumns(links, { columns }).map((cols, i) => {
38
+ return /* @__PURE__ */ jsx("div", { className: "footer-links-column", children: cols.filter((v) => v.group).map((item, j) => {
39
+ const { items } = item;
40
+ return /* @__PURE__ */ jsxs("div", { className: "footer-links-group", children: [
41
+ /* @__PURE__ */ jsx("span", { className: "footer-links-item", children: renderItem(item) }),
42
+ !!items?.length && /* @__PURE__ */ jsx("div", { className: "footer-links-sub", children: items.map((child, k) => /* @__PURE__ */ jsx(
43
+ "span",
44
+ {
45
+ className: clsx("footer-links-item", { "footer-links-item--new": child.isNew }),
46
+ children: renderItem(child)
47
+ },
48
+ k
49
+ )) })
50
+ ] }, j);
51
+ }) }, i);
52
+ });
53
+ }
54
+ return links.map((item, i) => {
55
+ const { items } = item;
56
+ const isActive = i === activeIndex;
57
+ return /* @__PURE__ */ jsxs(
58
+ "div",
59
+ {
60
+ className: clsx("footer-links-group", {
61
+ "footer-links-group--active": isActive
62
+ }),
63
+ onClick: () => setActiveIndex(activeIndex === i ? -1 : i),
64
+ children: [
65
+ /* @__PURE__ */ jsxs("span", { className: "footer-links-item", children: [
66
+ renderItem(item),
67
+ !!items?.length && /* @__PURE__ */ jsx("span", { className: "footer-links-group-expand-icon", children: /* @__PURE__ */ jsx(
68
+ ExpandMoreIcon,
69
+ {
70
+ style: {
71
+ transform: `rotate(${isActive ? 180 : 0}deg)`
72
+ }
73
+ }
74
+ ) })
75
+ ] }),
76
+ !!items?.length && /* @__PURE__ */ jsx("div", { className: "footer-links-sub", children: items.map((child, j) => /* @__PURE__ */ jsx("span", { className: clsx("footer-links-item", { "footer-links-item--new": child.isNew }), children: renderItem(child) }, j)) })
77
+ ]
78
+ },
79
+ i
80
+ );
81
+ });
82
+ }, [links, flowLayout, columnsLayout, activeIndex]);
83
+ if (!links?.length) {
84
+ return null;
85
+ }
26
86
  return /* @__PURE__ */ jsx(
27
87
  Root,
28
88
  {
29
89
  ...rest,
30
90
  className: clsx(rest.className, {
31
91
  "footer-links--grouped": isGroupMode,
32
- "footer-links--flow": flowLayout
92
+ "footer-links--flow": flowLayout,
93
+ "footer-links--columns": columnsLayout
33
94
  }),
34
- children: /* @__PURE__ */ jsxs("div", { className: "footer-links-inner", children: [
35
- flowLayout && links.map((item, i) => /* @__PURE__ */ jsx("span", { className: "footer-links-item", children: renderItem(item) }, i)),
36
- !flowLayout && links.map((item, i) => {
37
- const { items } = item;
38
- const isActive = i === activeIndex;
39
- return /* @__PURE__ */ jsxs(
40
- "div",
41
- {
42
- className: clsx("footer-links-group", {
43
- "footer-links-group--active": isActive
44
- }),
45
- onClick: () => setActiveIndex(activeIndex === i ? -1 : i),
46
- children: [
47
- /* @__PURE__ */ jsxs("span", { className: "footer-links-item", children: [
48
- renderItem(item),
49
- !!items?.length && /* @__PURE__ */ jsx("span", { className: "footer-links-group-expand-icon", children: /* @__PURE__ */ jsx(
50
- ExpandMoreIcon,
51
- {
52
- style: {
53
- transform: `rotate(${isActive ? 180 : 0}deg)`
54
- }
55
- }
56
- ) })
57
- ] }),
58
- !!items?.length && /* @__PURE__ */ jsx("div", { className: "footer-links-sub", children: items.map((child, j) => /* @__PURE__ */ jsx("span", { className: "footer-links-item", children: renderItem(child) }, j)) })
59
- ]
60
- },
61
- i
62
- );
63
- })
64
- ] })
95
+ children: /* @__PURE__ */ jsx("div", { className: "footer-links-inner", children: content })
65
96
  }
66
97
  );
67
98
  }
@@ -75,7 +106,9 @@ Links.propTypes = {
75
106
  })
76
107
  ),
77
108
  // 流动布局, 简单的从左到右排列
78
- flowLayout: PropTypes.bool
109
+ flowLayout: PropTypes.bool,
110
+ // 列布局
111
+ columns: PropTypes.number
79
112
  };
80
113
  Links.defaultProps = {
81
114
  links: [],
@@ -83,7 +116,7 @@ Links.defaultProps = {
83
116
  };
84
117
  const Root = styled("div")`
85
118
  overflow: hidden;
86
- color: ${(props) => props.theme.palette.grey[600]};
119
+ color: #9397a1;
87
120
  .footer-links-inner {
88
121
  display: flex;
89
122
  justify-content: space-between;
@@ -94,9 +127,6 @@ const Root = styled("div")`
94
127
  display: flex;
95
128
  flex-direction: column;
96
129
  }
97
- .footer-links-sub .footer-links-item {
98
- color: ${(props) => props.theme.palette.grey[900]};
99
- }
100
130
  .footer-links-group-expand-icon {
101
131
  display: none;
102
132
  position: absolute;
@@ -113,13 +143,22 @@ const Root = styled("div")`
113
143
  display: inline-flex;
114
144
  align-items: center;
115
145
  position: relative;
116
- padding: 4px 8px;
146
+ padding: 6px 8px;
117
147
  font-size: 14px;
148
+ &--new::after {
149
+ content: 'New';
150
+ color: #4672ea;
151
+ background-color: #e1e8fb;
152
+ padding: 1px 8px;
153
+ border-radius: 10px/50%;
154
+ margin-left: 8px;
155
+ }
118
156
  }
119
157
  &.footer-links--grouped {
120
158
  .footer-links-group {
121
159
  > .footer-links-item {
122
- font-weight: bold;
160
+ font-weight: 600;
161
+ color: #25292f;
123
162
  }
124
163
  .footer-links-sub {
125
164
  margin-top: 8px;
@@ -131,11 +170,29 @@ const Root = styled("div")`
131
170
  max-width: 150px;
132
171
  color: inherit;
133
172
  text-decoration: none;
173
+ transition: color 0.2s ease-in-out;
134
174
  &:hover {
135
- text-decoration: underline;
175
+ color: #25292f;
176
+ }
177
+ }
178
+ /* columns 布局 */
179
+ &.footer-links--columns {
180
+ .footer-links-inner {
181
+ gap: 96px;
182
+ }
183
+ .footer-links-column {
184
+ display: flex;
185
+ flex-direction: column;
186
+ }
187
+ .footer-links-group {
188
+ .footer-links-sub {
189
+ margin-top: 2px;
190
+ margin-bottom: 12px;
191
+ }
136
192
  }
137
193
  }
138
194
 
195
+ /* flow 布局 */
139
196
  &.footer-links--flow {
140
197
  display: inline-flex;
141
198
  .footer-links-inner {
@@ -157,6 +214,7 @@ const Root = styled("div")`
157
214
  }
158
215
  }
159
216
 
217
+ /* 移动端样式 */
160
218
  ${(props) => props.theme.breakpoints.down("md")} {
161
219
  .footer-links-inner {
162
220
  flex-direction: column;
@@ -50,8 +50,9 @@ const Root = styled("div")`
50
50
  a {
51
51
  color: ${(props) => props.theme.palette.grey[400]};
52
52
  text-decoration: none;
53
+ transition: color 0.2s ease-in-out;
53
54
  &:hover {
54
- color: ${(props) => props.theme.palette.primary.light};
55
+ color: #25292f;
55
56
  }
56
57
  }
57
58
  ${(props) => props.theme.breakpoints.down("md")} {
@@ -1,13 +1,16 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
+ import { useMemoizedFn } from "ahooks";
3
4
  import { withErrorBoundary } from "react-error-boundary";
4
5
  import { ErrorFallback } from "@arcblock/ux/lib/ErrorBoundary";
5
6
  import { styled } from "@arcblock/ux/lib/Theme";
6
7
  import { ResponsiveHeader } from "@arcblock/ux/lib/Header";
7
- import NavMenu from "@arcblock/ux/lib/NavMenu";
8
+ import NavMenu, { Products } from "@arcblock/ux/lib/NavMenu";
8
9
  import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
9
10
  import { temp as colors } from "@arcblock/ux/lib/Colors";
11
+ import { translate } from "@arcblock/ux/lib/Locale/util";
10
12
  import omit from "lodash/omit";
13
+ import isFinite from "lodash/isFinite";
11
14
  import clsx from "clsx";
12
15
  import Icon from "../Icon/index.js";
13
16
  import OverridableThemeProvider from "../common/overridable-theme-provider.js";
@@ -17,6 +20,14 @@ import HeaderAddons from "../common/header-addons.js";
17
20
  import { useWalletHiddenTopbar } from "../common/wallet-hidden-topbar.js";
18
21
  import withHideWhenEmbed from "../libs/with-hide-when-embed.js";
19
22
  import useMobile from "../hooks/use-mobile.js";
23
+ const translations = {
24
+ en: {
25
+ products: "Products"
26
+ },
27
+ zh: {
28
+ products: "\u4EA7\u54C1"
29
+ }
30
+ };
20
31
  const parseNavigation = (navigation) => {
21
32
  if (!navigation?.length) {
22
33
  return { navItems: [], activeId: null };
@@ -65,6 +76,9 @@ function Header({
65
76
  }) {
66
77
  useWalletHiddenTopbar();
67
78
  const { locale } = useLocaleContext() || {};
79
+ const t = useMemoizedFn((key, data = {}) => {
80
+ return translate(translations, key, locale, "en", data);
81
+ });
68
82
  const formattedBlocklet = useMemo(() => {
69
83
  const blocklet = Object.assign({}, window.blocklet, meta);
70
84
  try {
@@ -82,6 +96,14 @@ function Header({
82
96
  const navigation = getLocalizedNavigation(formattedBlocklet?.navigation?.header, locale);
83
97
  const parsedNavigation = parseNavigation(navigation);
84
98
  const { navItems, activeId } = parsedNavigation;
99
+ const productsNavOrder = parseInt(window.blocklet?.USE_ARCBLOCK_THEME, 10);
100
+ if (isFinite(productsNavOrder)) {
101
+ navItems.splice(productsNavOrder, 0, {
102
+ label: t("products"),
103
+ // eslint-disable-next-line react/no-unstable-nested-components
104
+ children: ({ isOpen }) => /* @__PURE__ */ jsx(Products, { isOpen })
105
+ });
106
+ }
85
107
  const _addons = typeof addons === "function" ? (builtInAddons) => addons(builtInAddons, { navigation: parsedNavigation }) : addons;
86
108
  const headerAddons = (
87
109
  // @ts-ignore
package/lib/utils.d.ts CHANGED
@@ -6,3 +6,4 @@ export function isUrl(str: any): boolean;
6
6
  export function isIconifyString(str: any): boolean;
7
7
  export function matchPath(path: any): any;
8
8
  export function matchPaths(paths?: any[]): number;
9
+ export function splitNavColumns(items: any, options?: {}): never[][];
package/lib/utils.js CHANGED
@@ -80,3 +80,74 @@ export const matchPaths = (paths = []) => {
80
80
  }, matched[0]);
81
81
  return mostSpecific.index;
82
82
  };
83
+
84
+ /** 导航列表分列 */
85
+ export const splitNavColumns = (items, options = {}) => {
86
+ const { columns = 1, breakInside = false, groupHeight = 48, itemHeight = 24, childrenKey = 'items' } = options;
87
+
88
+ // 高度预估
89
+ const totalHeight = items.reduce((height, group) => {
90
+ return height + groupHeight + (group[childrenKey]?.length || 0) * itemHeight;
91
+ }, 0);
92
+ const targetHeight = Math.ceil(totalHeight / columns);
93
+
94
+ // 使用贪心策略进行分列
95
+ const result = [[]];
96
+ let currentColumn = 0;
97
+ let currentHeight = 0;
98
+
99
+ // 允许的高度偏差范围(有利于得到高度相差不大的列)
100
+ const heightVariance = targetHeight * 0.2;
101
+
102
+ // 是否应该分列
103
+ const shouldBreakColumn = (nextHeight) => {
104
+ return (
105
+ currentHeight > targetHeight - heightVariance &&
106
+ currentColumn < columns - 1 &&
107
+ currentHeight + nextHeight > targetHeight + heightVariance
108
+ );
109
+ };
110
+
111
+ items.forEach((group) => {
112
+ const groupTotalHeight = groupHeight + (group[childrenKey]?.length || 0) * itemHeight;
113
+
114
+ // 允许截断分组时,可以在任何子项处换列
115
+ if (breakInside && shouldBreakColumn(groupHeight)) {
116
+ currentColumn++;
117
+ currentHeight = 0;
118
+ result[currentColumn] = [];
119
+ }
120
+ // 不允许截断分组时,只能在分组边界换列
121
+ if (!breakInside && currentHeight > 0 && shouldBreakColumn(groupTotalHeight)) {
122
+ currentColumn++;
123
+ currentHeight = 0;
124
+ result[currentColumn] = [];
125
+ }
126
+
127
+ // 添加分组标题
128
+ result[currentColumn].push({
129
+ ...group,
130
+ group: true,
131
+ });
132
+ currentHeight += groupHeight;
133
+
134
+ // 添加子项
135
+ if (group[childrenKey]) {
136
+ group[childrenKey].forEach((child) => {
137
+ if (breakInside && shouldBreakColumn(itemHeight)) {
138
+ currentColumn++;
139
+ currentHeight = 0;
140
+ result[currentColumn] = [];
141
+ }
142
+
143
+ result[currentColumn].push({
144
+ ...child,
145
+ group: false,
146
+ });
147
+ currentHeight += itemHeight;
148
+ });
149
+ }
150
+ });
151
+
152
+ return result;
153
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/ui-react",
3
- "version": "2.12.0",
3
+ "version": "2.12.2",
4
4
  "description": "Some useful front-end web components that can be used in Blocklets.",
5
5
  "keywords": [
6
6
  "react",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@abtnode/constant": "^1.16.39",
36
- "@arcblock/bridge": "^2.12.0",
37
- "@arcblock/react-hooks": "^2.12.0",
36
+ "@arcblock/bridge": "^2.12.2",
37
+ "@arcblock/react-hooks": "^2.12.2",
38
38
  "@arcblock/ws": "^1.19.13",
39
39
  "@blocklet/did-space-react": "^1.0.22",
40
40
  "@iconify-icons/logos": "^1.2.36",
@@ -84,5 +84,5 @@
84
84
  "jest": "^29.7.0",
85
85
  "unbuild": "^2.0.0"
86
86
  },
87
- "gitHead": "ce9042b3a31a06b60ab4d3b923e8b5903bbbf106"
87
+ "gitHead": "4dc132cab82765eef5194cf00075a13bd5d8e458"
88
88
  }
@@ -52,21 +52,23 @@ const Root = styled('div')`
52
52
  .footer-brand-logo {
53
53
  display: flex;
54
54
  align-items: center;
55
- margin-right: 16px;
55
+ margin-right: 12px;
56
56
  line-height: 1;
57
57
  img,
58
58
  svg {
59
59
  width: auto;
60
- height: 44px;
61
- max-height: 44px;
60
+ height: 40px;
61
+ max-height: 40px;
62
62
  }
63
63
  }
64
64
  .footer-brand-name {
65
- font-size: 16px;
66
- font-weight: bold;
65
+ font-size: 18px;
66
+ color: ${(props) => props.theme.palette.grey[900]};
67
67
  }
68
68
  .footer-brand-desc {
69
+ white-space: pre-line;
69
70
  margin-top: 16px;
71
+ color: #9397a1;
70
72
  }
71
73
 
72
74
  ${(props) => props.theme.breakpoints.down('sm')} {
@@ -1,11 +1,13 @@
1
1
  import { useMemo } from 'react';
2
2
  import PropTypes from 'prop-types';
3
+ import { useCreation } from 'ahooks';
3
4
  import { styled } from '@arcblock/ux/lib/Theme';
4
5
  import { withErrorBoundary } from 'react-error-boundary';
5
6
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
6
7
  import { ErrorFallback } from '@arcblock/ux/lib/ErrorBoundary';
7
8
  import { temp as colors } from '@arcblock/ux/lib/Colors';
8
9
  import omit from 'lodash/omit';
10
+ import isFinite from 'lodash/isFinite';
9
11
 
10
12
  import OverridableThemeProvider from '../common/overridable-theme-provider';
11
13
  import InternalFooter from './internal-footer';
@@ -13,6 +15,7 @@ import { mapRecursive } from '../utils';
13
15
  import { formatBlockletInfo, getLocalizedNavigation } from '../blocklets';
14
16
  import { BlockletMetaProps } from '../types';
15
17
  import withHideWhenEmbed from '../libs/with-hide-when-embed';
18
+
16
19
  /**
17
20
  * 专门用于 (composable) blocklet 的 Footer 组件, 基于 blocklet meta 中的数据渲染
18
21
  */
@@ -27,14 +30,37 @@ function Footer({ meta, theme: themeOverrides, ...rest }) {
27
30
  return blocklet;
28
31
  }
29
32
  }, [meta]);
33
+ const productsNav = useCreation(() => {
34
+ return {
35
+ title: { en: 'Products', zh: '产品' },
36
+ section: ['footer'],
37
+ items: [
38
+ { title: 'ArcSphere', link: `https://www.arcblock.io/content/tags/${locale}/arcsphere`, isNew: true },
39
+ { title: 'DID Wallet', link: `https://www.didwallet.io/${locale}` },
40
+ { title: 'DID Spaces', link: `https://www.didspaces.com/${locale}` },
41
+ { title: 'DID Name Service', link: `https://www.didnames.io/${locale}` },
42
+ { title: 'Blocklet Launcher', link: `https://launcher.arcblock.io/${locale}` },
43
+ { title: 'Blocklet Server', link: `https://www.arcblock.io/content/collections/${locale}/blocklet-server` },
44
+ { title: 'AIGNE', link: `https://www.aigne.io/${locale}` },
45
+ ],
46
+ };
47
+ }, [locale]);
48
+
30
49
  if (!formattedBlocklet.appName) {
31
50
  return null;
32
51
  }
33
52
 
34
53
  const { appLogo, appLogoRect, appName, appDescription, description, theme, copyright } = formattedBlocklet;
54
+ const navFooter = [...(formattedBlocklet?.navigation?.footer ?? [])];
55
+
56
+ // 显示 Products 导航
57
+ const productsNavOrder = parseInt(window.blocklet?.USE_ARCBLOCK_THEME, 10);
58
+ if (isFinite(productsNavOrder)) {
59
+ navFooter.splice(productsNavOrder, 0, productsNav);
60
+ }
35
61
 
36
62
  const localized = {
37
- footerNav: getLocalizedNavigation(formattedBlocklet?.navigation?.footer, locale) || [],
63
+ footerNav: getLocalizedNavigation(navFooter, locale) || [],
38
64
  socialMedia: getLocalizedNavigation(formattedBlocklet?.navigation?.social, locale) || [],
39
65
  links: getLocalizedNavigation(formattedBlocklet?.navigation?.bottom, locale) || [],
40
66
  };
@@ -35,7 +35,7 @@ function InternalFooter(props) {
35
35
  return brand ? <Brand {...brand} /> : null;
36
36
  };
37
37
  const renderNavigation = () => {
38
- return navigation?.length ? <Links links={navigation} /> : null;
38
+ return navigation?.length ? <Links links={navigation} columns={3} /> : null;
39
39
  };
40
40
  const renderSocialMedia = () => {
41
41
  return socialMedia?.length ? <SocialMedia items={socialMedia} /> : null;
@@ -1,5 +1,7 @@
1
1
  import PropTypes from 'prop-types';
2
+ import clsx from 'clsx';
2
3
  import Box from '@mui/material/Box';
4
+ import { grey } from '@mui/material/colors';
3
5
  import Container from '@mui/material/Container';
4
6
  import { styled } from '@arcblock/ux/lib/Theme';
5
7
 
@@ -8,25 +10,52 @@ import Row from './row';
8
10
  /**
9
11
  * footer standard layout
10
12
  */
11
- function StandardLayout({ elements, data, ...rest }) {
12
- return (
13
- <Root {...rest}>
14
- <Container>
13
+ function StandardLayout({ elements, data, className, ...rest }) {
14
+ const withNavigation = !!data.navigation?.length;
15
+ let topSection = null;
16
+
17
+ if (withNavigation) {
18
+ // 左 brand & social,右导航栏
19
+ topSection = (
20
+ <Box sx={{ display: 'flex', flexDirection: { xs: 'column', md: 'row' }, justifyContent: 'space-between' }}>
15
21
  <Box
16
22
  sx={{
23
+ flex: '1 1 auto',
24
+ paddingRight: { xs: 0, md: 3 },
17
25
  display: 'flex',
18
- flexDirection: { xs: 'column', md: 'row' },
19
- justifyContent: 'space-between',
20
- alignItems: { xs: 'center', md: 'space-between' },
26
+ flexDirection: 'column',
27
+ alignItems: { xs: 'center', md: 'flex-start' },
21
28
  gap: 2,
22
29
  pb: 3,
23
30
  }}>
24
31
  <Box>{elements.brand}</Box>
25
32
  <Box lineHeight={1}>{elements.socialMedia}</Box>
26
33
  </Box>
27
- {!!data.navigation?.length && (
28
- <Box sx={{ mb: 6, pt: 3, borderTop: 1, borderColor: 'grey.200' }}>{elements.navigation}</Box>
29
- )}
34
+ <Box sx={{ mb: 3, borderTop: { xs: `1px solid ${grey[200]}`, md: 0 } }}>{elements.navigation}</Box>
35
+ </Box>
36
+ );
37
+ } else {
38
+ // 左 brand,右 social
39
+ topSection = (
40
+ <Box
41
+ sx={{
42
+ display: 'flex',
43
+ flexDirection: { xs: 'column', md: 'row' },
44
+ justifyContent: 'space-between',
45
+ alignItems: { xs: 'center', md: 'space-between' },
46
+ gap: 2,
47
+ pb: 3,
48
+ }}>
49
+ <Box>{elements.brand}</Box>
50
+ <Box lineHeight={1}>{elements.socialMedia}</Box>
51
+ </Box>
52
+ );
53
+ }
54
+
55
+ return (
56
+ <Root {...rest} className={clsx({ 'footer--with-navs': withNavigation }, className)}>
57
+ <Container>
58
+ {topSection}
30
59
  <Row sx={{ pt: 3, borderTop: 1, borderColor: 'grey.200' }} autoCenter>
31
60
  {elements.copyright}
32
61
  {elements.links}
@@ -45,6 +74,7 @@ StandardLayout.propTypes = {
45
74
  links: PropTypes.element,
46
75
  }).isRequired,
47
76
  data: PropTypes.object.isRequired,
77
+ className: PropTypes.string,
48
78
  };
49
79
 
50
80
  const Root = styled('div')`
@@ -53,6 +83,14 @@ const Root = styled('div')`
53
83
  .footer-brand-desc {
54
84
  display: none;
55
85
  }
86
+ &.footer--with-navs {
87
+ ${(props) => props.theme.breakpoints.up('md')} {
88
+ .footer-brand-desc {
89
+ max-width: 360px;
90
+ display: block;
91
+ }
92
+ }
93
+ }
56
94
  && .footer-brand-logo {
57
95
  margin-right: 0;
58
96
  }
@@ -1,22 +1,26 @@
1
1
  /* eslint-disable react/no-array-index-key */
2
2
  import { useState } from 'react';
3
3
  import PropTypes from 'prop-types';
4
+ import { useCreation } from 'ahooks';
5
+ import isInteger from 'lodash/isInteger';
4
6
  import { styled } from '@arcblock/ux/lib/Theme';
5
7
  import clsx from 'clsx';
6
8
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
7
9
  import Icon from '../Icon';
10
+ import useMobile from '../hooks/use-mobile';
11
+ import { splitNavColumns } from '../utils';
8
12
 
9
13
  /**
10
14
  * footer 中的 links (支持分组, 最多支持 2 级)
11
15
  * TODO: dark/light theme
12
16
  */
13
- export default function Links({ links, flowLayout, ...rest }) {
17
+ export default function Links({ links, flowLayout, columns, ...rest }) {
14
18
  const [activeIndex, setActiveIndex] = useState(-1);
15
- if (!links?.length) {
16
- return null;
17
- }
19
+ const isMobile = useMobile({ key: 'md' });
18
20
  // 只要发现一项元素有子元素, 就认为是分组 (大字号突出 group title)
19
21
  const isGroupMode = links.some((item) => item.items?.length);
22
+ // 是否启用 columns 布局
23
+ const columnsLayout = !isMobile && isGroupMode && isInteger(columns) && columns > 1;
20
24
  const renderItem = ({ label, link, icon, render, props }) => {
21
25
  let result = label;
22
26
  if (render) {
@@ -35,56 +39,101 @@ export default function Links({ links, flowLayout, ...rest }) {
35
39
  </>
36
40
  );
37
41
  };
42
+ const content = useCreation(() => {
43
+ if (!links?.length) {
44
+ return null;
45
+ }
46
+ // 流布局
47
+ if (flowLayout) {
48
+ return links.map((item, i) => (
49
+ <span key={i} className="footer-links-item">
50
+ {renderItem(item)}
51
+ </span>
52
+ ));
53
+ }
54
+ // 列布局
55
+ if (columnsLayout) {
56
+ return splitNavColumns(links, { columns }).map((cols, i) => {
57
+ return (
58
+ <div key={i} className="footer-links-column">
59
+ {cols
60
+ .filter((v) => v.group)
61
+ .map((item, j) => {
62
+ const { items } = item;
63
+
64
+ return (
65
+ <div key={j} className="footer-links-group">
66
+ <span className="footer-links-item">{renderItem(item)}</span>
67
+ {!!items?.length && (
68
+ <div className="footer-links-sub">
69
+ {items.map((child, k) => (
70
+ <span
71
+ key={k}
72
+ className={clsx('footer-links-item', { 'footer-links-item--new': child.isNew })}>
73
+ {renderItem(child)}
74
+ </span>
75
+ ))}
76
+ </div>
77
+ )}
78
+ </div>
79
+ );
80
+ })}
81
+ </div>
82
+ );
83
+ });
84
+ }
85
+ // 纯 flex 布局
86
+ return links.map((item, i) => {
87
+ const { items } = item;
88
+ // 用于移动端展开
89
+ const isActive = i === activeIndex;
90
+
91
+ return (
92
+ <div
93
+ key={i}
94
+ className={clsx('footer-links-group', {
95
+ 'footer-links-group--active': isActive,
96
+ })}
97
+ onClick={() => setActiveIndex(activeIndex === i ? -1 : i)}>
98
+ <span className="footer-links-item">
99
+ {renderItem(item)}
100
+ {!!items?.length && (
101
+ <span className="footer-links-group-expand-icon">
102
+ <ExpandMoreIcon
103
+ style={{
104
+ transform: `rotate(${isActive ? 180 : 0}deg)`,
105
+ }}
106
+ />
107
+ </span>
108
+ )}
109
+ </span>
110
+ {!!items?.length && (
111
+ <div className="footer-links-sub">
112
+ {items.map((child, j) => (
113
+ <span key={j} className={clsx('footer-links-item', { 'footer-links-item--new': child.isNew })}>
114
+ {renderItem(child)}
115
+ </span>
116
+ ))}
117
+ </div>
118
+ )}
119
+ </div>
120
+ );
121
+ });
122
+ }, [links, flowLayout, columnsLayout, activeIndex]);
123
+
124
+ if (!links?.length) {
125
+ return null;
126
+ }
127
+
38
128
  return (
39
129
  <Root
40
130
  {...rest}
41
131
  className={clsx(rest.className, {
42
132
  'footer-links--grouped': isGroupMode,
43
133
  'footer-links--flow': flowLayout,
134
+ 'footer-links--columns': columnsLayout,
44
135
  })}>
45
- <div className="footer-links-inner">
46
- {flowLayout &&
47
- links.map((item, i) => (
48
- <span key={i} className="footer-links-item">
49
- {renderItem(item)}
50
- </span>
51
- ))}
52
- {!flowLayout &&
53
- links.map((item, i) => {
54
- const { items } = item;
55
- const isActive = i === activeIndex;
56
- return (
57
- <div
58
- key={i}
59
- className={clsx('footer-links-group', {
60
- 'footer-links-group--active': isActive,
61
- })}
62
- onClick={() => setActiveIndex(activeIndex === i ? -1 : i)}>
63
- <span className="footer-links-item">
64
- {renderItem(item)}
65
- {!!items?.length && (
66
- <span className="footer-links-group-expand-icon">
67
- <ExpandMoreIcon
68
- style={{
69
- transform: `rotate(${isActive ? 180 : 0}deg)`,
70
- }}
71
- />
72
- </span>
73
- )}
74
- </span>
75
- {!!items?.length && (
76
- <div className="footer-links-sub">
77
- {items.map((child, j) => (
78
- <span key={j} className="footer-links-item">
79
- {renderItem(child)}
80
- </span>
81
- ))}
82
- </div>
83
- )}
84
- </div>
85
- );
86
- })}
87
- </div>
136
+ <div className="footer-links-inner">{content}</div>
88
137
  </Root>
89
138
  );
90
139
  }
@@ -100,6 +149,8 @@ Links.propTypes = {
100
149
  ),
101
150
  // 流动布局, 简单的从左到右排列
102
151
  flowLayout: PropTypes.bool,
152
+ // 列布局
153
+ columns: PropTypes.number,
103
154
  };
104
155
 
105
156
  Links.defaultProps = {
@@ -109,7 +160,7 @@ Links.defaultProps = {
109
160
 
110
161
  const Root = styled('div')`
111
162
  overflow: hidden;
112
- color: ${(props) => props.theme.palette.grey[600]};
163
+ color: #9397a1;
113
164
  .footer-links-inner {
114
165
  display: flex;
115
166
  justify-content: space-between;
@@ -120,9 +171,6 @@ const Root = styled('div')`
120
171
  display: flex;
121
172
  flex-direction: column;
122
173
  }
123
- .footer-links-sub .footer-links-item {
124
- color: ${(props) => props.theme.palette.grey[900]};
125
- }
126
174
  .footer-links-group-expand-icon {
127
175
  display: none;
128
176
  position: absolute;
@@ -139,13 +187,22 @@ const Root = styled('div')`
139
187
  display: inline-flex;
140
188
  align-items: center;
141
189
  position: relative;
142
- padding: 4px 8px;
190
+ padding: 6px 8px;
143
191
  font-size: 14px;
192
+ &--new::after {
193
+ content: 'New';
194
+ color: #4672ea;
195
+ background-color: #e1e8fb;
196
+ padding: 1px 8px;
197
+ border-radius: 10px/50%;
198
+ margin-left: 8px;
199
+ }
144
200
  }
145
201
  &.footer-links--grouped {
146
202
  .footer-links-group {
147
203
  > .footer-links-item {
148
- font-weight: bold;
204
+ font-weight: 600;
205
+ color: #25292f;
149
206
  }
150
207
  .footer-links-sub {
151
208
  margin-top: 8px;
@@ -157,11 +214,29 @@ const Root = styled('div')`
157
214
  max-width: 150px;
158
215
  color: inherit;
159
216
  text-decoration: none;
217
+ transition: color 0.2s ease-in-out;
160
218
  &:hover {
161
- text-decoration: underline;
219
+ color: #25292f;
220
+ }
221
+ }
222
+ /* columns 布局 */
223
+ &.footer-links--columns {
224
+ .footer-links-inner {
225
+ gap: 96px;
226
+ }
227
+ .footer-links-column {
228
+ display: flex;
229
+ flex-direction: column;
230
+ }
231
+ .footer-links-group {
232
+ .footer-links-sub {
233
+ margin-top: 2px;
234
+ margin-bottom: 12px;
235
+ }
162
236
  }
163
237
  }
164
238
 
239
+ /* flow 布局 */
165
240
  &.footer-links--flow {
166
241
  display: inline-flex;
167
242
  .footer-links-inner {
@@ -183,6 +258,7 @@ const Root = styled('div')`
183
258
  }
184
259
  }
185
260
 
261
+ /* 移动端样式 */
186
262
  ${(props) => props.theme.breakpoints.down('md')} {
187
263
  .footer-links-inner {
188
264
  flex-direction: column;
@@ -54,8 +54,9 @@ const Root = styled('div')`
54
54
  a {
55
55
  color: ${(props) => props.theme.palette.grey[400]};
56
56
  text-decoration: none;
57
+ transition: color 0.2s ease-in-out;
57
58
  &:hover {
58
- color: ${(props) => props.theme.palette.primary.light};
59
+ color: #25292f;
59
60
  }
60
61
  }
61
62
  ${(props) => props.theme.breakpoints.down('md')} {
@@ -1,12 +1,15 @@
1
1
  import { useMemo } from 'react';
2
+ import { useMemoizedFn } from 'ahooks';
2
3
  import { withErrorBoundary } from 'react-error-boundary';
3
4
  import { ErrorFallback } from '@arcblock/ux/lib/ErrorBoundary';
4
5
  import { styled } from '@arcblock/ux/lib/Theme';
5
6
  import { ResponsiveHeader } from '@arcblock/ux/lib/Header';
6
- import NavMenu from '@arcblock/ux/lib/NavMenu';
7
+ import NavMenu, { Products } from '@arcblock/ux/lib/NavMenu';
7
8
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
8
9
  import { temp as colors } from '@arcblock/ux/lib/Colors';
10
+ import { translate } from '@arcblock/ux/lib/Locale/util';
9
11
  import omit from 'lodash/omit';
12
+ import isFinite from 'lodash/isFinite';
10
13
  import type { BoxProps, Breakpoint } from '@mui/material';
11
14
  import clsx from 'clsx';
12
15
 
@@ -20,6 +23,15 @@ import { BlockletMetaProps, SessionManagerProps } from '../@types';
20
23
  import withHideWhenEmbed from '../libs/with-hide-when-embed';
21
24
  import useMobile from '../hooks/use-mobile';
22
25
 
26
+ const translations = {
27
+ en: {
28
+ products: 'Products',
29
+ },
30
+ zh: {
31
+ products: '产品',
32
+ },
33
+ };
34
+
23
35
  // blocklet meta 中的 navigation 数据 => NavMenu 组件的 items
24
36
  const parseNavigation = (navigation: any) => {
25
37
  if (!navigation?.length) {
@@ -98,6 +110,9 @@ function Header({
98
110
  }: HeaderProps & Omit<BoxProps, keyof HeaderProps>) {
99
111
  useWalletHiddenTopbar();
100
112
  const { locale } = useLocaleContext() || {};
113
+ const t = useMemoizedFn((key, data = {}) => {
114
+ return translate(translations, key, locale, 'en', data);
115
+ });
101
116
  const formattedBlocklet = useMemo(() => {
102
117
  const blocklet = Object.assign({}, window.blocklet, meta);
103
118
  try {
@@ -117,6 +132,16 @@ function Header({
117
132
  const parsedNavigation = parseNavigation(navigation);
118
133
  const { navItems, activeId } = parsedNavigation;
119
134
 
135
+ // 显示 Products 导航
136
+ const productsNavOrder = parseInt(window.blocklet?.USE_ARCBLOCK_THEME, 10);
137
+ if (isFinite(productsNavOrder)) {
138
+ navItems.splice(productsNavOrder, 0, {
139
+ label: t('products'),
140
+ // eslint-disable-next-line react/no-unstable-nested-components
141
+ children: ({ isOpen }: { isOpen: boolean }) => <Products isOpen={isOpen} />,
142
+ });
143
+ }
144
+
120
145
  // eslint-disable-next-line @typescript-eslint/naming-convention
121
146
  const _addons =
122
147
  typeof addons === 'function'
package/src/utils.js CHANGED
@@ -80,3 +80,74 @@ export const matchPaths = (paths = []) => {
80
80
  }, matched[0]);
81
81
  return mostSpecific.index;
82
82
  };
83
+
84
+ /** 导航列表分列 */
85
+ export const splitNavColumns = (items, options = {}) => {
86
+ const { columns = 1, breakInside = false, groupHeight = 48, itemHeight = 24, childrenKey = 'items' } = options;
87
+
88
+ // 高度预估
89
+ const totalHeight = items.reduce((height, group) => {
90
+ return height + groupHeight + (group[childrenKey]?.length || 0) * itemHeight;
91
+ }, 0);
92
+ const targetHeight = Math.ceil(totalHeight / columns);
93
+
94
+ // 使用贪心策略进行分列
95
+ const result = [[]];
96
+ let currentColumn = 0;
97
+ let currentHeight = 0;
98
+
99
+ // 允许的高度偏差范围(有利于得到高度相差不大的列)
100
+ const heightVariance = targetHeight * 0.2;
101
+
102
+ // 是否应该分列
103
+ const shouldBreakColumn = (nextHeight) => {
104
+ return (
105
+ currentHeight > targetHeight - heightVariance &&
106
+ currentColumn < columns - 1 &&
107
+ currentHeight + nextHeight > targetHeight + heightVariance
108
+ );
109
+ };
110
+
111
+ items.forEach((group) => {
112
+ const groupTotalHeight = groupHeight + (group[childrenKey]?.length || 0) * itemHeight;
113
+
114
+ // 允许截断分组时,可以在任何子项处换列
115
+ if (breakInside && shouldBreakColumn(groupHeight)) {
116
+ currentColumn++;
117
+ currentHeight = 0;
118
+ result[currentColumn] = [];
119
+ }
120
+ // 不允许截断分组时,只能在分组边界换列
121
+ if (!breakInside && currentHeight > 0 && shouldBreakColumn(groupTotalHeight)) {
122
+ currentColumn++;
123
+ currentHeight = 0;
124
+ result[currentColumn] = [];
125
+ }
126
+
127
+ // 添加分组标题
128
+ result[currentColumn].push({
129
+ ...group,
130
+ group: true,
131
+ });
132
+ currentHeight += groupHeight;
133
+
134
+ // 添加子项
135
+ if (group[childrenKey]) {
136
+ group[childrenKey].forEach((child) => {
137
+ if (breakInside && shouldBreakColumn(itemHeight)) {
138
+ currentColumn++;
139
+ currentHeight = 0;
140
+ result[currentColumn] = [];
141
+ }
142
+
143
+ result[currentColumn].push({
144
+ ...child,
145
+ group: false,
146
+ });
147
+ currentHeight += itemHeight;
148
+ });
149
+ }
150
+ });
151
+
152
+ return result;
153
+ };