@smicolon/fasla-ui 0.2.4 → 0.4.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 +44 -20
- package/dist/index.cjs +319 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +92 -24
- package/dist/lib/utils.cjs +11 -0
- package/dist/lib/utils.d.cts +9 -0
- package/dist/motion/index.cjs +61 -0
- package/dist/motion/index.d.cts +33 -0
- package/dist/registry/index.cjs +179 -0
- package/dist/registry/index.d.cts +17 -0
- package/dist/registry/index.js +32 -3
- package/dist/tokens/index.cjs +77 -0
- package/dist/tokens/index.d.cts +124 -0
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +17 -7
- 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 +23 -0
- package/registry/blocks/empty-state/EmptyState.tsx +32 -33
- 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.test.tsx +39 -0
- package/registry/blocks/sidebar/Sidebar.tsx +36 -26
- package/registry/blocks/stats-card/StatsCard.tsx +12 -29
- package/registry/effects/animated-gradient/animated-gradient.tsx +19 -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 +70 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
- package/registry/next14-tsconfig.test.ts +43 -0
- 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.test.tsx +23 -0
- package/registry/ui/checkbox/checkbox.tsx +16 -27
- package/registry/ui/combobox/combobox.test.tsx +72 -0
- package/registry/ui/combobox/combobox.tsx +34 -59
- package/registry/ui/input/input.test.tsx +3 -3
- package/registry/ui/input/input.tsx +5 -5
- 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/index.ts +1 -0
- package/registry/ui/skeleton/skeleton.test.tsx +113 -29
- package/registry/ui/skeleton/skeleton.tsx +101 -41
- 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 +79 -16
- package/tailwind-preset.ts +143 -0
package/dist/tokens/index.js
CHANGED
|
@@ -2,33 +2,72 @@
|
|
|
2
2
|
var baseTokens = {
|
|
3
3
|
radius: { sm: "0.5rem", md: "0.75rem", lg: "1rem" },
|
|
4
4
|
spacing: { densityMultiplier: { compact: 0.9, comfortable: 1, spacious: 1.1 } },
|
|
5
|
-
|
|
5
|
+
// Brand V2.5 §14 — Geist is the LTR face. Arabic is set in Cairo, which the
|
|
6
|
+
// Tailwind preset selects per direction; this scaffold carries only the base.
|
|
7
|
+
// 16/24 is the `Base` rung, identical in both Figma ramps.
|
|
8
|
+
typography: { fontFamily: "Geist", baseFontSizePx: 16, lineHeight: 1.5 },
|
|
6
9
|
motion: { durationsMs: { fast: 120, base: 180, slow: 260 } }
|
|
7
10
|
};
|
|
8
11
|
|
|
9
12
|
// src/tokens/tailwind.ts
|
|
13
|
+
var semanticColor = (name) => `color-mix(in oklch, var(--${name}) calc(<alpha-value> * 100%), transparent)`;
|
|
14
|
+
var c = semanticColor;
|
|
10
15
|
var tailwindSemanticColors = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
foreground: "
|
|
16
|
-
primary
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
},
|
|
20
|
-
secondary: {
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
// Surfaces
|
|
17
|
+
background: c("background"),
|
|
18
|
+
foreground: c("foreground"),
|
|
19
|
+
card: { DEFAULT: c("card"), foreground: c("card-foreground") },
|
|
20
|
+
popover: { DEFAULT: c("popover"), foreground: c("popover-foreground") },
|
|
21
|
+
// Actions. `primary` is Figma's strong neutral (#0a0a0a light, #fafafa dark),
|
|
22
|
+
// identical to `foreground` in both modes. It is NOT a brand accent — an app
|
|
23
|
+
// that wants its brand colour should define its own token for it.
|
|
24
|
+
primary: { DEFAULT: c("primary"), foreground: c("primary-foreground") },
|
|
25
|
+
secondary: { DEFAULT: c("secondary"), foreground: c("secondary-foreground") },
|
|
26
|
+
muted: { DEFAULT: c("muted"), foreground: c("muted-foreground") },
|
|
27
|
+
accent: { DEFAULT: c("accent"), foreground: c("accent-foreground") },
|
|
28
|
+
destructive: {
|
|
29
|
+
DEFAULT: c("destructive"),
|
|
30
|
+
foreground: c("destructive-foreground")
|
|
23
31
|
},
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
// Lines and focus
|
|
33
|
+
border: c("border"),
|
|
34
|
+
input: c("input"),
|
|
35
|
+
ring: c("ring"),
|
|
36
|
+
// Status
|
|
37
|
+
success: { DEFAULT: c("success"), foreground: c("success-foreground") },
|
|
38
|
+
warning: { DEFAULT: c("warning"), foreground: c("warning-foreground") },
|
|
39
|
+
info: { DEFAULT: c("info"), foreground: c("info-foreground") },
|
|
40
|
+
// Soft tints — Figma's `opacity/*` variables, already translucent. The Badge
|
|
41
|
+
// Soft type lays one over `card`, exactly as Figma stacks the two fills, so
|
|
42
|
+
// the chip reads the same on any surface. Only `primary` changes in Dark.
|
|
43
|
+
soft: {
|
|
44
|
+
primary: c("soft-primary"),
|
|
45
|
+
secondary: c("soft-secondary"),
|
|
46
|
+
info: c("soft-info"),
|
|
47
|
+
success: c("soft-success"),
|
|
48
|
+
warning: c("soft-warning"),
|
|
49
|
+
destructive: c("soft-destructive")
|
|
27
50
|
},
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
51
|
+
// Inverse — a fixed contrast band, not a mode-reactive surface.
|
|
52
|
+
"background-inverse": c("background-inverse"),
|
|
53
|
+
"foreground-inverse": c("foreground-inverse"),
|
|
54
|
+
"muted-foreground-inverse": c("muted-foreground-inverse"),
|
|
55
|
+
// Charts
|
|
56
|
+
"chart-1": c("chart-1"),
|
|
57
|
+
"chart-2": c("chart-2"),
|
|
58
|
+
"chart-3": c("chart-3"),
|
|
59
|
+
"chart-4": c("chart-4"),
|
|
60
|
+
"chart-5": c("chart-5"),
|
|
61
|
+
sidebar: {
|
|
62
|
+
DEFAULT: c("sidebar"),
|
|
63
|
+
foreground: c("sidebar-foreground"),
|
|
64
|
+
primary: c("sidebar-primary"),
|
|
65
|
+
"primary-foreground": c("sidebar-primary-foreground"),
|
|
66
|
+
accent: c("sidebar-accent"),
|
|
67
|
+
"accent-foreground": c("sidebar-accent-foreground"),
|
|
68
|
+
border: c("sidebar-border"),
|
|
69
|
+
ring: c("sidebar-ring")
|
|
31
70
|
}
|
|
32
71
|
};
|
|
33
72
|
|
|
34
|
-
export { baseTokens, tailwindSemanticColors };
|
|
73
|
+
export { baseTokens, semanticColor, tailwindSemanticColors };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smicolon/fasla-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Professional UI components with animations and app building blocks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Smicolon",
|
|
@@ -27,8 +27,15 @@
|
|
|
27
27
|
"import": "./dist/index.js"
|
|
28
28
|
},
|
|
29
29
|
"./tokens": {
|
|
30
|
-
"
|
|
31
|
-
|
|
30
|
+
"import": {
|
|
31
|
+
"types": "./dist/tokens/index.d.ts",
|
|
32
|
+
"default": "./dist/tokens/index.js"
|
|
33
|
+
},
|
|
34
|
+
"require": {
|
|
35
|
+
"types": "./dist/tokens/index.d.cts",
|
|
36
|
+
"default": "./dist/tokens/index.cjs"
|
|
37
|
+
},
|
|
38
|
+
"default": "./dist/tokens/index.js"
|
|
32
39
|
},
|
|
33
40
|
"./motion": {
|
|
34
41
|
"types": "./dist/motion/index.d.ts",
|
|
@@ -38,6 +45,7 @@
|
|
|
38
45
|
"types": "./dist/registry/index.d.ts",
|
|
39
46
|
"import": "./dist/registry/index.js"
|
|
40
47
|
},
|
|
48
|
+
"./tailwind-preset": "./tailwind-preset.ts",
|
|
41
49
|
"./lib/utils": {
|
|
42
50
|
"types": "./dist/lib/utils.d.ts",
|
|
43
51
|
"import": "./dist/lib/utils.js"
|
|
@@ -45,6 +53,7 @@
|
|
|
45
53
|
},
|
|
46
54
|
"files": [
|
|
47
55
|
"dist",
|
|
56
|
+
"tailwind-preset.ts",
|
|
48
57
|
"registry",
|
|
49
58
|
"registry.json"
|
|
50
59
|
],
|
|
@@ -74,6 +83,7 @@
|
|
|
74
83
|
"eslint": "^8.57.0",
|
|
75
84
|
"framer-motion": "^11.0.0",
|
|
76
85
|
"jsdom": "^24.0.0",
|
|
86
|
+
"lucide-react": "^1.48.0",
|
|
77
87
|
"react": "^18.2.0",
|
|
78
88
|
"react-dom": "^18.2.0",
|
|
79
89
|
"tsup": "^8.0.0",
|
|
@@ -81,10 +91,10 @@
|
|
|
81
91
|
"vitest": "^1.6.0"
|
|
82
92
|
},
|
|
83
93
|
"peerDependencies": {
|
|
84
|
-
"react": "^18.0.0",
|
|
85
|
-
"react-dom": "^18.0.0",
|
|
86
|
-
"framer-motion": "^11.0.0",
|
|
87
|
-
"tailwindcss": "^3.4.0"
|
|
94
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
95
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
96
|
+
"framer-motion": "^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0",
|
|
97
|
+
"tailwindcss": "^3.4.0 || ^4.1.4"
|
|
88
98
|
},
|
|
89
99
|
"peerDependenciesMeta": {
|
|
90
100
|
"framer-motion": {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import { DataTable, Pagination } from "./DataTable"
|
|
4
|
+
|
|
5
|
+
describe("Pagination text", () => {
|
|
6
|
+
it("keeps its English defaults", () => {
|
|
7
|
+
render(<Pagination page={2} totalPages={5} onPageChange={() => {}} />)
|
|
8
|
+
expect(screen.getByRole("navigation", { name: "Pagination" })).toBeTruthy()
|
|
9
|
+
expect(screen.getByText("Page 2 of 5")).toBeTruthy()
|
|
10
|
+
expect(screen.getByRole("button", { name: "Go to previous page" })).toBeTruthy()
|
|
11
|
+
expect(screen.getByRole("button", { name: "Go to page 3" })).toBeTruthy()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("uses the text it is given", () => {
|
|
15
|
+
render(
|
|
16
|
+
<Pagination
|
|
17
|
+
page={2}
|
|
18
|
+
totalPages={5}
|
|
19
|
+
onPageChange={() => {}}
|
|
20
|
+
labels={{
|
|
21
|
+
pagination: "التنقّل بين الصفحات",
|
|
22
|
+
status: (page, total) => `الصفحة ${page} من ${total}`,
|
|
23
|
+
previous: "السابق",
|
|
24
|
+
next: "التالي",
|
|
25
|
+
goToPrevious: "الانتقال إلى الصفحة السابقة",
|
|
26
|
+
goToNext: "الانتقال إلى الصفحة التالية",
|
|
27
|
+
goToPage: (page) => `الانتقال إلى الصفحة ${page}`,
|
|
28
|
+
}}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
expect(screen.getByRole("navigation", { name: "التنقّل بين الصفحات" })).toBeTruthy()
|
|
32
|
+
expect(screen.getByText("الصفحة 2 من 5")).toBeTruthy()
|
|
33
|
+
expect(screen.getByRole("button", { name: "الانتقال إلى الصفحة السابقة" }).textContent).toBe("السابق")
|
|
34
|
+
expect(screen.getByRole("button", { name: "الانتقال إلى الصفحة 3" })).toBeTruthy()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe("DataTable column align", () => {
|
|
39
|
+
const cellClass = (align?: "start" | "center" | "end" | "left" | "right") => {
|
|
40
|
+
const { container, unmount } = render(
|
|
41
|
+
<DataTable data={[{ id: 1 }]} columns={[{ id: "id", header: "ID", cell: (row) => row.id, align }]} getRowKey={(row) => row.id} />
|
|
42
|
+
)
|
|
43
|
+
const className = container.querySelector("td")!.className
|
|
44
|
+
unmount()
|
|
45
|
+
return className
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
it("defaults to the start of the reading direction", () => {
|
|
49
|
+
expect(cellClass()).toContain("text-start")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("maps start and end to logical sides", () => {
|
|
53
|
+
expect(cellClass("start")).toContain("text-start")
|
|
54
|
+
expect(cellClass("end")).toContain("text-end")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("keeps left and right physical", () => {
|
|
58
|
+
expect(cellClass("left")).toContain("text-left")
|
|
59
|
+
expect(cellClass("right")).toContain("text-right")
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -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>
|
|
@@ -92,6 +92,17 @@ describe("EmptySearchResults", () => {
|
|
|
92
92
|
const svg = container.querySelector("svg")
|
|
93
93
|
expect(svg).toBeInTheDocument()
|
|
94
94
|
})
|
|
95
|
+
|
|
96
|
+
it("accepts the deprecated onClear without passing it to the DOM or showing a control", () => {
|
|
97
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
98
|
+
try {
|
|
99
|
+
render(<EmptySearchResults onClear={() => {}} />)
|
|
100
|
+
expect(error).not.toHaveBeenCalled()
|
|
101
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument()
|
|
102
|
+
} finally {
|
|
103
|
+
error.mockRestore()
|
|
104
|
+
}
|
|
105
|
+
})
|
|
95
106
|
})
|
|
96
107
|
|
|
97
108
|
describe("EmptyData", () => {
|
|
@@ -118,3 +129,15 @@ describe("EmptyData", () => {
|
|
|
118
129
|
expect(svg).toBeInTheDocument()
|
|
119
130
|
})
|
|
120
131
|
})
|
|
132
|
+
|
|
133
|
+
describe("EmptyState presets", () => {
|
|
134
|
+
it("take a title and description in place of their English", () => {
|
|
135
|
+
render(<EmptySearchResults title="لا توجد نتائج" description="جرّب كلمة بحث أخرى." />)
|
|
136
|
+
expect(screen.getByText("لا توجد نتائج")).toBeInTheDocument()
|
|
137
|
+
expect(screen.getByText("جرّب كلمة بحث أخرى.")).toBeInTheDocument()
|
|
138
|
+
expect(screen.queryByText("No results found")).not.toBeInTheDocument()
|
|
139
|
+
|
|
140
|
+
render(<EmptyData title="لا توجد طلبات بعد" description="أنشئ طلبك الأول للبدء." />)
|
|
141
|
+
expect(screen.getByText("لا توجد طلبات بعد")).toBeInTheDocument()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -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> {
|
|
@@ -98,34 +99,38 @@ export function EmptyState({
|
|
|
98
99
|
/** Pre-built empty state for no search results */
|
|
99
100
|
export function EmptySearchResults({
|
|
100
101
|
query,
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
title,
|
|
103
|
+
description,
|
|
104
|
+
...rest
|
|
103
105
|
}: Omit<EmptyStateProps, "title" | "description"> & {
|
|
106
|
+
/** Replaces the English title */
|
|
107
|
+
title?: string
|
|
108
|
+
/** Replaces the English description */
|
|
109
|
+
description?: string
|
|
104
110
|
query?: string
|
|
111
|
+
/**
|
|
112
|
+
* @deprecated Does nothing: this empty state has no clear control. Pass a
|
|
113
|
+
* button as `action` instead.
|
|
114
|
+
*/
|
|
105
115
|
onClear?: () => void
|
|
106
116
|
}) {
|
|
117
|
+
// onClear is kept so existing callers still compile; take it out here so it
|
|
118
|
+
// never reaches the DOM through EmptyState's spread.
|
|
119
|
+
const props = { ...rest }
|
|
120
|
+
delete props.onClear
|
|
121
|
+
|
|
107
122
|
return (
|
|
108
123
|
<EmptyState
|
|
109
|
-
title
|
|
124
|
+
// ?? rather than a later spread, so an undefined title keeps the English one
|
|
125
|
+
title={title ?? "No results found"}
|
|
110
126
|
description={
|
|
111
|
-
|
|
127
|
+
description ??
|
|
128
|
+
(query
|
|
112
129
|
? `No results for "${query}". Try a different search term.`
|
|
113
|
-
: "Try adjusting your search or filters."
|
|
130
|
+
: "Try adjusting your search or filters.")
|
|
114
131
|
}
|
|
115
132
|
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>
|
|
133
|
+
<SearchIcon strokeWidth={1.5} />
|
|
129
134
|
}
|
|
130
135
|
{...props}
|
|
131
136
|
/>
|
|
@@ -135,28 +140,22 @@ export function EmptySearchResults({
|
|
|
135
140
|
/** Pre-built empty state for no data */
|
|
136
141
|
export function EmptyData({
|
|
137
142
|
resourceName = "items",
|
|
143
|
+
title,
|
|
144
|
+
description,
|
|
138
145
|
...props
|
|
139
146
|
}: Omit<EmptyStateProps, "title" | "description"> & {
|
|
147
|
+
/** Replaces the English title */
|
|
148
|
+
title?: string
|
|
149
|
+
/** Replaces the English description */
|
|
150
|
+
description?: string
|
|
140
151
|
resourceName?: string
|
|
141
152
|
}) {
|
|
142
153
|
return (
|
|
143
154
|
<EmptyState
|
|
144
|
-
title={`No ${resourceName} yet`}
|
|
145
|
-
description={`Get started by creating your first ${resourceName.replace(/s$/, "")}.`}
|
|
155
|
+
title={title ?? `No ${resourceName} yet`}
|
|
156
|
+
description={description ?? `Get started by creating your first ${resourceName.replace(/s$/, "")}.`}
|
|
146
157
|
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>
|
|
158
|
+
<FilePlusIcon strokeWidth={1.5} />
|
|
160
159
|
}
|
|
161
160
|
{...props}
|
|
162
161
|
/>
|
|
@@ -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 && (
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
3
|
+
import { Sidebar, SidebarCollapseButton } from "./Sidebar"
|
|
4
|
+
|
|
5
|
+
describe("Sidebar onCollapsedChange", () => {
|
|
6
|
+
it("is called when the collapse button inside it is pressed", () => {
|
|
7
|
+
const onSidebarChange = vi.fn()
|
|
8
|
+
const onButtonChange = vi.fn()
|
|
9
|
+
render(
|
|
10
|
+
<Sidebar onCollapsedChange={onSidebarChange}>
|
|
11
|
+
<SidebarCollapseButton collapsed={false} onCollapsedChange={onButtonChange} />
|
|
12
|
+
</Sidebar>
|
|
13
|
+
)
|
|
14
|
+
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }))
|
|
15
|
+
expect(onButtonChange).toHaveBeenCalledWith(true)
|
|
16
|
+
expect(onSidebarChange).toHaveBeenCalledWith(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("calls a handler given to both the sidebar and the button once", () => {
|
|
20
|
+
const onChange = vi.fn()
|
|
21
|
+
render(
|
|
22
|
+
<Sidebar collapsed onCollapsedChange={onChange}>
|
|
23
|
+
<SidebarCollapseButton collapsed onCollapsedChange={onChange} />
|
|
24
|
+
</Sidebar>
|
|
25
|
+
)
|
|
26
|
+
fireEvent.click(screen.getByRole("button", { name: "Expand sidebar" }))
|
|
27
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
28
|
+
expect(onChange).toHaveBeenCalledWith(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("is not passed to the aside", () => {
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<Sidebar onCollapsedChange={() => {}}>
|
|
34
|
+
<span />
|
|
35
|
+
</Sidebar>
|
|
36
|
+
)
|
|
37
|
+
expect(container.querySelector("aside")?.getAttributeNames()).toEqual(["class"])
|
|
38
|
+
})
|
|
39
|
+
})
|