@bbki.ng/ui 0.1.1

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.
Files changed (97) hide show
  1. package/.storybook/main.ts +21 -0
  2. package/.storybook/preview-head.html +10 -0
  3. package/.storybook/preview.tsx +30 -0
  4. package/CHANGELOG.md +8 -0
  5. package/README.md +124 -0
  6. package/package.json +57 -0
  7. package/scripts/build-tokens.ts +170 -0
  8. package/src/atoms/blink-dot/BlinkDot.stories.tsx +44 -0
  9. package/src/atoms/blink-dot/BlinkDot.tsx +45 -0
  10. package/src/atoms/blink-dot/index.ts +2 -0
  11. package/src/atoms/button/Button.stories.tsx +84 -0
  12. package/src/atoms/button/Button.tsx +59 -0
  13. package/src/atoms/button/Button.types.ts +20 -0
  14. package/src/atoms/button/Button.variants.ts +58 -0
  15. package/src/atoms/button/index.ts +3 -0
  16. package/src/atoms/link/Link.stories.tsx +121 -0
  17. package/src/atoms/link/Link.tsx +69 -0
  18. package/src/atoms/link/Link.types.ts +26 -0
  19. package/src/atoms/link/Link.variants.ts +55 -0
  20. package/src/atoms/link/index.ts +3 -0
  21. package/src/atoms/logo/Logo.stories.tsx +37 -0
  22. package/src/atoms/logo/Logo.tsx +49 -0
  23. package/src/atoms/logo/Logo.types.ts +4 -0
  24. package/src/atoms/logo/index.ts +2 -0
  25. package/src/index.ts +54 -0
  26. package/src/layout/container/Container.stories.tsx +73 -0
  27. package/src/layout/container/Container.tsx +57 -0
  28. package/src/layout/container/index.ts +2 -0
  29. package/src/layout/grid/Grid.stories.tsx +106 -0
  30. package/src/layout/grid/Grid.tsx +86 -0
  31. package/src/layout/grid/index.ts +2 -0
  32. package/src/layout/index.ts +4 -0
  33. package/src/molecules/article/Article.stories.tsx +45 -0
  34. package/src/molecules/article/Article.tsx +25 -0
  35. package/src/molecules/article/Article.types.ts +11 -0
  36. package/src/molecules/article/index.ts +2 -0
  37. package/src/molecules/breadcrumb/Breadcrumb.stories.tsx +60 -0
  38. package/src/molecules/breadcrumb/Breadcrumb.tsx +43 -0
  39. package/src/molecules/breadcrumb/Breadcrumb.types.ts +19 -0
  40. package/src/molecules/breadcrumb/index.ts +2 -0
  41. package/src/molecules/list/List.stories.tsx +84 -0
  42. package/src/molecules/list/List.tsx +79 -0
  43. package/src/molecules/list/List.types.ts +23 -0
  44. package/src/molecules/list/index.ts +2 -0
  45. package/src/molecules/nav/Nav.stories.tsx +45 -0
  46. package/src/molecules/nav/Nav.tsx +29 -0
  47. package/src/molecules/nav/Nav.types.ts +10 -0
  48. package/src/molecules/nav/index.ts +2 -0
  49. package/src/molecules/panel/Panel.stories.tsx +42 -0
  50. package/src/molecules/panel/Panel.tsx +27 -0
  51. package/src/molecules/panel/Panel.types.ts +6 -0
  52. package/src/molecules/panel/index.ts +2 -0
  53. package/src/molecules/table/Table.stories.tsx +54 -0
  54. package/src/molecules/table/Table.tsx +31 -0
  55. package/src/molecules/table/Table.types.ts +20 -0
  56. package/src/molecules/table/index.ts +2 -0
  57. package/src/organisms/canvas/Canvas.tsx +79 -0
  58. package/src/organisms/canvas/Canvas.types.ts +25 -0
  59. package/src/organisms/canvas/index.ts +3 -0
  60. package/src/organisms/canvas/useRenderer.ts +44 -0
  61. package/src/organisms/drop-image/DropImage.stories.tsx +36 -0
  62. package/src/organisms/drop-image/DropImage.tsx +193 -0
  63. package/src/organisms/drop-image/DropImage.types.ts +26 -0
  64. package/src/organisms/drop-image/index.ts +3 -0
  65. package/src/organisms/drop-image/useDropImage.ts +124 -0
  66. package/src/organisms/drop-image/utils.ts +1 -0
  67. package/src/organisms/drop-zone/DropZone.tsx +58 -0
  68. package/src/organisms/drop-zone/DropZone.types.ts +9 -0
  69. package/src/organisms/drop-zone/index.ts +2 -0
  70. package/src/organisms/loading-spiral/LoadingSpiral.stories.tsx +30 -0
  71. package/src/organisms/loading-spiral/LoadingSpiral.tsx +44 -0
  72. package/src/organisms/loading-spiral/LoadingSpiral.types.ts +4 -0
  73. package/src/organisms/loading-spiral/constants.ts +62 -0
  74. package/src/organisms/loading-spiral/createOptions.ts +31 -0
  75. package/src/organisms/loading-spiral/createSettings.ts +26 -0
  76. package/src/organisms/loading-spiral/index.ts +2 -0
  77. package/src/organisms/loading-spiral/useCanvasRef.ts +23 -0
  78. package/src/organisms/loading-spiral/utils.ts +5 -0
  79. package/src/organisms/page/Page.stories.tsx +65 -0
  80. package/src/organisms/page/Page.tsx +71 -0
  81. package/src/organisms/page/Page.types.ts +23 -0
  82. package/src/organisms/page/index.ts +8 -0
  83. package/src/styles.css +151 -0
  84. package/src/theme/ThemeContext.tsx +20 -0
  85. package/src/theme/ThemeProvider.tsx +93 -0
  86. package/src/theme/index.ts +3 -0
  87. package/src/tokens/css/dark.css +111 -0
  88. package/src/tokens/css/light.css +111 -0
  89. package/src/tokens/index.ts +127 -0
  90. package/tokens/base/colors.json +54 -0
  91. package/tokens/base/shadows.json +34 -0
  92. package/tokens/base/spacing.json +21 -0
  93. package/tokens/base/typography.json +35 -0
  94. package/tokens/semantic/dark.json +50 -0
  95. package/tokens/semantic/light.json +54 -0
  96. package/tsconfig.json +22 -0
  97. package/vite.config.ts +44 -0
@@ -0,0 +1,106 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Grid } from './Grid';
3
+
4
+ const meta: Meta<typeof Grid> = {
5
+ title: 'Layout/Grid',
6
+ component: Grid,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ cols: {
10
+ control: 'select',
11
+ options: [1, 2, 3, 4, 6, 12],
12
+ },
13
+ gap: {
14
+ control: 'select',
15
+ options: ['none', 'sm', 'md', 'lg', 'xl'],
16
+ },
17
+ },
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ const Box = ({ children }: { children: React.ReactNode }) => (
24
+ <div className="h-24 bg-[var(--semantic-color-muted)] rounded flex items-center justify-center text-[var(--semantic-color-foreground)]">
25
+ {children}
26
+ </div>
27
+ );
28
+
29
+ export const Default: Story = {
30
+ args: {
31
+ children: (
32
+ <>
33
+ <Box>1</Box>
34
+ <Box>2</Box>
35
+ <Box>3</Box>
36
+ <Box>4</Box>
37
+ <Box>5</Box>
38
+ <Box>6</Box>
39
+ </>
40
+ ),
41
+ cols: 3,
42
+ gap: 'md',
43
+ },
44
+ };
45
+
46
+ export const ColumnVariants: Story = {
47
+ render: () => (
48
+ <div className="space-y-8">
49
+ {[2, 3, 4, 6].map(colCount => (
50
+ <div key={colCount}>
51
+ <p className="text-sm text-[var(--semantic-color-muted-foreground)] mb-2">
52
+ {colCount} columns
53
+ </p>
54
+ <Grid cols={colCount as 2 | 3 | 4 | 6} gap="md">
55
+ {Array.from({ length: colCount }).map((_, i) => (
56
+ <Box key={i}>{i + 1}</Box>
57
+ ))}
58
+ </Grid>
59
+ </div>
60
+ ))}
61
+ </div>
62
+ ),
63
+ };
64
+
65
+ export const GapVariations: Story = {
66
+ render: () => (
67
+ <div className="space-y-8">
68
+ {(['none', 'sm', 'md', 'lg', 'xl'] as const).map(gapSize => (
69
+ <div key={gapSize}>
70
+ <p className="text-sm text-[var(--semantic-color-muted-foreground)] mb-2">
71
+ gap: {gapSize}
72
+ </p>
73
+ <Grid cols={3} gap={gapSize}>
74
+ <Box>1</Box>
75
+ <Box>2</Box>
76
+ <Box>3</Box>
77
+ </Grid>
78
+ </div>
79
+ ))}
80
+ </div>
81
+ ),
82
+ };
83
+
84
+ export const ThreeColumnLayout: Story = {
85
+ render: () => (
86
+ <div className="h-96 border border-[var(--semantic-color-border)] rounded">
87
+ <Grid
88
+ gap="md"
89
+ leftAside={
90
+ <div className="h-full bg-[var(--semantic-color-muted)] rounded p-4 text-[var(--semantic-color-foreground)]">
91
+ Left Sidebar
92
+ </div>
93
+ }
94
+ rightAside={
95
+ <div className="h-full bg-[var(--semantic-color-muted)] rounded p-4 text-[var(--semantic-color-foreground)]">
96
+ Right Sidebar
97
+ </div>
98
+ }
99
+ >
100
+ <div className="h-full bg-[var(--semantic-color-primary)]/10 rounded p-4 text-[var(--semantic-color-foreground)]">
101
+ Main Content Area
102
+ </div>
103
+ </Grid>
104
+ </div>
105
+ ),
106
+ };
@@ -0,0 +1,86 @@
1
+ import React from 'react';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export interface GridProps {
5
+ children: React.ReactNode;
6
+ className?: string;
7
+ /** 列数 */
8
+ cols?: 1 | 2 | 3 | 4 | 6 | 12;
9
+ /** 间距 */
10
+ gap?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
11
+ /** 响应式:移动端列数 */
12
+ mobileCols?: 1 | 2;
13
+ /** 左侧固定内容(三栏布局) */
14
+ leftAside?: React.ReactNode;
15
+ /** 右侧固定内容(三栏布局) */
16
+ rightAside?: React.ReactNode;
17
+ }
18
+
19
+ const gapMap = {
20
+ none: 'gap-0',
21
+ sm: 'gap-2',
22
+ md: 'gap-4',
23
+ lg: 'gap-6',
24
+ xl: 'gap-8',
25
+ };
26
+
27
+ const colsMap: Record<number, string> = {
28
+ 1: 'grid-cols-1',
29
+ 2: 'grid-cols-2',
30
+ 3: 'grid-cols-3',
31
+ 4: 'grid-cols-4',
32
+ 6: 'grid-cols-6',
33
+ 12: 'grid-cols-12',
34
+ };
35
+
36
+ /**
37
+ * Grid 布局组件
38
+ *
39
+ * 支持标准网格和三栏布局(参考 MainLayout)。
40
+ */
41
+ export const Grid: React.FC<GridProps> = ({
42
+ children,
43
+ className,
44
+ cols = 3,
45
+ gap = 'md',
46
+ mobileCols = 1,
47
+ leftAside,
48
+ rightAside,
49
+ }) => {
50
+ // 三栏布局模式
51
+ if (leftAside || rightAside) {
52
+ return (
53
+ <div
54
+ className={twMerge(
55
+ 'grid w-full h-full',
56
+ 'grid-cols-1 md:grid-cols-3',
57
+ gapMap[gap],
58
+ className
59
+ )}
60
+ >
61
+ {leftAside && <div className="hidden md:block h-full overflow-auto">{leftAside}</div>}
62
+ <div className="h-full overflow-auto px-4">{children}</div>
63
+ {rightAside && <div className="hidden md:block h-full overflow-auto">{rightAside}</div>}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ // 标准网格模式
69
+ const responsiveCols = mobileCols === 2 ? 'grid-cols-2' : 'grid-cols-1';
70
+
71
+ return (
72
+ <div
73
+ className={twMerge(
74
+ 'grid w-full',
75
+ responsiveCols,
76
+ `md:${colsMap[cols]}`,
77
+ gapMap[gap],
78
+ className
79
+ )}
80
+ >
81
+ {children}
82
+ </div>
83
+ );
84
+ };
85
+
86
+ Grid.displayName = 'Grid';
@@ -0,0 +1,2 @@
1
+ export { Grid } from './Grid';
2
+ export type { GridProps } from './Grid';
@@ -0,0 +1,4 @@
1
+ export { Container } from './container';
2
+ export type { ContainerProps } from './container';
3
+ export { Grid } from './grid';
4
+ export type { GridProps } from './grid';
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Article } from './Article';
3
+
4
+ const meta: Meta<typeof Article> = {
5
+ title: 'Molecules/Article',
6
+ component: Article,
7
+ tags: ['autodocs'],
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ };
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ export const Default: Story = {
17
+ args: {
18
+ title: '文章标题',
19
+ children: <p className="text-content-primary">这是文章内容。可以包含任意 React 节点。</p>,
20
+ },
21
+ };
22
+
23
+ export const WithDate: Story = {
24
+ args: {
25
+ title: '带日期的文章',
26
+ date: '2024-01-15',
27
+ children: <p className="text-content-primary">这篇文章带有发布日期。</p>,
28
+ },
29
+ };
30
+
31
+ export const WithDescription: Story = {
32
+ args: {
33
+ title: '带描述的文章',
34
+ description: '这是文章的描述/摘要部分',
35
+ children: <p className="text-content-primary">这是文章的详细内容。</p>,
36
+ },
37
+ };
38
+
39
+ export const Loading: Story = {
40
+ args: {
41
+ title: '加载中',
42
+ loading: true,
43
+ children: <p className="text-content-secondary">内容正在加载中...</p>,
44
+ },
45
+ };
@@ -0,0 +1,25 @@
1
+ import { twMerge } from 'tailwind-merge';
2
+ import { BlinkDot } from '../../atoms/blink-dot';
3
+ import { ArticleProps } from './Article.types';
4
+
5
+ export const Article = (props: ArticleProps) => {
6
+ const { title, content, children, date, className, description, loading } = props;
7
+
8
+ return (
9
+ <div className={twMerge('relative', className)}>
10
+ <div className="mb-32 leading-none">
11
+ <span className="text-2xl mb-2 inline-block text-content-primary">{title}</span>
12
+ {loading && title && <BlinkDot status="blink" className="ml-2" />}
13
+ {date && (
14
+ <div className="px-2 pb-0 text-content-secondary">
15
+ <small>{date}</small>
16
+ </div>
17
+ )}
18
+ </div>
19
+ {description && <div className="mb-8 text-content-secondary">{description}</div>}
20
+ <div className="text-content-primary relative">{children || content}</div>
21
+ </div>
22
+ );
23
+ };
24
+
25
+ Article.displayName = 'Article';
@@ -0,0 +1,11 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ export interface ArticleProps {
4
+ title: ReactNode;
5
+ children?: ReactNode;
6
+ content?: ReactNode;
7
+ date?: string;
8
+ className?: string;
9
+ description?: ReactNode;
10
+ loading?: boolean;
11
+ }
@@ -0,0 +1,2 @@
1
+ export { Article } from './Article';
2
+ export type { ArticleProps } from './Article.types';
@@ -0,0 +1,60 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import { Breadcrumb } from './Breadcrumb';
4
+
5
+ const meta: Meta<typeof Breadcrumb> = {
6
+ title: 'Molecules/Breadcrumb',
7
+ component: Breadcrumb,
8
+ tags: ['autodocs'],
9
+ decorators: [
10
+ Story => (
11
+ <MemoryRouter>
12
+ <Story />
13
+ </MemoryRouter>
14
+ ),
15
+ ],
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof Breadcrumb>;
20
+
21
+ const defaultPaths = [{ name: '~', path: '/' }, { name: 'ext', path: '/ext' }, { name: 'txt' }];
22
+
23
+ const pathsWithCn = [
24
+ { name: '~', path: '/' },
25
+ { name: 'ext', path: '/ext' },
26
+ { name: 'txt', path: '/txt' },
27
+ { name: '与或非禁区' },
28
+ ];
29
+
30
+ const pathsWithMultiCnWords = [
31
+ { name: '~', path: '/' },
32
+ { name: 'ext', path: '/ext' },
33
+ { name: '图片', path: '/png' },
34
+ { name: '县城' },
35
+ ];
36
+
37
+ export const Default: Story = {
38
+ args: {
39
+ paths: defaultPaths,
40
+ },
41
+ };
42
+
43
+ export const WithChineseWords: Story = {
44
+ args: {
45
+ paths: pathsWithCn,
46
+ },
47
+ };
48
+
49
+ export const WithMultiChineseWords: Story = {
50
+ args: {
51
+ paths: pathsWithMultiCnWords,
52
+ },
53
+ };
54
+
55
+ export const Loading: Story = {
56
+ args: {
57
+ paths: defaultPaths,
58
+ loading: true,
59
+ },
60
+ };
@@ -0,0 +1,43 @@
1
+ import React from 'react';
2
+ import classNames from 'classnames';
3
+ import { Link } from '../../atoms/link';
4
+ import { BreadcrumbProps } from './Breadcrumb.types';
5
+
6
+ /**
7
+ * Breadcrumb component for displaying navigation hierarchy
8
+ *
9
+ * Uses semantic color tokens and supports loading states.
10
+ * Automatically adjusts vertical offset for non-English characters.
11
+ *
12
+ * @example
13
+ * <Breadcrumb paths={[{ name: '~', path: '/' }, { name: 'ext', path: '/ext' }, { name: 'txt' }]} />
14
+ */
15
+ export const Breadcrumb: React.FC<BreadcrumbProps> = ({ paths, loading }) => {
16
+ const PathElements = paths.map(({ path, name }, index) => {
17
+ // Separator slash between segments
18
+ const slash = index === 0 ? null : <span className="text-content-disabled">/</span>;
19
+
20
+ // Check if name contains non-English characters
21
+ const isNonEnName = !/^[a-zA-Z~]+$/.test(name);
22
+ const offsetCls = classNames({ 'relative top-[2px]': isNonEnName });
23
+
24
+ // Determine if this is the last segment
25
+ const isLast = index === paths.length - 1;
26
+
27
+ // Set status for loading indicator on the last segment
28
+ const status = loading && isLast ? 'blink' : 'hidden';
29
+
30
+ return (
31
+ <span key={path || name}>
32
+ {slash}
33
+ <Link to={path ?? ''} className={offsetCls} readonly={!path} status={status}>
34
+ {name}
35
+ </Link>
36
+ </span>
37
+ );
38
+ });
39
+
40
+ return <div className="breadcrumb">{PathElements}</div>;
41
+ };
42
+
43
+ Breadcrumb.displayName = 'Breadcrumb';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Path object representing a single breadcrumb segment
3
+ */
4
+ export type PathObj = {
5
+ /** Optional path for navigation. If not provided, the segment is displayed as text */
6
+ path?: string;
7
+ /** Display name for the breadcrumb segment */
8
+ name: string;
9
+ };
10
+
11
+ /**
12
+ * Props for the Breadcrumb component
13
+ */
14
+ export type BreadcrumbProps = {
15
+ /** Array of path objects to display as breadcrumbs */
16
+ paths: PathObj[];
17
+ /** Whether the last breadcrumb segment is in loading state */
18
+ loading?: boolean;
19
+ };
@@ -0,0 +1,2 @@
1
+ export { Breadcrumb } from './Breadcrumb';
2
+ export type { PathObj, BreadcrumbProps } from './Breadcrumb.types';
@@ -0,0 +1,84 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import { List, TitledList, LinkList } from './List';
4
+
5
+ const meta: Meta<typeof List> = {
6
+ title: 'Molecules/List',
7
+ component: List,
8
+ tags: ['autodocs'],
9
+ parameters: {
10
+ layout: 'centered',
11
+ },
12
+ decorators: [
13
+ Story => (
14
+ <MemoryRouter>
15
+ <Story />
16
+ </MemoryRouter>
17
+ ),
18
+ ],
19
+ };
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ const sampleItems = [
25
+ { id: 1, name: 'Item 1' },
26
+ { id: 2, name: 'Item 2' },
27
+ { id: 3, name: 'Item 3' },
28
+ ];
29
+
30
+ export const Default: Story = {
31
+ args: {
32
+ items: sampleItems,
33
+ itemRenderer: (item: (typeof sampleItems)[0]) => (
34
+ <span className="text-content-primary">{item.name}</span>
35
+ ),
36
+ },
37
+ };
38
+
39
+ export const Horizontal: Story = {
40
+ args: {
41
+ items: sampleItems,
42
+ horizontal: true,
43
+ itemRenderer: (item: (typeof sampleItems)[0]) => (
44
+ <span className="text-content-primary px-2 py-1">{item.name}</span>
45
+ ),
46
+ },
47
+ };
48
+
49
+ export const WithFooter: Story = {
50
+ args: {
51
+ items: sampleItems,
52
+ footer: <span className="text-content-secondary">Footer content</span>,
53
+ itemRenderer: (item: (typeof sampleItems)[0]) => (
54
+ <span className="text-content-primary">{item.name}</span>
55
+ ),
56
+ },
57
+ };
58
+
59
+ export const Titled: StoryObj<typeof TitledList> = {
60
+ render: () => (
61
+ <TitledList
62
+ title="列表标题"
63
+ description="这是一个带标题的列表"
64
+ items={sampleItems}
65
+ itemRenderer={(item: (typeof sampleItems)[0]) => (
66
+ <span className="text-content-primary">{item.name}</span>
67
+ )}
68
+ />
69
+ ),
70
+ };
71
+
72
+ export const Links: StoryObj<typeof LinkList> = {
73
+ render: () => (
74
+ <LinkList
75
+ title="链接列表"
76
+ description="常用链接"
77
+ links={[
78
+ { to: '/', children: '首页' },
79
+ { to: '/about', children: '关于' },
80
+ { to: 'https://github.com', children: 'GitHub', external: true },
81
+ ]}
82
+ />
83
+ ),
84
+ };
@@ -0,0 +1,79 @@
1
+ import { twMerge } from 'tailwind-merge';
2
+ import { Link, LinkProps } from '../../atoms/link';
3
+ import { Article } from '../article';
4
+ import { ListProps, TitledListProps, LinkListProps } from './List.types';
5
+
6
+ export const List = (props: ListProps) => {
7
+ const { items, itemRenderer, className, horizontal, compact, footer, spaceBetween } = props;
8
+
9
+ const spaceCls = compact ? '' : horizontal ? 'mr-6' : 'mb-8';
10
+
11
+ return (
12
+ <ul
13
+ className={twMerge(
14
+ 'list-none',
15
+ horizontal && 'flex items-center',
16
+ !horizontal && footer && spaceBetween && 'flex flex-col justify-between',
17
+ className
18
+ )}
19
+ >
20
+ {items.map((item, index) => {
21
+ return (
22
+ <li
23
+ key={item.id || index}
24
+ className={twMerge('shrink-0', spaceCls, horizontal && 'my-0')}
25
+ >
26
+ {itemRenderer(item, index)}
27
+ </li>
28
+ );
29
+ })}
30
+ {footer && (
31
+ <li key="footer" className={twMerge('shrink-0', horizontal && 'my-0')}>
32
+ {footer}
33
+ </li>
34
+ )}
35
+ </ul>
36
+ );
37
+ };
38
+
39
+ List.displayName = 'List';
40
+
41
+ export const TitledList = (props: TitledListProps) => {
42
+ const { title, description, ...rest } = props;
43
+
44
+ if (!title) {
45
+ return <List {...rest} />;
46
+ }
47
+
48
+ return (
49
+ <Article title={title} description={description} className="w-fit">
50
+ <List {...rest} />
51
+ </Article>
52
+ );
53
+ };
54
+
55
+ TitledList.displayName = 'TitledList';
56
+
57
+ export const LinkList = (props: LinkListProps) => {
58
+ const { title, description, links, ...rest } = props;
59
+
60
+ const renderLink = ({ children, to, external, ...linkRest }: LinkProps) => {
61
+ return (
62
+ <Link to={to || ''} external={external} {...linkRest}>
63
+ {children}
64
+ </Link>
65
+ );
66
+ };
67
+
68
+ return (
69
+ <TitledList
70
+ title={title}
71
+ description={description}
72
+ items={links}
73
+ itemRenderer={renderLink}
74
+ {...rest}
75
+ />
76
+ );
77
+ };
78
+
79
+ LinkList.displayName = 'LinkList';
@@ -0,0 +1,23 @@
1
+ import { ReactElement, ReactNode } from 'react';
2
+ import { LinkProps } from '../../atoms/link';
3
+
4
+ export interface ListProps {
5
+ items: any[];
6
+ itemRenderer: (itemProps: any, index: number) => ReactElement;
7
+ className?: string;
8
+ compact?: boolean;
9
+ horizontal?: boolean;
10
+ footer?: ReactNode;
11
+ spaceBetween?: boolean;
12
+ }
13
+
14
+ export interface TitledListProps extends ListProps {
15
+ title?: ReactNode;
16
+ description?: ReactNode;
17
+ }
18
+
19
+ export interface LinkListProps extends Omit<ListProps, 'itemRenderer' | 'items'> {
20
+ title?: string;
21
+ description?: ReactNode;
22
+ links: LinkProps[];
23
+ }
@@ -0,0 +1,2 @@
1
+ export { List, TitledList, LinkList } from './List';
2
+ export type { ListProps, TitledListProps, LinkListProps } from './List.types';
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import { Nav } from './Nav';
4
+
5
+ const meta: Meta<typeof Nav> = {
6
+ title: 'Molecules/Nav',
7
+ component: Nav,
8
+ tags: ['autodocs'],
9
+ parameters: {
10
+ layout: 'centered',
11
+ },
12
+ decorators: [
13
+ Story => (
14
+ <MemoryRouter>
15
+ <Story />
16
+ </MemoryRouter>
17
+ ),
18
+ ],
19
+ };
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ export const Default: Story = {
25
+ args: {
26
+ paths: [{ name: '~', path: '/' }, { name: 'ext', path: '/ext' }, { name: 'txt' }],
27
+ },
28
+ };
29
+
30
+ export const Loading: Story = {
31
+ args: {
32
+ paths: [
33
+ { name: '~', path: '/' },
34
+ { name: 'loading', path: '/loading' },
35
+ ],
36
+ loading: true,
37
+ },
38
+ };
39
+
40
+ export const Mini: Story = {
41
+ args: {
42
+ paths: [{ name: 'ext', path: '/ext' }, { name: 'txt' }],
43
+ mini: true,
44
+ },
45
+ };
@@ -0,0 +1,29 @@
1
+ import { twMerge } from 'tailwind-merge';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { Breadcrumb } from '../breadcrumb';
4
+ import { Logo } from '../../atoms/logo';
5
+ import { NavProps } from './Nav.types';
6
+
7
+ export const Nav = (props: NavProps) => {
8
+ const { paths, loading, mini, className, customLogo } = props;
9
+ const nav = useNavigate();
10
+
11
+ if (mini) {
12
+ return (
13
+ <div className={twMerge('p-2 w-full flex items-center', className)}>
14
+ <Breadcrumb paths={paths} />
15
+ </div>
16
+ );
17
+ }
18
+
19
+ return (
20
+ <div className={twMerge('p-2 w-full flex items-center', className)}>
21
+ {customLogo || (
22
+ <Logo className="mr-2 cursor-pointer hover:opacity-80" onClick={() => nav('/')} />
23
+ )}
24
+ <Breadcrumb paths={paths} loading={loading} />
25
+ </div>
26
+ );
27
+ };
28
+
29
+ Nav.displayName = 'Nav';