@smicolon/fasla-ui 0.2.4 → 0.3.0
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/README.md +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +91 -23
- package/dist/registry/index.js +31 -2
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +4 -1
- package/registry/blocks/app-shell/AppShell.tsx +1 -1
- package/registry/blocks/data-table/DataTable.test.tsx +61 -0
- package/registry/blocks/data-table/DataTable.tsx +53 -16
- package/registry/blocks/data-table/index.ts +1 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
- package/registry/blocks/empty-state/EmptyState.tsx +22 -31
- package/registry/blocks/form-section/FormSection.tsx +1 -1
- package/registry/blocks/navbar/Navbar.test.tsx +12 -0
- package/registry/blocks/navbar/Navbar.tsx +8 -23
- package/registry/blocks/page-header/PageHeader.tsx +5 -2
- package/registry/blocks/sidebar/Sidebar.tsx +9 -16
- package/registry/blocks/stats-card/StatsCard.tsx +9 -27
- package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
- package/registry/effects/border-beam/border-beam.tsx +77 -53
- package/registry/effects/effects.test.tsx +266 -0
- package/registry/effects/glow-card/glow-card.tsx +68 -43
- package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
- package/registry/effects/spotlight/spotlight.tsx +73 -29
- package/registry/effects/text-reveal/index.ts +1 -0
- package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
- package/registry/effects/text-reveal/text-reveal.tsx +68 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
- package/registry/text-props-undefined.test.tsx +73 -0
- package/registry/ui/avatar/avatar.test.tsx +316 -0
- package/registry/ui/avatar/avatar.tsx +235 -108
- package/registry/ui/avatar/index.ts +1 -1
- package/registry/ui/badge/badge.test.tsx +267 -32
- package/registry/ui/badge/badge.tsx +233 -21
- package/registry/ui/button/button.test.tsx +6 -0
- package/registry/ui/button/button.tsx +6 -23
- package/registry/ui/card/card.tsx +1 -1
- package/registry/ui/checkbox/checkbox.tsx +13 -26
- package/registry/ui/combobox/combobox.test.tsx +43 -0
- package/registry/ui/combobox/combobox.tsx +27 -55
- package/registry/ui/input/input.test.tsx +2 -2
- package/registry/ui/input/input.tsx +4 -4
- package/registry/ui/radio/index.ts +1 -0
- package/registry/ui/radio/radio.test.tsx +248 -0
- package/registry/ui/radio/radio.tsx +208 -0
- package/registry/ui/select/select.tsx +4 -14
- package/registry/ui/skeleton/skeleton.tsx +1 -1
- package/registry/ui/status-indicator/index.ts +6 -0
- package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
- package/registry/ui/status-indicator/status-indicator.tsx +87 -0
- package/registry/ui/switch/index.ts +1 -1
- package/registry/ui/switch/switch.test.tsx +323 -0
- package/registry/ui/switch/switch.tsx +254 -57
- package/registry/ui/textarea/textarea.tsx +1 -1
- package/registry.json +78 -15
- package/tailwind-preset.ts +143 -0
|
@@ -13,8 +13,8 @@ export interface Column<T> {
|
|
|
13
13
|
sortable?: boolean
|
|
14
14
|
/** Column width class */
|
|
15
15
|
width?: string
|
|
16
|
-
/** Alignment */
|
|
17
|
-
align?: "
|
|
16
|
+
/** Alignment. `start` and `end` follow the reading direction; `left` and `right` are physical sides. Defaults to `start`. */
|
|
17
|
+
align?: "start" | "center" | "end" | "left" | "right"
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface DataTableProps<T> extends React.HTMLAttributes<HTMLDivElement> {
|
|
@@ -37,8 +37,10 @@ export interface DataTableProps<T> extends React.HTMLAttributes<HTMLDivElement>
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const alignClasses = {
|
|
40
|
-
|
|
40
|
+
start: "text-start",
|
|
41
41
|
center: "text-center",
|
|
42
|
+
end: "text-end",
|
|
43
|
+
left: "text-left",
|
|
42
44
|
right: "text-right",
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -70,7 +72,7 @@ export function DataTable<T>({
|
|
|
70
72
|
key={column.id}
|
|
71
73
|
className={cn(
|
|
72
74
|
"h-10 px-4 font-medium text-muted-foreground",
|
|
73
|
-
alignClasses[column.align || "
|
|
75
|
+
alignClasses[column.align || "start"],
|
|
74
76
|
column.width
|
|
75
77
|
)}
|
|
76
78
|
>
|
|
@@ -108,7 +110,7 @@ export function DataTable<T>({
|
|
|
108
110
|
key={column.id}
|
|
109
111
|
className={cn(
|
|
110
112
|
"h-10 px-4 font-medium text-muted-foreground",
|
|
111
|
-
alignClasses[column.align || "
|
|
113
|
+
alignClasses[column.align || "start"],
|
|
112
114
|
column.width
|
|
113
115
|
)}
|
|
114
116
|
>
|
|
@@ -141,7 +143,7 @@ export function DataTable<T>({
|
|
|
141
143
|
key={column.id}
|
|
142
144
|
className={cn(
|
|
143
145
|
"h-10 px-4 font-medium text-muted-foreground",
|
|
144
|
-
alignClasses[column.align || "
|
|
146
|
+
alignClasses[column.align || "start"],
|
|
145
147
|
column.width
|
|
146
148
|
)}
|
|
147
149
|
>
|
|
@@ -170,7 +172,7 @@ export function DataTable<T>({
|
|
|
170
172
|
key={column.id}
|
|
171
173
|
className={cn(
|
|
172
174
|
"p-4",
|
|
173
|
-
alignClasses[column.align || "
|
|
175
|
+
alignClasses[column.align || "start"],
|
|
174
176
|
column.width
|
|
175
177
|
)}
|
|
176
178
|
>
|
|
@@ -197,6 +199,38 @@ export interface PaginationProps {
|
|
|
197
199
|
/** Show page numbers */
|
|
198
200
|
showPageNumbers?: boolean
|
|
199
201
|
className?: string
|
|
202
|
+
/** Text and accessible names; each defaults to English */
|
|
203
|
+
labels?: Partial<PaginationLabels>
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface PaginationLabels {
|
|
207
|
+
/** Accessible name of the pagination landmark */
|
|
208
|
+
pagination: string
|
|
209
|
+
/** Visible status, given the page and the total */
|
|
210
|
+
status: (page: number, totalPages: number) => string
|
|
211
|
+
previous: string
|
|
212
|
+
next: string
|
|
213
|
+
goToPrevious: string
|
|
214
|
+
goToNext: string
|
|
215
|
+
goToPage: (page: number) => string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The entries of a partial object whose values are not undefined. */
|
|
219
|
+
function definedOnly<T extends object>(value: Partial<T> | undefined): Partial<T> {
|
|
220
|
+
if (!value) return {}
|
|
221
|
+
return Object.fromEntries(
|
|
222
|
+
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
|
223
|
+
) as Partial<T>
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const DEFAULT_PAGINATION_LABELS: PaginationLabels = {
|
|
227
|
+
pagination: "Pagination",
|
|
228
|
+
status: (page, totalPages) => `Page ${page} of ${totalPages}`,
|
|
229
|
+
previous: "Previous",
|
|
230
|
+
next: "Next",
|
|
231
|
+
goToPrevious: "Go to previous page",
|
|
232
|
+
goToNext: "Go to next page",
|
|
233
|
+
goToPage: (page) => `Go to page ${page}`,
|
|
200
234
|
}
|
|
201
235
|
|
|
202
236
|
/**
|
|
@@ -208,7 +242,12 @@ export function Pagination({
|
|
|
208
242
|
onPageChange,
|
|
209
243
|
showPageNumbers = true,
|
|
210
244
|
className,
|
|
245
|
+
labels,
|
|
211
246
|
}: PaginationProps) {
|
|
247
|
+
// A label passed as undefined (a missed translation key, say) keeps its
|
|
248
|
+
// English default instead of replacing it: a plain spread would leave
|
|
249
|
+
// status or goToPage undefined, and calling them would crash the render.
|
|
250
|
+
const text = { ...DEFAULT_PAGINATION_LABELS, ...definedOnly(labels) }
|
|
212
251
|
const canGoPrevious = page > 1
|
|
213
252
|
const canGoNext = page < totalPages
|
|
214
253
|
|
|
@@ -241,19 +280,17 @@ export function Pagination({
|
|
|
241
280
|
return (
|
|
242
281
|
<nav
|
|
243
282
|
className={cn("flex items-center justify-between px-2 py-3", className)}
|
|
244
|
-
aria-label=
|
|
283
|
+
aria-label={text.pagination}
|
|
245
284
|
>
|
|
246
|
-
<p className="text-sm text-muted-foreground">
|
|
247
|
-
Page {page} of {totalPages}
|
|
248
|
-
</p>
|
|
285
|
+
<p className="text-sm text-muted-foreground">{text.status(page, totalPages)}</p>
|
|
249
286
|
<div className="flex items-center gap-1">
|
|
250
287
|
<button
|
|
251
288
|
onClick={() => onPageChange(page - 1)}
|
|
252
289
|
disabled={!canGoPrevious}
|
|
253
290
|
className="inline-flex h-8 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
254
|
-
aria-label=
|
|
291
|
+
aria-label={text.goToPrevious}
|
|
255
292
|
>
|
|
256
|
-
|
|
293
|
+
{text.previous}
|
|
257
294
|
</button>
|
|
258
295
|
{showPageNumbers &&
|
|
259
296
|
getPageNumbers().map((pageNum, i) =>
|
|
@@ -271,7 +308,7 @@ export function Pagination({
|
|
|
271
308
|
? "bg-primary text-primary-foreground"
|
|
272
309
|
: "hover:bg-accent hover:text-accent-foreground"
|
|
273
310
|
)}
|
|
274
|
-
aria-label={
|
|
311
|
+
aria-label={text.goToPage(pageNum)}
|
|
275
312
|
aria-current={pageNum === page ? "page" : undefined}
|
|
276
313
|
>
|
|
277
314
|
{pageNum}
|
|
@@ -282,9 +319,9 @@ export function Pagination({
|
|
|
282
319
|
onClick={() => onPageChange(page + 1)}
|
|
283
320
|
disabled={!canGoNext}
|
|
284
321
|
className="inline-flex h-8 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
285
|
-
aria-label=
|
|
322
|
+
aria-label={text.goToNext}
|
|
286
323
|
>
|
|
287
|
-
|
|
324
|
+
{text.next}
|
|
288
325
|
</button>
|
|
289
326
|
</div>
|
|
290
327
|
</nav>
|
|
@@ -118,3 +118,15 @@ describe("EmptyData", () => {
|
|
|
118
118
|
expect(svg).toBeInTheDocument()
|
|
119
119
|
})
|
|
120
120
|
})
|
|
121
|
+
|
|
122
|
+
describe("EmptyState presets", () => {
|
|
123
|
+
it("take a title and description in place of their English", () => {
|
|
124
|
+
render(<EmptySearchResults title="لا توجد نتائج" description="جرّب كلمة بحث أخرى." />)
|
|
125
|
+
expect(screen.getByText("لا توجد نتائج")).toBeInTheDocument()
|
|
126
|
+
expect(screen.getByText("جرّب كلمة بحث أخرى.")).toBeInTheDocument()
|
|
127
|
+
expect(screen.queryByText("No results found")).not.toBeInTheDocument()
|
|
128
|
+
|
|
129
|
+
render(<EmptyData title="لا توجد طلبات بعد" description="أنشئ طلبك الأول للبدء." />)
|
|
130
|
+
expect(screen.getByText("لا توجد طلبات بعد")).toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
|
+
import { FilePlusIcon, SearchIcon } from "lucide-react"
|
|
2
3
|
import { cn } from "../../../src/lib/utils"
|
|
3
4
|
|
|
4
5
|
export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
@@ -99,33 +100,29 @@ export function EmptyState({
|
|
|
99
100
|
export function EmptySearchResults({
|
|
100
101
|
query,
|
|
101
102
|
onClear,
|
|
103
|
+
title,
|
|
104
|
+
description,
|
|
102
105
|
...props
|
|
103
106
|
}: Omit<EmptyStateProps, "title" | "description"> & {
|
|
107
|
+
/** Replaces the English title */
|
|
108
|
+
title?: string
|
|
109
|
+
/** Replaces the English description */
|
|
110
|
+
description?: string
|
|
104
111
|
query?: string
|
|
105
112
|
onClear?: () => void
|
|
106
113
|
}) {
|
|
107
114
|
return (
|
|
108
115
|
<EmptyState
|
|
109
|
-
title
|
|
116
|
+
// ?? rather than a later spread, so an undefined title keeps the English one
|
|
117
|
+
title={title ?? "No results found"}
|
|
110
118
|
description={
|
|
111
|
-
|
|
119
|
+
description ??
|
|
120
|
+
(query
|
|
112
121
|
? `No results for "${query}". Try a different search term.`
|
|
113
|
-
: "Try adjusting your search or filters."
|
|
122
|
+
: "Try adjusting your search or filters.")
|
|
114
123
|
}
|
|
115
124
|
icon={
|
|
116
|
-
<
|
|
117
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
118
|
-
fill="none"
|
|
119
|
-
viewBox="0 0 24 24"
|
|
120
|
-
strokeWidth={1.5}
|
|
121
|
-
stroke="currentColor"
|
|
122
|
-
>
|
|
123
|
-
<path
|
|
124
|
-
strokeLinecap="round"
|
|
125
|
-
strokeLinejoin="round"
|
|
126
|
-
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
|
127
|
-
/>
|
|
128
|
-
</svg>
|
|
125
|
+
<SearchIcon strokeWidth={1.5} />
|
|
129
126
|
}
|
|
130
127
|
{...props}
|
|
131
128
|
/>
|
|
@@ -135,28 +132,22 @@ export function EmptySearchResults({
|
|
|
135
132
|
/** Pre-built empty state for no data */
|
|
136
133
|
export function EmptyData({
|
|
137
134
|
resourceName = "items",
|
|
135
|
+
title,
|
|
136
|
+
description,
|
|
138
137
|
...props
|
|
139
138
|
}: Omit<EmptyStateProps, "title" | "description"> & {
|
|
139
|
+
/** Replaces the English title */
|
|
140
|
+
title?: string
|
|
141
|
+
/** Replaces the English description */
|
|
142
|
+
description?: string
|
|
140
143
|
resourceName?: string
|
|
141
144
|
}) {
|
|
142
145
|
return (
|
|
143
146
|
<EmptyState
|
|
144
|
-
title={`No ${resourceName} yet`}
|
|
145
|
-
description={`Get started by creating your first ${resourceName.replace(/s$/, "")}.`}
|
|
147
|
+
title={title ?? `No ${resourceName} yet`}
|
|
148
|
+
description={description ?? `Get started by creating your first ${resourceName.replace(/s$/, "")}.`}
|
|
146
149
|
icon={
|
|
147
|
-
<
|
|
148
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
149
|
-
fill="none"
|
|
150
|
-
viewBox="0 0 24 24"
|
|
151
|
-
strokeWidth={1.5}
|
|
152
|
-
stroke="currentColor"
|
|
153
|
-
>
|
|
154
|
-
<path
|
|
155
|
-
strokeLinecap="round"
|
|
156
|
-
strokeLinejoin="round"
|
|
157
|
-
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
|
158
|
-
/>
|
|
159
|
-
</svg>
|
|
150
|
+
<FilePlusIcon strokeWidth={1.5} />
|
|
160
151
|
}
|
|
161
152
|
{...props}
|
|
162
153
|
/>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import { NavbarToggle } from "./Navbar"
|
|
4
|
+
|
|
5
|
+
describe("NavbarToggle", () => {
|
|
6
|
+
it("tells assistive tech whether the menu is showing", () => {
|
|
7
|
+
const { rerender } = render(<NavbarToggle />)
|
|
8
|
+
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("false")
|
|
9
|
+
rerender(<NavbarToggle open />)
|
|
10
|
+
expect(screen.getByRole("button", { name: "Close menu" }).getAttribute("aria-expanded")).toBe("true")
|
|
11
|
+
})
|
|
12
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
|
+
import { MenuIcon, XIcon } from "lucide-react"
|
|
2
3
|
import { cn } from "../../../src/lib/utils"
|
|
3
4
|
|
|
4
5
|
export interface NavbarProps extends React.HTMLAttributes<HTMLElement> {
|
|
@@ -160,31 +161,15 @@ export function NavbarToggle({
|
|
|
160
161
|
className
|
|
161
162
|
)}
|
|
162
163
|
aria-label={open ? "Close menu" : "Open menu"}
|
|
164
|
+
// Screen readers announce whether the menu it controls is showing.
|
|
165
|
+
aria-expanded={open ?? false}
|
|
163
166
|
{...props}
|
|
164
167
|
>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
stroke="currentColor"
|
|
171
|
-
strokeWidth="2"
|
|
172
|
-
strokeLinecap="round"
|
|
173
|
-
strokeLinejoin="round"
|
|
174
|
-
>
|
|
175
|
-
{open ? (
|
|
176
|
-
<>
|
|
177
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
178
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
179
|
-
</>
|
|
180
|
-
) : (
|
|
181
|
-
<>
|
|
182
|
-
<line x1="4" y1="12" x2="20" y2="12" />
|
|
183
|
-
<line x1="4" y1="6" x2="20" y2="6" />
|
|
184
|
-
<line x1="4" y1="18" x2="20" y2="18" />
|
|
185
|
-
</>
|
|
186
|
-
)}
|
|
187
|
-
</svg>
|
|
168
|
+
{open ? (
|
|
169
|
+
<XIcon size={20} strokeWidth={1.5} />
|
|
170
|
+
) : (
|
|
171
|
+
<MenuIcon size={20} strokeWidth={1.5} />
|
|
172
|
+
)}
|
|
188
173
|
</button>
|
|
189
174
|
)
|
|
190
175
|
}
|
|
@@ -14,6 +14,8 @@ export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
14
14
|
bordered?: boolean
|
|
15
15
|
/** Semantic heading level for the title */
|
|
16
16
|
headingLevel?: 1 | 2 | 3
|
|
17
|
+
/** Accessible name of the breadcrumb landmark */
|
|
18
|
+
breadcrumbLabel?: string
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -27,6 +29,7 @@ export function PageHeader({
|
|
|
27
29
|
breadcrumb,
|
|
28
30
|
bordered = true,
|
|
29
31
|
headingLevel = 1,
|
|
32
|
+
breadcrumbLabel = "Breadcrumb",
|
|
30
33
|
className,
|
|
31
34
|
...props
|
|
32
35
|
}: PageHeaderProps) {
|
|
@@ -44,11 +47,11 @@ export function PageHeader({
|
|
|
44
47
|
<div className="flex items-start justify-between gap-4">
|
|
45
48
|
<div className="min-w-0 flex-1">
|
|
46
49
|
{breadcrumb && (
|
|
47
|
-
<nav className="mb-2 text-sm text-muted-foreground" aria-label=
|
|
50
|
+
<nav className="mb-2 text-sm text-muted-foreground" aria-label={breadcrumbLabel}>
|
|
48
51
|
{breadcrumb}
|
|
49
52
|
</nav>
|
|
50
53
|
)}
|
|
51
|
-
<Heading className="truncate text-xl font-semibold
|
|
54
|
+
<Heading className="truncate text-xl font-semibold md:text-2xl">
|
|
52
55
|
{title}
|
|
53
56
|
</Heading>
|
|
54
57
|
{description && (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
+
import { ChevronsLeftIcon } from "lucide-react"
|
|
4
5
|
import { cn } from "../../../src/lib/utils"
|
|
5
6
|
|
|
6
7
|
export interface SidebarProps extends React.HTMLAttributes<HTMLElement> {
|
|
@@ -30,7 +31,7 @@ export function Sidebar({
|
|
|
30
31
|
return (
|
|
31
32
|
<aside
|
|
32
33
|
className={cn(
|
|
33
|
-
"flex h-full flex-col border-
|
|
34
|
+
"flex h-full flex-col border-e bg-background transition-all duration-200",
|
|
34
35
|
collapsed ? "w-16" : widthClasses[width],
|
|
35
36
|
className
|
|
36
37
|
)}
|
|
@@ -116,7 +117,7 @@ export function SidebarGroup({
|
|
|
116
117
|
return (
|
|
117
118
|
<div className={cn("py-2", className)} {...props}>
|
|
118
119
|
{label && (
|
|
119
|
-
<div className="mb-2 px-2 text-xs font-semibold uppercase
|
|
120
|
+
<div className="mb-2 px-2 text-xs font-semibold uppercase text-muted-foreground">
|
|
120
121
|
{label}
|
|
121
122
|
</div>
|
|
122
123
|
)}
|
|
@@ -183,20 +184,12 @@ export function SidebarCollapseButton({
|
|
|
183
184
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
184
185
|
{...props}
|
|
185
186
|
>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
strokeWidth="2"
|
|
193
|
-
strokeLinecap="round"
|
|
194
|
-
strokeLinejoin="round"
|
|
195
|
-
className={cn("transition-transform", collapsed && "rotate-180")}
|
|
196
|
-
>
|
|
197
|
-
<polyline points="11 17 6 12 11 7" />
|
|
198
|
-
<polyline points="18 17 13 12 18 7" />
|
|
199
|
-
</svg>
|
|
187
|
+
{/* Points at the edge the sidebar collapses toward, so it mirrors in RTL. */}
|
|
188
|
+
<ChevronsLeftIcon
|
|
189
|
+
size={16}
|
|
190
|
+
strokeWidth={1.5}
|
|
191
|
+
className={cn("transition-transform motion-reduce:transition-none rtl:-scale-x-100", collapsed && "rotate-180")}
|
|
192
|
+
/>
|
|
200
193
|
</button>
|
|
201
194
|
)
|
|
202
195
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
|
+
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
2
3
|
import { cn } from "../../../src/lib/utils"
|
|
3
4
|
|
|
4
5
|
export interface StatsCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
@@ -43,7 +44,7 @@ export function StatsCard({
|
|
|
43
44
|
{loading ? (
|
|
44
45
|
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
|
45
46
|
) : (
|
|
46
|
-
<p className="text-2xl font-bold
|
|
47
|
+
<p className="text-2xl font-bold">{value}</p>
|
|
47
48
|
)}
|
|
48
49
|
</div>
|
|
49
50
|
{icon && (
|
|
@@ -65,35 +66,16 @@ export function StatsCard({
|
|
|
65
66
|
)}
|
|
66
67
|
>
|
|
67
68
|
{trend.direction === "up" && (
|
|
68
|
-
<
|
|
69
|
-
width="16"
|
|
70
|
-
height="16"
|
|
71
|
-
viewBox="0 0 24 24"
|
|
72
|
-
fill="none"
|
|
73
|
-
stroke="currentColor"
|
|
74
|
-
strokeWidth="2"
|
|
75
|
-
strokeLinecap="round"
|
|
76
|
-
strokeLinejoin="round"
|
|
77
|
-
>
|
|
78
|
-
<polyline points="18 15 12 9 6 15" />
|
|
79
|
-
</svg>
|
|
69
|
+
<ChevronUpIcon size={16} strokeWidth={1.5} />
|
|
80
70
|
)}
|
|
81
71
|
{trend.direction === "down" && (
|
|
82
|
-
<
|
|
83
|
-
width="16"
|
|
84
|
-
height="16"
|
|
85
|
-
viewBox="0 0 24 24"
|
|
86
|
-
fill="none"
|
|
87
|
-
stroke="currentColor"
|
|
88
|
-
strokeWidth="2"
|
|
89
|
-
strokeLinecap="round"
|
|
90
|
-
strokeLinejoin="round"
|
|
91
|
-
>
|
|
92
|
-
<polyline points="6 9 12 15 18 9" />
|
|
93
|
-
</svg>
|
|
72
|
+
<ChevronDownIcon size={16} strokeWidth={1.5} />
|
|
94
73
|
)}
|
|
95
|
-
{
|
|
96
|
-
|
|
74
|
+
{/* One LTR unit: in RTL the sign would otherwise jump to the far end, "12.5%+". */}
|
|
75
|
+
<bdi dir="ltr">
|
|
76
|
+
{trend.value > 0 ? "+" : ""}
|
|
77
|
+
{trend.value}%
|
|
78
|
+
</bdi>
|
|
97
79
|
</span>
|
|
98
80
|
)}
|
|
99
81
|
{description && (
|
|
@@ -4,6 +4,24 @@ import * as React from "react"
|
|
|
4
4
|
import { motion, useReducedMotion } from "framer-motion"
|
|
5
5
|
import { cn } from "../../../src/lib/utils"
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The colours, without blank entries (a cleared control, say); the fallback
|
|
9
|
+
* when none are left, since an empty list would draw nothing.
|
|
10
|
+
*/
|
|
11
|
+
const filledList = (colors: string[] | undefined, fallback: string[]) => {
|
|
12
|
+
const usable = (colors ?? []).filter((color) => typeof color === "string" && color.trim())
|
|
13
|
+
return usable.length ? usable : fallback
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const GRADIENT_COLORS = [
|
|
17
|
+
"var(--primary)",
|
|
18
|
+
"var(--secondary)",
|
|
19
|
+
"hsl(280 100% 70%)",
|
|
20
|
+
"hsl(200 100% 70%)",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const TEXT_COLORS = ["var(--primary)", "hsl(280 100% 70%)", "var(--primary)"]
|
|
24
|
+
|
|
7
25
|
export interface AnimatedGradientProps
|
|
8
26
|
extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
27
|
/** Gradient colors */
|
|
@@ -28,12 +46,7 @@ const blurValues = {
|
|
|
28
46
|
* Respects prefers-reduced-motion by stopping animation.
|
|
29
47
|
*/
|
|
30
48
|
export function AnimatedGradient({
|
|
31
|
-
colors
|
|
32
|
-
"hsl(var(--primary))",
|
|
33
|
-
"hsl(var(--secondary))",
|
|
34
|
-
"hsl(280 100% 70%)",
|
|
35
|
-
"hsl(200 100% 70%)",
|
|
36
|
-
],
|
|
49
|
+
colors: colorsProp,
|
|
37
50
|
speed = 10,
|
|
38
51
|
blur = "3xl",
|
|
39
52
|
className,
|
|
@@ -41,10 +54,11 @@ export function AnimatedGradient({
|
|
|
41
54
|
...props
|
|
42
55
|
}: AnimatedGradientProps) {
|
|
43
56
|
const prefersReducedMotion = useReducedMotion()
|
|
57
|
+
const colors = filledList(colorsProp, GRADIENT_COLORS)
|
|
44
58
|
|
|
45
59
|
return (
|
|
46
60
|
<div
|
|
47
|
-
className={cn("relative overflow-hidden", className)}
|
|
61
|
+
className={cn("relative isolate overflow-hidden", className)}
|
|
48
62
|
{...props}
|
|
49
63
|
>
|
|
50
64
|
{/* Gradient blobs */}
|
|
@@ -100,12 +114,13 @@ export interface AnimatedGradientTextProps {
|
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
export function AnimatedGradientText({
|
|
103
|
-
colors
|
|
117
|
+
colors: colorsProp,
|
|
104
118
|
speed = 3,
|
|
105
119
|
className,
|
|
106
120
|
children,
|
|
107
121
|
}: AnimatedGradientTextProps) {
|
|
108
122
|
const prefersReducedMotion = useReducedMotion()
|
|
123
|
+
const colors = filledList(colorsProp, TEXT_COLORS)
|
|
109
124
|
|
|
110
125
|
return (
|
|
111
126
|
<motion.span
|