@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.
Files changed (72) hide show
  1. package/README.md +44 -20
  2. package/dist/index.cjs +319 -0
  3. package/dist/index.d.cts +5 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +92 -24
  6. package/dist/lib/utils.cjs +11 -0
  7. package/dist/lib/utils.d.cts +9 -0
  8. package/dist/motion/index.cjs +61 -0
  9. package/dist/motion/index.d.cts +33 -0
  10. package/dist/registry/index.cjs +179 -0
  11. package/dist/registry/index.d.cts +17 -0
  12. package/dist/registry/index.js +32 -3
  13. package/dist/tokens/index.cjs +77 -0
  14. package/dist/tokens/index.d.cts +124 -0
  15. package/dist/tokens/index.d.ts +75 -6
  16. package/dist/tokens/index.js +59 -20
  17. package/package.json +17 -7
  18. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  19. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  20. package/registry/blocks/data-table/DataTable.tsx +53 -16
  21. package/registry/blocks/data-table/index.ts +1 -0
  22. package/registry/blocks/empty-state/EmptyState.test.tsx +23 -0
  23. package/registry/blocks/empty-state/EmptyState.tsx +32 -33
  24. package/registry/blocks/form-section/FormSection.tsx +1 -1
  25. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  26. package/registry/blocks/navbar/Navbar.tsx +8 -23
  27. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  28. package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
  29. package/registry/blocks/sidebar/Sidebar.tsx +36 -26
  30. package/registry/blocks/stats-card/StatsCard.tsx +12 -29
  31. package/registry/effects/animated-gradient/animated-gradient.tsx +19 -8
  32. package/registry/effects/border-beam/border-beam.tsx +77 -53
  33. package/registry/effects/effects.test.tsx +266 -0
  34. package/registry/effects/glow-card/glow-card.tsx +68 -43
  35. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  36. package/registry/effects/spotlight/spotlight.tsx +73 -29
  37. package/registry/effects/text-reveal/index.ts +1 -0
  38. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  39. package/registry/effects/text-reveal/text-reveal.tsx +70 -21
  40. package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
  41. package/registry/next14-tsconfig.test.ts +43 -0
  42. package/registry/text-props-undefined.test.tsx +73 -0
  43. package/registry/ui/avatar/avatar.test.tsx +316 -0
  44. package/registry/ui/avatar/avatar.tsx +235 -108
  45. package/registry/ui/avatar/index.ts +1 -1
  46. package/registry/ui/badge/badge.test.tsx +267 -32
  47. package/registry/ui/badge/badge.tsx +233 -21
  48. package/registry/ui/button/button.test.tsx +6 -0
  49. package/registry/ui/button/button.tsx +6 -23
  50. package/registry/ui/card/card.tsx +1 -1
  51. package/registry/ui/checkbox/checkbox.test.tsx +23 -0
  52. package/registry/ui/checkbox/checkbox.tsx +16 -27
  53. package/registry/ui/combobox/combobox.test.tsx +72 -0
  54. package/registry/ui/combobox/combobox.tsx +34 -59
  55. package/registry/ui/input/input.test.tsx +3 -3
  56. package/registry/ui/input/input.tsx +5 -5
  57. package/registry/ui/radio/index.ts +1 -0
  58. package/registry/ui/radio/radio.test.tsx +248 -0
  59. package/registry/ui/radio/radio.tsx +208 -0
  60. package/registry/ui/select/select.tsx +4 -14
  61. package/registry/ui/skeleton/index.ts +1 -0
  62. package/registry/ui/skeleton/skeleton.test.tsx +113 -29
  63. package/registry/ui/skeleton/skeleton.tsx +101 -41
  64. package/registry/ui/status-indicator/index.ts +6 -0
  65. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  66. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  67. package/registry/ui/switch/index.ts +1 -1
  68. package/registry/ui/switch/switch.test.tsx +323 -0
  69. package/registry/ui/switch/switch.tsx +254 -57
  70. package/registry/ui/textarea/textarea.tsx +1 -1
  71. package/registry.json +79 -16
  72. package/tailwind-preset.ts +143 -0
@@ -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
- typography: { fontFamily: "Inter", baseFontSizePx: 16, lineHeight: 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
- border: "hsl(var(--border))",
12
- input: "hsl(var(--input))",
13
- ring: "hsl(var(--ring))",
14
- background: "hsl(var(--background))",
15
- foreground: "hsl(var(--foreground))",
16
- primary: {
17
- DEFAULT: "hsl(var(--primary))",
18
- foreground: "hsl(var(--primary-foreground))"
19
- },
20
- secondary: {
21
- DEFAULT: "hsl(var(--secondary))",
22
- foreground: "hsl(var(--secondary-foreground))"
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
- muted: {
25
- DEFAULT: "hsl(var(--muted))",
26
- foreground: "hsl(var(--muted-foreground))"
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
- destructive: {
29
- DEFAULT: "hsl(var(--destructive))",
30
- foreground: "hsl(var(--destructive-foreground))"
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.2.4",
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
- "types": "./dist/tokens/index.d.ts",
31
- "import": "./dist/tokens/index.js"
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": {
@@ -42,7 +42,7 @@ export function AppShell({
42
42
  {sidebar && (
43
43
  <aside
44
44
  className={cn(
45
- "shrink-0 border-r bg-muted/30",
45
+ "shrink-0 border-e bg-muted/30",
46
46
  sidebarWidths[sidebarWidth],
47
47
  sidebarMobile ? "block" : "hidden md:block"
48
48
  )}
@@ -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?: "left" | "center" | "right"
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
- left: "text-left",
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 || "left"],
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 || "left"],
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 || "left"],
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 || "left"],
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="Pagination"
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="Go to previous page"
291
+ aria-label={text.goToPrevious}
255
292
  >
256
- Previous
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={`Go to page ${pageNum}`}
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="Go to next page"
322
+ aria-label={text.goToNext}
286
323
  >
287
- Next
324
+ {text.next}
288
325
  </button>
289
326
  </div>
290
327
  </nav>
@@ -4,4 +4,5 @@ export {
4
4
  type Column,
5
5
  type DataTableProps,
6
6
  type PaginationProps,
7
+ type PaginationLabels,
7
8
  } from "./DataTable"
@@ -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
- onClear,
102
- ...props
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="No results found"
124
+ // ?? rather than a later spread, so an undefined title keeps the English one
125
+ title={title ?? "No results found"}
110
126
  description={
111
- query
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
- <svg
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
- <svg
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
  />
@@ -82,7 +82,7 @@ export function FormField({
82
82
  )}
83
83
  >
84
84
  {label}
85
- {required && <span className="ml-1 text-destructive">*</span>}
85
+ {required && <span className="ms-1 text-destructive">*</span>}
86
86
  </label>
87
87
  {children}
88
88
  {description && !error && (
@@ -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
- <svg
166
- width="20"
167
- height="20"
168
- viewBox="0 0 24 24"
169
- fill="none"
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="Breadcrumb">
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 tracking-tight md:text-2xl">
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
+ })