@studiocubics/components 0.0.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.
- package/CHANGELOG.md +11 -0
- package/README.md +71 -0
- package/eslint.config.js +21 -0
- package/package.json +66 -0
- package/rollup.config.js +34 -0
- package/src/Cards/Card/Card.module.css +27 -0
- package/src/Cards/Card/Card.tsx +105 -0
- package/src/Cards/CollectionItemCard/CollectionItemCard.module.css +84 -0
- package/src/Cards/CollectionItemCard/CollectionItemCard.tsx +170 -0
- package/src/Cards/CollectionItemCard/CollectionItemCardActions.tsx +85 -0
- package/src/Cards/CollectionItemCard/_index.ts +2 -0
- package/src/Cards/GlassCard/GlassCard.module.css +71 -0
- package/src/Cards/GlassCard/GlassCard.tsx +80 -0
- package/src/Cards/_index.ts +3 -0
- package/src/Display/Accordion/Accordion.module.css +69 -0
- package/src/Display/Accordion/Accordion.tsx +61 -0
- package/src/Display/Accordion/AccordionItem.tsx +135 -0
- package/src/Display/Accordion/_index.ts +2 -0
- package/src/Display/Chip/Chip.module.css +64 -0
- package/src/Display/Chip/Chip.tsx +105 -0
- package/src/Display/IdentityDisplay/IdentityDisplay.module.css +95 -0
- package/src/Display/IdentityDisplay/IdentityDisplay.tsx +119 -0
- package/src/Display/InputErrors/InputErrors.module.css +6 -0
- package/src/Display/InputErrors/InputErrors.tsx +52 -0
- package/src/Display/Kbd/Kbd.module.css +29 -0
- package/src/Display/Kbd/Kbd.tsx +39 -0
- package/src/Display/Kbd/_index.ts +2 -0
- package/src/Display/Kbd/buttonList.tsx +246 -0
- package/src/Display/LabeledValue/LabeledValue.module.css +32 -0
- package/src/Display/LabeledValue/LabeledValue.tsx +20 -0
- package/src/Display/List/List.module.css +143 -0
- package/src/Display/List/List.tsx +298 -0
- package/src/Display/PasswordStrength/PasswordStrength.module.css +45 -0
- package/src/Display/PasswordStrength/PasswordStrength.tsx +41 -0
- package/src/Display/PasswordStrength/usePasswordStrength.tsx +77 -0
- package/src/Display/Skeleton/Skeleton.module.css +54 -0
- package/src/Display/Skeleton/Skeleton.tsx +28 -0
- package/src/Display/Toast/Toaster.tsx +58 -0
- package/src/Display/Toast/_index.ts +2 -0
- package/src/Display/Toast/toast.ts +44 -0
- package/src/Display/Tooltip/Tooltip.module.css +128 -0
- package/src/Display/Tooltip/Tooltip.tsx +93 -0
- package/src/Display/Tooltip/getArrowDirection.ts +55 -0
- package/src/Display/Tooltip/useTooltip.tsx +63 -0
- package/src/Display/_index.ts +12 -0
- package/src/Forms/ConfirmationForm/ConfirmationForm.module.css +23 -0
- package/src/Forms/ConfirmationForm/ConfirmationForm.tsx +60 -0
- package/src/Forms/_index.ts +1 -0
- package/src/Inputs/Button/Button.module.css +131 -0
- package/src/Inputs/Button/Button.tsx +178 -0
- package/src/Inputs/Checkbox/Checkbox.module.css +77 -0
- package/src/Inputs/Checkbox/Checkbox.tsx +191 -0
- package/src/Inputs/Checkbox/CheckboxGroup/CheckboxGroup.module.css +10 -0
- package/src/Inputs/Checkbox/CheckboxGroup/CheckboxGroup.tsx +83 -0
- package/src/Inputs/Checkbox/CheckboxSelectAll.tsx +34 -0
- package/src/Inputs/Checkbox/_index.ts +3 -0
- package/src/Inputs/PasswordInput/PasswordInput.module.css +111 -0
- package/src/Inputs/PasswordInput/PasswordInput.tsx +229 -0
- package/src/Inputs/Select/Select.module.css +138 -0
- package/src/Inputs/Select/Select.tsx +136 -0
- package/src/Inputs/Switch/Switch.module.css +119 -0
- package/src/Inputs/Switch/Switch.tsx +195 -0
- package/src/Inputs/TextAreaInput/TextAreaInput.module.css +65 -0
- package/src/Inputs/TextAreaInput/TextAreaInput.tsx +97 -0
- package/src/Inputs/TextInput/TextInput.module.css +112 -0
- package/src/Inputs/TextInput/TextInput.tsx +142 -0
- package/src/Inputs/ThemeToggle/ThemeToggleListItem.tsx +80 -0
- package/src/Inputs/ThemeToggle/_index.ts +1 -0
- package/src/Inputs/_index.ts +8 -0
- package/src/Layout/Dialog/Dialog.module.css +15 -0
- package/src/Layout/Dialog/Dialog.tsx +115 -0
- package/src/Layout/PageLayout/PageLayout.module.css +20 -0
- package/src/Layout/PageLayout/PageLayout.tsx +79 -0
- package/src/Layout/PageLayoutPagination/PageLayoutPagination.module.css +5 -0
- package/src/Layout/PageLayoutPagination/PageLayoutPagination.tsx +40 -0
- package/src/Layout/PageLayoutTabs/PageLayoutTabs.module.css +3 -0
- package/src/Layout/PageLayoutTabs/PageLayoutTabs.tsx +62 -0
- package/src/Layout/Popover/Popover.module.css +9 -0
- package/src/Layout/Popover/Popover.tsx +145 -0
- package/src/Layout/SectionWrapper/SectionWrapper.module.css +31 -0
- package/src/Layout/SectionWrapper/SectionWrapper.tsx +62 -0
- package/src/Layout/Sidebar/Sidebar.module.css +17 -0
- package/src/Layout/Sidebar/Sidebar.tsx +39 -0
- package/src/Layout/Sidebar/SidebarBody/SidebarBody.module.css +31 -0
- package/src/Layout/Sidebar/SidebarBody/SidebarBody.tsx +18 -0
- package/src/Layout/Sidebar/SidebarDrawer/SidebarDrawer.module.css +20 -0
- package/src/Layout/Sidebar/SidebarDrawer/SidebarDrawer.tsx +19 -0
- package/src/Layout/Sidebar/SidebarFooter/SidebarFooter.module.css +35 -0
- package/src/Layout/Sidebar/SidebarFooter/SidebarFooter.tsx +19 -0
- package/src/Layout/Sidebar/SidebarHeader/SidebarHeader.tsx +14 -0
- package/src/Layout/Sidebar/SidebarViewport/SidebarViewport.module.css +12 -0
- package/src/Layout/Sidebar/SidebarViewport/SidebarViewport.tsx +11 -0
- package/src/Layout/Sidebar/_index.ts +6 -0
- package/src/Layout/Table/Table.module.css +46 -0
- package/src/Layout/Table/Table.tsx +222 -0
- package/src/Layout/Table/TableFooter.tsx +4 -0
- package/src/Layout/Table/TableHeader.tsx +4 -0
- package/src/Layout/Table/_index.ts +5 -0
- package/src/Layout/Table/tableUtils.ts +142 -0
- package/src/Layout/Table/types.ts +48 -0
- package/src/Layout/_index.ts +8 -0
- package/src/Misc/Cursor/Cursor.module.css +31 -0
- package/src/Misc/Cursor/Cursor.tsx +77 -0
- package/src/Misc/Logos.tsx +230 -0
- package/src/Misc/PoweredByBanner/PoweredByBanner.module.css +20 -0
- package/src/Misc/PoweredByBanner/PoweredByBanner.tsx +17 -0
- package/src/Misc/Ripple/Ripple.module.css +25 -0
- package/src/Misc/Ripple/Ripple.tsx +126 -0
- package/src/Misc/Spinner/Spinner.module.css +38 -0
- package/src/Misc/Spinner/Spinner.tsx +36 -0
- package/src/Misc/TransitionAnimation/TransitionAnimation.module.css +131 -0
- package/src/Misc/TransitionAnimation/TransitionAnimation.tsx +166 -0
- package/src/Misc/_index.ts +6 -0
- package/src/Navigation/Breadcrumbs/Breadcrumbs.module.css +22 -0
- package/src/Navigation/Breadcrumbs/Breadcrumbs.tsx +127 -0
- package/src/Navigation/Breadcrumbs/BreadcrumbsItem.tsx +31 -0
- package/src/Navigation/Breadcrumbs/_index.ts +3 -0
- package/src/Navigation/Breadcrumbs/useBreadcrumbs.tsx +74 -0
- package/src/Navigation/Pagination/Pagination.module.css +41 -0
- package/src/Navigation/Pagination/Pagination.tsx +187 -0
- package/src/Navigation/Pagination/PaginationItem.tsx +28 -0
- package/src/Navigation/Pagination/_index.ts +3 -0
- package/src/Navigation/Pagination/usePagination.tsx +65 -0
- package/src/Navigation/Tabs/Tab/Tab.module.css +43 -0
- package/src/Navigation/Tabs/Tab/Tab.tsx +155 -0
- package/src/Navigation/Tabs/Tabs.tsx +37 -0
- package/src/Navigation/Tabs/TabsBar/TabsBar.module.css +47 -0
- package/src/Navigation/Tabs/TabsBar/TabsBar.tsx +92 -0
- package/src/Navigation/Tabs/_index.ts +3 -0
- package/src/Navigation/_index.ts +3 -0
- package/src/Typography/ClampedText/ClampedText.module.css +5 -0
- package/src/Typography/ClampedText/ClampedText.tsx +77 -0
- package/src/Typography/CopyableText/CopyableText.module.css +21 -0
- package/src/Typography/CopyableText/CopyableText.tsx +120 -0
- package/src/Typography/PageTitle/PageTitle.module.css +47 -0
- package/src/Typography/PageTitle/PageTitle.tsx +35 -0
- package/src/Typography/_index.ts +3 -0
- package/src/declaration.d.ts +4 -0
- package/src/index.ts +8 -0
- package/tsconfig.json +32 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, type ReactElement, type ReactNode } from "react";
|
|
3
|
+
import styles from "./Pagination.module.css";
|
|
4
|
+
import { usePagination } from "./usePagination";
|
|
5
|
+
import { type PaginationItemProps, PaginationItem } from "./PaginationItem";
|
|
6
|
+
import { cn } from "@studiocubics/utils";
|
|
7
|
+
|
|
8
|
+
export interface PaginationProps {
|
|
9
|
+
/**
|
|
10
|
+
* Total number of pages
|
|
11
|
+
*/
|
|
12
|
+
count: number;
|
|
13
|
+
/**
|
|
14
|
+
* For controlled Pagination pass the state
|
|
15
|
+
*/
|
|
16
|
+
page?: number;
|
|
17
|
+
/**
|
|
18
|
+
* For controlled Pagination pass the onChange function
|
|
19
|
+
*/
|
|
20
|
+
onChange?: (pageNumber: number) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Page that will be selected by default
|
|
23
|
+
* @default 1
|
|
24
|
+
*/
|
|
25
|
+
defaultPage?: number;
|
|
26
|
+
/**
|
|
27
|
+
* How many siblings of the active item should be shown
|
|
28
|
+
* @default 1
|
|
29
|
+
*/
|
|
30
|
+
siblingCount?: number;
|
|
31
|
+
/**
|
|
32
|
+
* How many of the boundary items should be shown
|
|
33
|
+
* @default 1
|
|
34
|
+
*/
|
|
35
|
+
boundaryCount?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Function that can be used to modify the rendered PaginationItem component
|
|
38
|
+
*/
|
|
39
|
+
renderItem?: (
|
|
40
|
+
props: PaginationItemProps,
|
|
41
|
+
key?: string | number
|
|
42
|
+
) => ReactElement;
|
|
43
|
+
/**
|
|
44
|
+
* Shows the skip to first button
|
|
45
|
+
*/
|
|
46
|
+
showFirstButton?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Shows the skip to last button
|
|
49
|
+
*/
|
|
50
|
+
showLastButton?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Icon for first button and the last button icon which will be 180deg
|
|
53
|
+
*/
|
|
54
|
+
firstLastButtonIcon?: ReactNode;
|
|
55
|
+
/**
|
|
56
|
+
* Icon for prev button and the next button icon which will be 180deg
|
|
57
|
+
*/
|
|
58
|
+
prevNextButtonIcon?: ReactNode;
|
|
59
|
+
}
|
|
60
|
+
const arrowLeftIcon = (
|
|
61
|
+
<svg
|
|
62
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
63
|
+
width="24"
|
|
64
|
+
height="24"
|
|
65
|
+
viewBox="0 0 24 24"
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke="currentColor"
|
|
68
|
+
strokeWidth="2"
|
|
69
|
+
strokeLinecap="round"
|
|
70
|
+
strokeLinejoin="round"
|
|
71
|
+
className="lucide lucide-arrow-left-icon lucide-arrow-left"
|
|
72
|
+
>
|
|
73
|
+
<path d="m12 19-7-7 7-7" />
|
|
74
|
+
<path d="M19 12H5" />
|
|
75
|
+
</svg>
|
|
76
|
+
);
|
|
77
|
+
const ArrowLeftToLineIcon = (
|
|
78
|
+
<svg
|
|
79
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
80
|
+
width="24"
|
|
81
|
+
height="24"
|
|
82
|
+
viewBox="0 0 24 24"
|
|
83
|
+
fill="none"
|
|
84
|
+
stroke="currentColor"
|
|
85
|
+
strokeWidth="2"
|
|
86
|
+
strokeLinecap="round"
|
|
87
|
+
strokeLinejoin="round"
|
|
88
|
+
className="lucide lucide-arrow-left-to-line-icon lucide-arrow-left-to-line"
|
|
89
|
+
>
|
|
90
|
+
<path d="M3 19V5" />
|
|
91
|
+
<path d="m13 6-6 6 6 6" />
|
|
92
|
+
<path d="M7 12h14" />
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
export function Pagination(props: PaginationProps) {
|
|
96
|
+
const {
|
|
97
|
+
count,
|
|
98
|
+
page: pageProp,
|
|
99
|
+
onChange,
|
|
100
|
+
defaultPage = 1,
|
|
101
|
+
boundaryCount = 1,
|
|
102
|
+
siblingCount = 1,
|
|
103
|
+
showFirstButton = true,
|
|
104
|
+
showLastButton = true,
|
|
105
|
+
firstLastButtonIcon = ArrowLeftToLineIcon,
|
|
106
|
+
prevNextButtonIcon = arrowLeftIcon,
|
|
107
|
+
renderItem = (props, key?) => <PaginationItem key={key} {...props} />,
|
|
108
|
+
} = props;
|
|
109
|
+
|
|
110
|
+
const [page, setPage] = useState(defaultPage);
|
|
111
|
+
const activePage = pageProp ?? page;
|
|
112
|
+
|
|
113
|
+
const paginationRange = usePagination({
|
|
114
|
+
count,
|
|
115
|
+
page: activePage,
|
|
116
|
+
siblingCount,
|
|
117
|
+
boundaryCount,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function renderIconButton({
|
|
121
|
+
disabled,
|
|
122
|
+
onClick,
|
|
123
|
+
icon,
|
|
124
|
+
}: {
|
|
125
|
+
disabled: boolean;
|
|
126
|
+
onClick: () => void;
|
|
127
|
+
icon: React.ReactNode;
|
|
128
|
+
}) {
|
|
129
|
+
return renderItem({
|
|
130
|
+
className: cn(styles.item, styles.icon, disabled ? styles.disabled : ""),
|
|
131
|
+
onClick: !disabled ? onClick : undefined,
|
|
132
|
+
children: icon,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function handleClick(i: number) {
|
|
137
|
+
if (i < 1 || i > count) return;
|
|
138
|
+
setPage(i);
|
|
139
|
+
if (onChange) onChange(i);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<ul className={styles.root}>
|
|
144
|
+
{showFirstButton &&
|
|
145
|
+
renderIconButton({
|
|
146
|
+
disabled: activePage === 1,
|
|
147
|
+
onClick: () => handleClick(1),
|
|
148
|
+
icon: firstLastButtonIcon,
|
|
149
|
+
})}
|
|
150
|
+
{renderIconButton({
|
|
151
|
+
disabled: activePage === 1,
|
|
152
|
+
onClick: () => handleClick(activePage - 1),
|
|
153
|
+
icon: prevNextButtonIcon,
|
|
154
|
+
})}
|
|
155
|
+
|
|
156
|
+
{paginationRange.map((item, idx) =>
|
|
157
|
+
item === "ellipses" ? (
|
|
158
|
+
<li key={`dots-${idx}`}>…</li>
|
|
159
|
+
) : (
|
|
160
|
+
renderItem(
|
|
161
|
+
{
|
|
162
|
+
className: cn(
|
|
163
|
+
styles.item,
|
|
164
|
+
activePage === item ? styles.activeItem : ""
|
|
165
|
+
),
|
|
166
|
+
onClick: () => handleClick(item),
|
|
167
|
+
children: item,
|
|
168
|
+
},
|
|
169
|
+
item
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
)}
|
|
173
|
+
{renderIconButton({
|
|
174
|
+
disabled: activePage === count,
|
|
175
|
+
onClick: () => handleClick(activePage + 1),
|
|
176
|
+
icon: prevNextButtonIcon,
|
|
177
|
+
})}
|
|
178
|
+
|
|
179
|
+
{showLastButton &&
|
|
180
|
+
renderIconButton({
|
|
181
|
+
disabled: activePage === count,
|
|
182
|
+
onClick: () => handleClick(count),
|
|
183
|
+
icon: firstLastButtonIcon,
|
|
184
|
+
})}
|
|
185
|
+
</ul>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
PolymorphicComponentProps,
|
|
5
|
+
PolymorphicComponentType,
|
|
6
|
+
} from "@studiocubics/types";
|
|
7
|
+
import { type ElementType } from "react";
|
|
8
|
+
|
|
9
|
+
interface PaginationItemBaseProps {
|
|
10
|
+
to?: "string";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PaginationItemProps<C extends ElementType = "li"> =
|
|
14
|
+
PolymorphicComponentProps<C, PaginationItemBaseProps>;
|
|
15
|
+
|
|
16
|
+
const PaginationItemBase = <C extends ElementType = "button">(
|
|
17
|
+
props: PaginationItemProps<C>
|
|
18
|
+
) => {
|
|
19
|
+
const { to, as, children, ...rest } = props;
|
|
20
|
+
const Component = (as || to ? "a" : "li") as ElementType;
|
|
21
|
+
return <Component {...rest}>{children}</Component>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
PaginationItemBase.displayName = "PaginationItem";
|
|
25
|
+
export const PaginationItem = PaginationItemBase as PolymorphicComponentType<
|
|
26
|
+
PaginationItemBaseProps,
|
|
27
|
+
"li"
|
|
28
|
+
>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function usePagination({
|
|
2
|
+
count,
|
|
3
|
+
page,
|
|
4
|
+
siblingCount = 1,
|
|
5
|
+
boundaryCount = 1,
|
|
6
|
+
}: {
|
|
7
|
+
count: number;
|
|
8
|
+
page: number;
|
|
9
|
+
siblingCount?: number;
|
|
10
|
+
boundaryCount?: number;
|
|
11
|
+
}) {
|
|
12
|
+
const startPages = Array.from(
|
|
13
|
+
{ length: Math.min(boundaryCount, count) },
|
|
14
|
+
(_, i) => i + 1
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const endPages = Array.from(
|
|
18
|
+
{ length: Math.min(boundaryCount, count) },
|
|
19
|
+
(_, i) => count - i
|
|
20
|
+
).reverse();
|
|
21
|
+
|
|
22
|
+
// siblingsStart determines the first page number in the sibling range:
|
|
23
|
+
// - Math.min ensures we don't go beyond the range where siblings would overlap with end pages.
|
|
24
|
+
// - Math.max ensures we don't go below the first possible sibling after the boundary pages.
|
|
25
|
+
const siblingsStart = Math.max(
|
|
26
|
+
Math.min(page - siblingCount, count - boundaryCount - siblingCount * 2 - 1),
|
|
27
|
+
boundaryCount + 2
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// The upper bound ensures siblings do not overlap with end pages; if endPages exist, use the first end page minus 2, otherwise use count - 1.
|
|
31
|
+
const siblingsEnd = Math.min(
|
|
32
|
+
Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),
|
|
33
|
+
endPages.length > 0 ? endPages[0] - 2 : count - 1
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const itemList: (number | "ellipses")[] = [];
|
|
37
|
+
|
|
38
|
+
// Start pages
|
|
39
|
+
itemList.push(...startPages);
|
|
40
|
+
|
|
41
|
+
// Ellipses after start pages
|
|
42
|
+
if (siblingsStart > boundaryCount + 2) {
|
|
43
|
+
itemList.push("ellipses");
|
|
44
|
+
} else if (boundaryCount + 1 < count - boundaryCount) {
|
|
45
|
+
itemList.push(boundaryCount + 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Middle pages
|
|
49
|
+
for (let i = siblingsStart; i <= siblingsEnd; i++) {
|
|
50
|
+
itemList.push(i);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ellipses before end pages
|
|
54
|
+
if (siblingsEnd < count - boundaryCount - 1) {
|
|
55
|
+
itemList.push("ellipses");
|
|
56
|
+
} else if (count - boundaryCount > boundaryCount) {
|
|
57
|
+
itemList.push(count - boundaryCount);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// End pages
|
|
61
|
+
const endPagesFiltered = endPages.filter((p) => !itemList.includes(p));
|
|
62
|
+
itemList.push(...endPagesFiltered);
|
|
63
|
+
|
|
64
|
+
return itemList;
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
cursor: pointer;
|
|
3
|
+
position: relative;
|
|
4
|
+
padding: var(--spacing-gap) var(--spacing-gap-2);
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: var(--spacing-gap);
|
|
8
|
+
|
|
9
|
+
font-size: var(--fs-body2);
|
|
10
|
+
font-family: var(--font-p);
|
|
11
|
+
color: var(--color-on-background);
|
|
12
|
+
text-wrap: nowrap;
|
|
13
|
+
|
|
14
|
+
border-radius: var(--shape-br-sm);
|
|
15
|
+
border: none;
|
|
16
|
+
background-color: transparent;
|
|
17
|
+
|
|
18
|
+
transition: background-color var(--transition-time) var(--transition-tf);
|
|
19
|
+
}
|
|
20
|
+
.clickable:not(.selected):hover {
|
|
21
|
+
background-color: var(--color-background-faint);
|
|
22
|
+
color: var(--color-on-surface);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.selected {
|
|
26
|
+
font-weight: bold;
|
|
27
|
+
color: var(--color-primary);
|
|
28
|
+
&:hover {
|
|
29
|
+
background-color: transparent;
|
|
30
|
+
color: var(--color-on-surface);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.disabled {
|
|
35
|
+
color: var(--color-on-background-faint);
|
|
36
|
+
cursor: not-allowed;
|
|
37
|
+
}
|
|
38
|
+
.iconContainer {
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
color: inherit;
|
|
43
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ComponentProps,
|
|
5
|
+
type ElementType,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
type MouseEvent,
|
|
10
|
+
} from "react";
|
|
11
|
+
import styles from "./Tab.module.css";
|
|
12
|
+
import { useTabs } from "../Tabs";
|
|
13
|
+
import { cn, mergeRefs } from "@studiocubics/utils";
|
|
14
|
+
import type {
|
|
15
|
+
PolymorphicComponentProps,
|
|
16
|
+
PolymorphicComponentType,
|
|
17
|
+
} from "@studiocubics/types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Props specific to the Tab component.
|
|
21
|
+
*
|
|
22
|
+
* These extend the intrinsic element props of whatever element is passed via `as`.
|
|
23
|
+
* @group Tab
|
|
24
|
+
* @category inputs
|
|
25
|
+
*/
|
|
26
|
+
export interface TabBaseProps {
|
|
27
|
+
selected?: boolean;
|
|
28
|
+
startIcon?: ReactNode;
|
|
29
|
+
endIcon?: ReactNode;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
href?: ComponentProps<"a">["href"];
|
|
32
|
+
slotProps?: {
|
|
33
|
+
startIcon?: ComponentProps<"span">;
|
|
34
|
+
endIcon?: ComponentProps<"span">;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const defaultElement = "button";
|
|
38
|
+
type DefaultElement = typeof defaultElement;
|
|
39
|
+
/**
|
|
40
|
+
* Polymorphic props for the Tab component.
|
|
41
|
+
*
|
|
42
|
+
* `C` defines the element type rendered by the component (e.g. `"Tab"`, `"a"`, `"div"`).
|
|
43
|
+
* All intrinsic props for `C` are supported unless overridden by `TabBaseProps`.
|
|
44
|
+
*
|
|
45
|
+
* @group Tab
|
|
46
|
+
* @category inputs
|
|
47
|
+
*/
|
|
48
|
+
export type TabProps<C extends ElementType = DefaultElement> =
|
|
49
|
+
PolymorphicComponentProps<C, TabBaseProps>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Base implementation for the Tab component.
|
|
53
|
+
*
|
|
54
|
+
* This is a polymorphic component that defaults to rendering a `<Tab>`.
|
|
55
|
+
* Use the `as` prop to change the underlying element.
|
|
56
|
+
*
|
|
57
|
+
* @typeParam C - The intrinsic or custom element type to render.
|
|
58
|
+
*
|
|
59
|
+
* @group Tab
|
|
60
|
+
* @category inputs
|
|
61
|
+
*/
|
|
62
|
+
function TabBase<C extends ElementType = DefaultElement>(props: TabProps<C>) {
|
|
63
|
+
const {
|
|
64
|
+
as,
|
|
65
|
+
className,
|
|
66
|
+
selected: _selected,
|
|
67
|
+
startIcon,
|
|
68
|
+
endIcon,
|
|
69
|
+
disabled,
|
|
70
|
+
href,
|
|
71
|
+
onClick,
|
|
72
|
+
onTouchStart,
|
|
73
|
+
children,
|
|
74
|
+
slotProps = {},
|
|
75
|
+
ref,
|
|
76
|
+
...restProps
|
|
77
|
+
} = props;
|
|
78
|
+
const { activeTab, setActiveTab } = useTabs();
|
|
79
|
+
const tabRef = useRef<HTMLButtonElement>(null);
|
|
80
|
+
|
|
81
|
+
const clickable = !disabled && (!!href || !!onClick);
|
|
82
|
+
const selected = tabRef.current != null ? activeTab == tabRef.current : false;
|
|
83
|
+
const Component = (as || defaultElement) as ElementType;
|
|
84
|
+
|
|
85
|
+
function handleClick(e: MouseEvent<HTMLButtonElement>) {
|
|
86
|
+
if (disabled) return;
|
|
87
|
+
if (tabRef.current)
|
|
88
|
+
tabRef.current.scrollIntoView({
|
|
89
|
+
block: "nearest",
|
|
90
|
+
inline: "center",
|
|
91
|
+
behavior: "smooth",
|
|
92
|
+
});
|
|
93
|
+
setActiveTab(tabRef.current);
|
|
94
|
+
if (onClick) onClick(e);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (_selected) setActiveTab(tabRef.current);
|
|
99
|
+
}, [_selected]);
|
|
100
|
+
|
|
101
|
+
const componentProps = {
|
|
102
|
+
className: cn(
|
|
103
|
+
className,
|
|
104
|
+
styles.root,
|
|
105
|
+
selected ? styles.selected : undefined,
|
|
106
|
+
clickable ? styles.clickable : undefined,
|
|
107
|
+
disabled ? styles.disabled : undefined,
|
|
108
|
+
),
|
|
109
|
+
onClick: handleClick,
|
|
110
|
+
disabled,
|
|
111
|
+
href: disabled ? "" : href,
|
|
112
|
+
ref: mergeRefs(ref, tabRef),
|
|
113
|
+
...restProps,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Component {...componentProps}>
|
|
118
|
+
{startIcon && (
|
|
119
|
+
<span
|
|
120
|
+
{...slotProps.startIcon}
|
|
121
|
+
className={cn(styles.iconContainer, slotProps.startIcon?.className)}
|
|
122
|
+
>
|
|
123
|
+
{startIcon}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
{children}
|
|
127
|
+
{endIcon && (
|
|
128
|
+
<span
|
|
129
|
+
{...slotProps.endIcon}
|
|
130
|
+
className={cn(styles.iconContainer, slotProps.endIcon?.className)}
|
|
131
|
+
>
|
|
132
|
+
{endIcon}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</Component>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
TabBase.displayName = "Tab";
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A polymorphic Tab component.
|
|
142
|
+
*
|
|
143
|
+
* By default it renders a `<Tab>`, but any element can be used via the `as` prop:
|
|
144
|
+
*
|
|
145
|
+
* ```tsx
|
|
146
|
+
* <Tab as="a" href="/docs">Read docs</Tab>
|
|
147
|
+
* ```
|
|
148
|
+
*
|
|
149
|
+
* @group Tab
|
|
150
|
+
* @category inputs
|
|
151
|
+
*/
|
|
152
|
+
export const Tab = TabBase as PolymorphicComponentType<
|
|
153
|
+
TabBaseProps,
|
|
154
|
+
DefaultElement
|
|
155
|
+
>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { SetState } from "@studiocubics/types";
|
|
4
|
+
import { createContext, type ReactNode, useContext, useState } from "react";
|
|
5
|
+
|
|
6
|
+
interface TabsContextProps {
|
|
7
|
+
activeTab: HTMLButtonElement | null;
|
|
8
|
+
setActiveTab: SetState<HTMLButtonElement | null>;
|
|
9
|
+
}
|
|
10
|
+
export interface TabsProps {
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TabsContext = createContext<TabsContextProps | null>(null);
|
|
15
|
+
|
|
16
|
+
export function useTabs() {
|
|
17
|
+
const c = useContext(TabsContext);
|
|
18
|
+
if (!c) throw new Error("Components must be wrapped in <Tabs/>");
|
|
19
|
+
return c;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Tabs(props: TabsProps) {
|
|
23
|
+
const { children } = props;
|
|
24
|
+
const [activeTab, setActiveTab] =
|
|
25
|
+
useState<TabsContextProps["activeTab"]>(null);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<TabsContext.Provider
|
|
29
|
+
value={{
|
|
30
|
+
activeTab,
|
|
31
|
+
setActiveTab,
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</TabsContext.Provider>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
overflow: hidden;
|
|
3
|
+
display: flex;
|
|
4
|
+
background: var(--color-background-alpha);
|
|
5
|
+
border-radius: var(--shape-br-sm);
|
|
6
|
+
backdrop-filter: blur(5px);
|
|
7
|
+
pointer-events: all;
|
|
8
|
+
}
|
|
9
|
+
.overflowContainer {
|
|
10
|
+
isolation: isolate;
|
|
11
|
+
display: flex;
|
|
12
|
+
gap: var(--spacing-gap);
|
|
13
|
+
position: relative;
|
|
14
|
+
}
|
|
15
|
+
.rowContainer {
|
|
16
|
+
width: max-content;
|
|
17
|
+
justify-content: flex-start;
|
|
18
|
+
padding: var(--spacing-gap) var(--spacing-gap-2);
|
|
19
|
+
overflow: auto hidden;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.columnContainer {
|
|
23
|
+
height: 100%;
|
|
24
|
+
width: 100%;
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
padding: var(--spacing-gap-2);
|
|
27
|
+
overflow: hidden auto;
|
|
28
|
+
gap: var(--spacing-gap);
|
|
29
|
+
}
|
|
30
|
+
.marker {
|
|
31
|
+
position: absolute;
|
|
32
|
+
left: 0;
|
|
33
|
+
width: 0;
|
|
34
|
+
z-index: -1;
|
|
35
|
+
transition: all var(--transition-time) var(--transition-tf);
|
|
36
|
+
}
|
|
37
|
+
.markerGlass {
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: 100%;
|
|
40
|
+
--glass-border-radius: var(--shape-br-sm);
|
|
41
|
+
--glass-backdrop-blur: 0;
|
|
42
|
+
/* background-color: color-mix(
|
|
43
|
+
in srgb,
|
|
44
|
+
var(--color-background) 50%,
|
|
45
|
+
transparent
|
|
46
|
+
); */
|
|
47
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, type ReactNode, type ComponentProps } from "react";
|
|
4
|
+
import { useTabs } from "../Tabs";
|
|
5
|
+
import styles from "./TabsBar.module.css";
|
|
6
|
+
import { cn } from "@studiocubics/utils";
|
|
7
|
+
import {
|
|
8
|
+
GlassCard,
|
|
9
|
+
type GlassCardProps,
|
|
10
|
+
} from "../../../Cards/GlassCard/GlassCard";
|
|
11
|
+
|
|
12
|
+
interface TabsBarProps extends ComponentProps<"div"> {
|
|
13
|
+
column?: boolean;
|
|
14
|
+
slotProps?: {
|
|
15
|
+
overflowContainer?: ComponentProps<"div">;
|
|
16
|
+
marker?: ComponentProps<"span">;
|
|
17
|
+
markerGlass?: GlassCardProps;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function TabsBar(props: TabsBarProps) {
|
|
22
|
+
const {
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
column = false,
|
|
26
|
+
slotProps = {},
|
|
27
|
+
...rest
|
|
28
|
+
} = props;
|
|
29
|
+
|
|
30
|
+
const { activeTab } = useTabs();
|
|
31
|
+
const markerRef = useRef<HTMLSpanElement>(null);
|
|
32
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!rootRef.current || !markerRef.current || !activeTab) return;
|
|
36
|
+
|
|
37
|
+
const updateMarkerPosition = () => {
|
|
38
|
+
if (!rootRef.current || !markerRef.current || !activeTab) return;
|
|
39
|
+
|
|
40
|
+
const marker = markerRef.current;
|
|
41
|
+
const tabRect = activeTab.getBoundingClientRect();
|
|
42
|
+
const rootRect = rootRef.current.getBoundingClientRect();
|
|
43
|
+
|
|
44
|
+
// Account for scroll offset
|
|
45
|
+
const scrollLeft = rootRef.current.scrollLeft;
|
|
46
|
+
const scrollTop = rootRef.current.scrollTop;
|
|
47
|
+
|
|
48
|
+
marker.style.width = `${tabRect.width}px`;
|
|
49
|
+
marker.style.height = `${tabRect.height}px`;
|
|
50
|
+
marker.style.left = `${tabRect.left - rootRect.left + scrollLeft}px`;
|
|
51
|
+
marker.style.top = `${tabRect.top - rootRect.top + scrollTop}px`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
updateMarkerPosition();
|
|
55
|
+
|
|
56
|
+
const container = rootRef.current;
|
|
57
|
+
|
|
58
|
+
// Update marker position when container resizes
|
|
59
|
+
const resizeObserver = new ResizeObserver(updateMarkerPosition);
|
|
60
|
+
resizeObserver.observe(container);
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
resizeObserver.disconnect();
|
|
64
|
+
};
|
|
65
|
+
}, [activeTab]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<nav {...rest} className={cn(styles.root, className)}>
|
|
69
|
+
<div
|
|
70
|
+
{...slotProps.overflowContainer}
|
|
71
|
+
ref={rootRef}
|
|
72
|
+
className={cn(
|
|
73
|
+
styles.overflowContainer,
|
|
74
|
+
column ? styles.columnContainer : styles.rowContainer,
|
|
75
|
+
slotProps.overflowContainer?.className,
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{children as ReactNode}
|
|
79
|
+
<span
|
|
80
|
+
{...slotProps.marker}
|
|
81
|
+
className={cn(styles.marker, slotProps.marker?.className)}
|
|
82
|
+
ref={markerRef}
|
|
83
|
+
>
|
|
84
|
+
<GlassCard
|
|
85
|
+
{...slotProps.markerGlass}
|
|
86
|
+
className={cn(styles.markerGlass, slotProps.markerGlass?.className)}
|
|
87
|
+
/>
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
</nav>
|
|
91
|
+
);
|
|
92
|
+
}
|