@zentauri-ui/zentauri-components 2.1.5 → 2.1.7

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 (149) hide show
  1. package/README.md +12 -8
  2. package/cli/cli.integration.test.ts +36 -0
  3. package/cli/index.mjs +91 -12
  4. package/cli/index.test.ts +180 -0
  5. package/cli/props.json +609 -14
  6. package/cli/registry.json +22 -0
  7. package/cli/rewrite-imports.mjs +29 -4
  8. package/cli/rewrite-imports.test.ts +35 -0
  9. package/dist/{chunk-RENXBUZY.js → chunk-5ELR6MIN.js} +6 -6
  10. package/dist/{chunk-RENXBUZY.js.map → chunk-5ELR6MIN.js.map} +1 -1
  11. package/dist/chunk-5FU57ZVQ.js +19 -0
  12. package/dist/{chunk-D2GISTDL.js.map → chunk-5FU57ZVQ.js.map} +1 -1
  13. package/dist/chunk-74SKXGTM.js +4 -0
  14. package/dist/chunk-74SKXGTM.js.map +1 -0
  15. package/dist/{chunk-WBZKMSXW.mjs → chunk-7UXPXCKV.mjs} +3 -3
  16. package/dist/{chunk-WBZKMSXW.mjs.map → chunk-7UXPXCKV.mjs.map} +1 -1
  17. package/dist/chunk-COCPCZMR.mjs +77 -0
  18. package/dist/chunk-COCPCZMR.mjs.map +1 -0
  19. package/dist/chunk-CYKSS5S5.mjs +128 -0
  20. package/dist/chunk-CYKSS5S5.mjs.map +1 -0
  21. package/dist/chunk-DBNGLT5U.mjs +221 -0
  22. package/dist/chunk-DBNGLT5U.mjs.map +1 -0
  23. package/dist/{chunk-BL6UVCV7.mjs → chunk-FUCW5GPE.mjs} +36 -11
  24. package/dist/chunk-FUCW5GPE.mjs.map +1 -0
  25. package/dist/chunk-G7FVHZRB.js +225 -0
  26. package/dist/chunk-G7FVHZRB.js.map +1 -0
  27. package/dist/chunk-HMDH4BQJ.js +123 -0
  28. package/dist/chunk-HMDH4BQJ.js.map +1 -0
  29. package/dist/chunk-I7EBE7BD.js +98 -0
  30. package/dist/chunk-I7EBE7BD.js.map +1 -0
  31. package/dist/{chunk-PAG5CTLN.mjs → chunk-KVSRUAXP.mjs} +3 -3
  32. package/dist/{chunk-PAG5CTLN.mjs.map → chunk-KVSRUAXP.mjs.map} +1 -1
  33. package/dist/chunk-LHBJD57K.mjs +143 -0
  34. package/dist/chunk-LHBJD57K.mjs.map +1 -0
  35. package/dist/chunk-OYAJG2BO.js +83 -0
  36. package/dist/chunk-OYAJG2BO.js.map +1 -0
  37. package/dist/chunk-PG7LQVU6.js +86 -0
  38. package/dist/chunk-PG7LQVU6.js.map +1 -0
  39. package/dist/chunk-PTU5ZAYX.js +145 -0
  40. package/dist/chunk-PTU5ZAYX.js.map +1 -0
  41. package/dist/chunk-QKO5DA4N.mjs +81 -0
  42. package/dist/chunk-QKO5DA4N.mjs.map +1 -0
  43. package/dist/chunk-T7PIKDUZ.js +130 -0
  44. package/dist/chunk-T7PIKDUZ.js.map +1 -0
  45. package/dist/chunk-TDK5TVJE.mjs +3 -0
  46. package/dist/chunk-TDK5TVJE.mjs.map +1 -0
  47. package/dist/{chunk-NZSZE36T.js → chunk-TJ2EWPER.js} +42 -10
  48. package/dist/chunk-TJ2EWPER.js.map +1 -0
  49. package/dist/chunk-VBNW2B4D.mjs +3 -0
  50. package/dist/chunk-VBNW2B4D.mjs.map +1 -0
  51. package/dist/chunk-W6DO36XD.mjs +96 -0
  52. package/dist/chunk-W6DO36XD.mjs.map +1 -0
  53. package/dist/chunk-XR3J46TZ.js +4 -0
  54. package/dist/chunk-XR3J46TZ.js.map +1 -0
  55. package/dist/chunk-ZOHCADDL.mjs +121 -0
  56. package/dist/chunk-ZOHCADDL.mjs.map +1 -0
  57. package/dist/design-system/audio-player.d.ts +61 -0
  58. package/dist/design-system/audio-player.d.ts.map +1 -0
  59. package/dist/design-system/data-table.d.ts +8 -0
  60. package/dist/design-system/data-table.d.ts.map +1 -0
  61. package/dist/design-system/facade.js +11 -10
  62. package/dist/design-system/facade.js.map +1 -1
  63. package/dist/design-system/facade.mjs +10 -9
  64. package/dist/design-system/facade.mjs.map +1 -1
  65. package/dist/design-system/index.d.ts +2 -0
  66. package/dist/design-system/index.d.ts.map +1 -1
  67. package/dist/hooks/useTableFilter.js +6 -116
  68. package/dist/hooks/useTableFilter.js.map +1 -1
  69. package/dist/hooks/useTableFilter.mjs +1 -118
  70. package/dist/hooks/useTableFilter.mjs.map +1 -1
  71. package/dist/hooks/useTableSort.js +6 -91
  72. package/dist/hooks/useTableSort.js.map +1 -1
  73. package/dist/hooks/useTableSort.mjs +1 -93
  74. package/dist/hooks/useTableSort.mjs.map +1 -1
  75. package/dist/hooks/useVirtualList.js +6 -76
  76. package/dist/hooks/useVirtualList.js.map +1 -1
  77. package/dist/hooks/useVirtualList.mjs +1 -78
  78. package/dist/hooks/useVirtualList.mjs.map +1 -1
  79. package/dist/ui/audio-player/audio-player-base.d.ts +20 -0
  80. package/dist/ui/audio-player/audio-player-base.d.ts.map +1 -0
  81. package/dist/ui/audio-player/audio-player.d.ts +6 -0
  82. package/dist/ui/audio-player/audio-player.d.ts.map +1 -0
  83. package/dist/ui/audio-player/index.d.ts +5 -0
  84. package/dist/ui/audio-player/index.d.ts.map +1 -0
  85. package/dist/ui/audio-player/types.d.ts +44 -0
  86. package/dist/ui/audio-player/types.d.ts.map +1 -0
  87. package/dist/ui/audio-player/variants.d.ts +12 -0
  88. package/dist/ui/audio-player/variants.d.ts.map +1 -0
  89. package/dist/ui/audio-player.js +556 -0
  90. package/dist/ui/audio-player.js.map +1 -0
  91. package/dist/ui/audio-player.mjs +545 -0
  92. package/dist/ui/audio-player.mjs.map +1 -0
  93. package/dist/ui/buttons/animated.js +13 -12
  94. package/dist/ui/buttons/animated.js.map +1 -1
  95. package/dist/ui/buttons/animated.mjs +11 -10
  96. package/dist/ui/buttons/animated.mjs.map +1 -1
  97. package/dist/ui/buttons.js +15 -13
  98. package/dist/ui/buttons.mjs +13 -11
  99. package/dist/ui/checkbox.js +7 -123
  100. package/dist/ui/checkbox.js.map +1 -1
  101. package/dist/ui/checkbox.mjs +2 -126
  102. package/dist/ui/checkbox.mjs.map +1 -1
  103. package/dist/ui/data-table/data-table-base.d.ts +6 -0
  104. package/dist/ui/data-table/data-table-base.d.ts.map +1 -0
  105. package/dist/ui/data-table/data-table.d.ts +6 -0
  106. package/dist/ui/data-table/data-table.d.ts.map +1 -0
  107. package/dist/ui/data-table/index.d.ts +4 -0
  108. package/dist/ui/data-table/index.d.ts.map +1 -0
  109. package/dist/ui/data-table/types.d.ts +92 -0
  110. package/dist/ui/data-table/types.d.ts.map +1 -0
  111. package/dist/ui/data-table/variants.d.ts +8 -0
  112. package/dist/ui/data-table/variants.d.ts.map +1 -0
  113. package/dist/ui/data-table.js +620 -0
  114. package/dist/ui/data-table.js.map +1 -0
  115. package/dist/ui/data-table.mjs +611 -0
  116. package/dist/ui/data-table.mjs.map +1 -0
  117. package/dist/ui/dynamic-stepper.js +23 -22
  118. package/dist/ui/dynamic-stepper.js.map +1 -1
  119. package/dist/ui/dynamic-stepper.mjs +12 -11
  120. package/dist/ui/dynamic-stepper.mjs.map +1 -1
  121. package/dist/ui/inputs.js +7 -138
  122. package/dist/ui/inputs.js.map +1 -1
  123. package/dist/ui/inputs.mjs +2 -141
  124. package/dist/ui/inputs.mjs.map +1 -1
  125. package/dist/ui/pagination.js +25 -225
  126. package/dist/ui/pagination.js.map +1 -1
  127. package/dist/ui/pagination.mjs +13 -227
  128. package/dist/ui/pagination.mjs.map +1 -1
  129. package/dist/ui/table.js +1 -0
  130. package/dist/ui/table.mjs +1 -0
  131. package/package.json +1 -1
  132. package/src/design-system/audio-player.ts +109 -0
  133. package/src/design-system/data-table.ts +20 -0
  134. package/src/design-system/index.ts +2 -0
  135. package/src/ui/audio-player/audio-player-base.tsx +557 -0
  136. package/src/ui/audio-player/audio-player.test.tsx +485 -0
  137. package/src/ui/audio-player/audio-player.tsx +8 -0
  138. package/src/ui/audio-player/index.ts +24 -0
  139. package/src/ui/audio-player/types.ts +57 -0
  140. package/src/ui/audio-player/variants.ts +43 -0
  141. package/src/ui/data-table/data-table-base.tsx +701 -0
  142. package/src/ui/data-table/data-table.test.tsx +389 -0
  143. package/src/ui/data-table/data-table.tsx +11 -0
  144. package/src/ui/data-table/index.ts +24 -0
  145. package/src/ui/data-table/types.ts +121 -0
  146. package/src/ui/data-table/variants.ts +21 -0
  147. package/dist/chunk-BL6UVCV7.mjs.map +0 -1
  148. package/dist/chunk-D2GISTDL.js +0 -19
  149. package/dist/chunk-NZSZE36T.js.map +0 -1
@@ -0,0 +1,485 @@
1
+ import { createRef } from "react";
2
+ import { render, screen, act } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi, beforeEach } from "vitest";
5
+
6
+ import { AudioPlayer } from "./audio-player";
7
+ import {
8
+ AudioPlayerBase,
9
+ AudioPlayerProgress,
10
+ AudioPlayerTime,
11
+ AudioPlayerVolume,
12
+ useAudioPlayer,
13
+ } from "./audio-player-base";
14
+
15
+ // jsdom does not implement HTMLMediaElement playback — stub the methods.
16
+ beforeEach(() => {
17
+ window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined);
18
+ window.HTMLMediaElement.prototype.pause = vi.fn();
19
+ Object.defineProperty(window.HTMLMediaElement.prototype, "duration", {
20
+ configurable: true,
21
+ get: () => 120,
22
+ });
23
+ // jsdom does not implement pointer capture — stub to avoid unhandled errors
24
+ // when userEvent clicks to focus a slider before firing keyboard events.
25
+ HTMLElement.prototype.setPointerCapture = vi.fn();
26
+ HTMLElement.prototype.releasePointerCapture = vi.fn();
27
+ });
28
+
29
+ // Helper: a minimal player with all sub-components and a test-driven controls div.
30
+ function FullPlayer({
31
+ src = "test.mp3",
32
+ ...rest
33
+ }: Partial<Parameters<typeof AudioPlayerBase>[0]>) {
34
+ return (
35
+ <AudioPlayerBase src={src} {...rest}>
36
+ <AudioPlayerProgress />
37
+ <AudioPlayerTime />
38
+ <AudioPlayerVolume />
39
+ </AudioPlayerBase>
40
+ );
41
+ }
42
+
43
+ // Helper: renders a component that reads from context so we can inspect state.
44
+ function ContextInspector() {
45
+ const ctx = useAudioPlayer();
46
+ return (
47
+ <div>
48
+ <span data-testid="isPlaying">{String(ctx.isPlaying)}</span>
49
+ <span data-testid="currentTime">{ctx.currentTime}</span>
50
+ <span data-testid="duration">{ctx.duration}</span>
51
+ <span data-testid="progress">{ctx.progress}</span>
52
+ <span data-testid="volume">{ctx.volume}</span>
53
+ <span data-testid="muted">{String(ctx.muted)}</span>
54
+ <button data-testid="play" onClick={ctx.play}>
55
+ play
56
+ </button>
57
+ <button data-testid="pause" onClick={ctx.pause}>
58
+ pause
59
+ </button>
60
+ <button data-testid="toggle" onClick={ctx.toggle}>
61
+ toggle
62
+ </button>
63
+ <button data-testid="reset" onClick={ctx.reset}>
64
+ reset
65
+ </button>
66
+ <button data-testid="seek" onClick={() => ctx.seek(30)}>
67
+ seek
68
+ </button>
69
+ <button data-testid="seekPct" onClick={() => ctx.seekByPercent(50)}>
70
+ seekPct
71
+ </button>
72
+ <button data-testid="setVol" onClick={() => ctx.setVolume(0.5)}>
73
+ setVol
74
+ </button>
75
+ <button data-testid="toggleMute" onClick={ctx.toggleMute}>
76
+ toggleMute
77
+ </button>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ describe("AudioPlayer", () => {
83
+ it("exposes displayName on all parts", () => {
84
+ expect(AudioPlayer.displayName).toBe("AudioPlayer");
85
+ expect(AudioPlayerBase.displayName).toBe("AudioPlayer");
86
+ expect(AudioPlayerProgress.displayName).toBe("AudioPlayerProgress");
87
+ expect(AudioPlayerTime.displayName).toBe("AudioPlayerTime");
88
+ expect(AudioPlayerVolume.displayName).toBe("AudioPlayerVolume");
89
+ });
90
+
91
+ it("stamps data-slot on root", () => {
92
+ render(<FullPlayer />);
93
+ expect(document.querySelector('[data-slot="audio-player"]')).toBeTruthy();
94
+ });
95
+
96
+ it("renders a hidden audio element with the given src", () => {
97
+ render(<FullPlayer src="song.mp3" />);
98
+ const audio = document.querySelector("audio") as HTMLAudioElement;
99
+ expect(audio).toBeTruthy();
100
+ expect(audio.src).toContain("song.mp3");
101
+ expect(audio.className).toContain("hidden");
102
+ });
103
+
104
+ it("applies appearance, size, shape classes via variants", () => {
105
+ render(<FullPlayer appearance="blue" size="lg" shape="pill" />);
106
+ const root = document.querySelector(
107
+ '[data-slot="audio-player"]',
108
+ ) as HTMLElement;
109
+ expect(root.className).toMatch(/--audio-fill/);
110
+ expect(root.className).toContain("p-5");
111
+ expect(root.className).toContain("text-base");
112
+ expect(root.className).toContain("gap-4");
113
+ expect(root.className).toContain("rounded-3xl");
114
+ });
115
+
116
+ it("forwards ref to the root div", () => {
117
+ const ref = createRef<HTMLDivElement>();
118
+ render(<AudioPlayerBase ref={ref} src="test.mp3" />);
119
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
120
+ expect(ref.current?.getAttribute("data-slot")).toBe("audio-player");
121
+ });
122
+
123
+ describe("useAudioPlayer", () => {
124
+ it("throws when used outside AudioPlayer", () => {
125
+ const consoleError = vi
126
+ .spyOn(console, "error")
127
+ .mockImplementation(() => {});
128
+ expect(() => render(<AudioPlayerProgress />)).toThrow(
129
+ "useAudioPlayer must be used within <AudioPlayer>",
130
+ );
131
+ consoleError.mockRestore();
132
+ });
133
+ });
134
+
135
+ describe("AudioPlayerProgress", () => {
136
+ it("stamps data-slot and has slider role", () => {
137
+ render(<FullPlayer />);
138
+ const slider = screen.getByRole("slider", { name: "Audio progress" });
139
+ expect(slider).toBeTruthy();
140
+ expect(slider.getAttribute("data-slot")).toBe("audio-player-progress");
141
+ });
142
+
143
+ it("has correct aria attributes at 0%", () => {
144
+ render(<FullPlayer />);
145
+ const slider = screen.getByRole("slider", { name: "Audio progress" });
146
+ expect(slider.getAttribute("aria-valuenow")).toBe("0");
147
+ expect(slider.getAttribute("aria-valuemin")).toBe("0");
148
+ expect(slider.getAttribute("aria-valuemax")).toBe("100");
149
+ });
150
+
151
+ it("stamps data-slot on the bar", () => {
152
+ render(<FullPlayer />);
153
+ expect(
154
+ document.querySelector('[data-slot="audio-player-bar"]'),
155
+ ).toBeTruthy();
156
+ });
157
+
158
+ it("responds to ArrowRight key to seek forward", async () => {
159
+ const user = userEvent.setup();
160
+ render(
161
+ <AudioPlayerBase src="test.mp3">
162
+ <ContextInspector />
163
+ <AudioPlayerProgress />
164
+ </AudioPlayerBase>,
165
+ );
166
+ const slider = screen.getByRole("slider", { name: "Audio progress" });
167
+ await user.type(slider, "{ArrowRight}");
168
+ // jsdom does not fire timeupdate automatically — dispatch it so React state updates.
169
+ const audio = document.querySelector("audio") as HTMLAudioElement;
170
+ act(() => {
171
+ audio.dispatchEvent(new Event("timeupdate"));
172
+ });
173
+ // seekByPercent is guarded — duration is 120 (finite > 0); progress goes 0 → 1%
174
+ expect(slider.getAttribute("aria-valuenow")).toBe("1");
175
+ });
176
+
177
+ it("responds to ArrowLeft key", async () => {
178
+ const user = userEvent.setup();
179
+ render(
180
+ <AudioPlayerBase src="test.mp3">
181
+ <AudioPlayerProgress />
182
+ </AudioPlayerBase>,
183
+ );
184
+ const slider = screen.getByRole("slider", { name: "Audio progress" });
185
+ // at 0%, ArrowLeft clamps to 0 — valuenow stays 0
186
+ await user.type(slider, "{ArrowLeft}");
187
+ expect(slider.getAttribute("aria-valuenow")).toBe("0");
188
+ });
189
+
190
+ it("responds to Home and End keys", async () => {
191
+ const user = userEvent.setup();
192
+ render(
193
+ <AudioPlayerBase src="test.mp3">
194
+ <AudioPlayerProgress />
195
+ </AudioPlayerBase>,
196
+ );
197
+ const audio = document.querySelector("audio") as HTMLAudioElement;
198
+ const slider = screen.getByRole("slider", { name: "Audio progress" });
199
+ await user.type(slider, "{End}");
200
+ act(() => {
201
+ audio.dispatchEvent(new Event("timeupdate"));
202
+ });
203
+ expect(slider.getAttribute("aria-valuenow")).toBe("100");
204
+ await user.type(slider, "{Home}");
205
+ act(() => {
206
+ audio.dispatchEvent(new Event("timeupdate"));
207
+ });
208
+ expect(slider.getAttribute("aria-valuenow")).toBe("0");
209
+ });
210
+ });
211
+
212
+ describe("AudioPlayerTime", () => {
213
+ it("stamps data-slot", () => {
214
+ render(<FullPlayer />);
215
+ expect(
216
+ document.querySelector('[data-slot="audio-player-time"]'),
217
+ ).toBeTruthy();
218
+ });
219
+
220
+ it("renders 0:00 / 0:00 before metadata loads", () => {
221
+ Object.defineProperty(window.HTMLMediaElement.prototype, "duration", {
222
+ configurable: true,
223
+ get: () => NaN,
224
+ });
225
+ render(<FullPlayer />);
226
+ const timeEl = document.querySelector('[data-slot="audio-player-time"]')!;
227
+ expect(timeEl.textContent).toContain("0:00");
228
+ });
229
+
230
+ it("accepts a custom format function", () => {
231
+ render(
232
+ <AudioPlayerBase src="test.mp3">
233
+ <AudioPlayerTime format={() => "custom"} />
234
+ </AudioPlayerBase>,
235
+ );
236
+ const timeEl = document.querySelector('[data-slot="audio-player-time"]')!;
237
+ expect(timeEl.textContent).toContain("custom");
238
+ });
239
+ });
240
+
241
+ describe("AudioPlayerVolume", () => {
242
+ it("stamps data-slot", () => {
243
+ render(<FullPlayer />);
244
+ expect(
245
+ document.querySelector('[data-slot="audio-player-volume"]'),
246
+ ).toBeTruthy();
247
+ });
248
+
249
+ it("has a slider with correct aria attributes", () => {
250
+ render(<FullPlayer />);
251
+ const slider = screen.getByRole("slider", { name: "Volume" });
252
+ expect(slider.getAttribute("aria-valuenow")).toBe("100");
253
+ expect(slider.getAttribute("aria-valuemin")).toBe("0");
254
+ expect(slider.getAttribute("aria-valuemax")).toBe("100");
255
+ });
256
+
257
+ it("has a mute toggle button", () => {
258
+ render(<FullPlayer />);
259
+ expect(screen.getByRole("button", { name: /mute/i })).toBeTruthy();
260
+ });
261
+
262
+ it("responds to ArrowLeft key to decrease volume", async () => {
263
+ const user = userEvent.setup();
264
+ render(
265
+ <AudioPlayerBase src="test.mp3">
266
+ <AudioPlayerVolume />
267
+ </AudioPlayerBase>,
268
+ );
269
+ const slider = screen.getByRole("slider", { name: "Volume" });
270
+ await user.type(slider, "{ArrowLeft}");
271
+ expect(Number(slider.getAttribute("aria-valuenow"))).toBeLessThan(100);
272
+ });
273
+
274
+ it("responds to ArrowRight key to increase volume", async () => {
275
+ const user = userEvent.setup();
276
+ render(
277
+ <AudioPlayerBase src="test.mp3">
278
+ <AudioPlayerVolume />
279
+ </AudioPlayerBase>,
280
+ );
281
+ const slider = screen.getByRole("slider", { name: "Volume" });
282
+ await user.type(slider, "{Home}");
283
+ expect(slider.getAttribute("aria-valuenow")).toBe("0");
284
+ await user.type(slider, "{ArrowRight}");
285
+ expect(slider.getAttribute("aria-valuenow")).toBe("5");
286
+ });
287
+
288
+ it("responds to Home and End keys", async () => {
289
+ const user = userEvent.setup();
290
+ render(
291
+ <AudioPlayerBase src="test.mp3">
292
+ <AudioPlayerVolume />
293
+ </AudioPlayerBase>,
294
+ );
295
+ const slider = screen.getByRole("slider", { name: "Volume" });
296
+ await user.type(slider, "{Home}");
297
+ expect(slider.getAttribute("aria-valuenow")).toBe("0");
298
+ await user.type(slider, "{End}");
299
+ expect(slider.getAttribute("aria-valuenow")).toBe("100");
300
+ });
301
+
302
+ it("shows mute icon after clicking the mute button", async () => {
303
+ const user = userEvent.setup();
304
+ render(
305
+ <AudioPlayerBase src="test.mp3">
306
+ <AudioPlayerVolume />
307
+ </AudioPlayerBase>,
308
+ );
309
+ const muteBtn = screen.getByRole("button", { name: /mute/i });
310
+ await user.click(muteBtn);
311
+ expect(screen.getByRole("button", { name: /unmute/i })).toBeTruthy();
312
+ });
313
+ });
314
+
315
+ describe("context controls via ContextInspector", () => {
316
+ it("play calls HTMLMediaElement.play", async () => {
317
+ const user = userEvent.setup();
318
+ render(
319
+ <AudioPlayerBase src="test.mp3">
320
+ <ContextInspector />
321
+ </AudioPlayerBase>,
322
+ );
323
+ await user.click(screen.getByTestId("play"));
324
+ expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled();
325
+ });
326
+
327
+ it("pause calls HTMLMediaElement.pause", async () => {
328
+ const user = userEvent.setup();
329
+ render(
330
+ <AudioPlayerBase src="test.mp3">
331
+ <ContextInspector />
332
+ </AudioPlayerBase>,
333
+ );
334
+ await user.click(screen.getByTestId("pause"));
335
+ expect(window.HTMLMediaElement.prototype.pause).toHaveBeenCalled();
336
+ });
337
+
338
+ it("toggle calls play when paused", async () => {
339
+ const user = userEvent.setup();
340
+ render(
341
+ <AudioPlayerBase src="test.mp3">
342
+ <ContextInspector />
343
+ </AudioPlayerBase>,
344
+ );
345
+ await user.click(screen.getByTestId("toggle"));
346
+ expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled();
347
+ });
348
+
349
+ it("reset calls pause and sets currentTime to 0", async () => {
350
+ const user = userEvent.setup();
351
+ render(
352
+ <AudioPlayerBase src="test.mp3">
353
+ <ContextInspector />
354
+ </AudioPlayerBase>,
355
+ );
356
+ await user.click(screen.getByTestId("reset"));
357
+ expect(window.HTMLMediaElement.prototype.pause).toHaveBeenCalled();
358
+ expect(screen.getByTestId("currentTime").textContent).toBe("0");
359
+ });
360
+
361
+ it("seek sets audio.currentTime", async () => {
362
+ const user = userEvent.setup();
363
+ render(
364
+ <AudioPlayerBase src="test.mp3">
365
+ <ContextInspector />
366
+ </AudioPlayerBase>,
367
+ );
368
+ await user.click(screen.getByTestId("seek"));
369
+ // Seek to 30s out of 120s — audio.currentTime is set but React state
370
+ // only updates via the timeupdate event (not fired in jsdom); we just
371
+ // assert no throw occurs and the audio element was targeted.
372
+ const audio = document.querySelector("audio") as HTMLAudioElement;
373
+ expect(audio.currentTime).toBe(30);
374
+ });
375
+
376
+ it("seekByPercent sets audio.currentTime proportionally", async () => {
377
+ const user = userEvent.setup();
378
+ render(
379
+ <AudioPlayerBase src="test.mp3">
380
+ <ContextInspector />
381
+ </AudioPlayerBase>,
382
+ );
383
+ await user.click(screen.getByTestId("seekPct"));
384
+ const audio = document.querySelector("audio") as HTMLAudioElement;
385
+ expect(audio.currentTime).toBe(60); // 50% of 120
386
+ });
387
+
388
+ it("setVolume updates audio.volume", async () => {
389
+ const user = userEvent.setup();
390
+ render(
391
+ <AudioPlayerBase src="test.mp3">
392
+ <ContextInspector />
393
+ </AudioPlayerBase>,
394
+ );
395
+ await user.click(screen.getByTestId("setVol"));
396
+ const audio = document.querySelector("audio") as HTMLAudioElement;
397
+ expect(audio.volume).toBe(0.5);
398
+ });
399
+
400
+ it("toggleMute flips audio.muted", async () => {
401
+ const user = userEvent.setup();
402
+ render(
403
+ <AudioPlayerBase src="test.mp3">
404
+ <ContextInspector />
405
+ </AudioPlayerBase>,
406
+ );
407
+ const audio = document.querySelector("audio") as HTMLAudioElement;
408
+ expect(audio.muted).toBe(false);
409
+ await user.click(screen.getByTestId("toggleMute"));
410
+ expect(audio.muted).toBe(true);
411
+ });
412
+
413
+ it("onPlay / onPause callbacks fire via simulated audio events", async () => {
414
+ const onPlay = vi.fn();
415
+ const onPause = vi.fn();
416
+ render(
417
+ <AudioPlayerBase src="test.mp3" onPlay={onPlay} onPause={onPause}>
418
+ <ContextInspector />
419
+ </AudioPlayerBase>,
420
+ );
421
+ const audio = document.querySelector("audio") as HTMLAudioElement;
422
+ act(() => {
423
+ audio.dispatchEvent(new Event("play"));
424
+ });
425
+ expect(onPlay).toHaveBeenCalledTimes(1);
426
+ act(() => {
427
+ audio.dispatchEvent(new Event("pause"));
428
+ });
429
+ expect(onPause).toHaveBeenCalledTimes(1);
430
+ });
431
+
432
+ it("onEnded callback fires and isPlaying becomes false", () => {
433
+ const onEnded = vi.fn();
434
+ render(
435
+ <AudioPlayerBase src="test.mp3" onEnded={onEnded}>
436
+ <ContextInspector />
437
+ </AudioPlayerBase>,
438
+ );
439
+ const audio = document.querySelector("audio") as HTMLAudioElement;
440
+ act(() => {
441
+ audio.dispatchEvent(new Event("ended"));
442
+ });
443
+ expect(onEnded).toHaveBeenCalledTimes(1);
444
+ expect(screen.getByTestId("isPlaying").textContent).toBe("false");
445
+ });
446
+
447
+ it("durationchange updates duration state", () => {
448
+ render(
449
+ <AudioPlayerBase src="test.mp3">
450
+ <ContextInspector />
451
+ </AudioPlayerBase>,
452
+ );
453
+ const audio = document.querySelector("audio") as HTMLAudioElement;
454
+ act(() => {
455
+ audio.dispatchEvent(new Event("durationchange"));
456
+ });
457
+ expect(Number(screen.getByTestId("duration").textContent)).toBe(120);
458
+ });
459
+ });
460
+
461
+ describe("src change resets state", () => {
462
+ it("resets isPlaying and currentTime when src prop changes", async () => {
463
+ const { rerender } = render(
464
+ <AudioPlayerBase src="a.mp3">
465
+ <ContextInspector />
466
+ </AudioPlayerBase>,
467
+ );
468
+ // simulate playing
469
+ const audio = document.querySelector("audio") as HTMLAudioElement;
470
+ act(() => {
471
+ audio.dispatchEvent(new Event("play"));
472
+ });
473
+ expect(screen.getByTestId("isPlaying").textContent).toBe("true");
474
+
475
+ rerender(
476
+ <AudioPlayerBase src="b.mp3">
477
+ <ContextInspector />
478
+ </AudioPlayerBase>,
479
+ );
480
+ expect(screen.getByTestId("isPlaying").textContent).toBe("false");
481
+ expect(screen.getByTestId("currentTime").textContent).toBe("0");
482
+ expect(screen.getByTestId("duration").textContent).toBe("0");
483
+ });
484
+ });
485
+ });
@@ -0,0 +1,8 @@
1
+ import { AudioPlayerBase } from "./audio-player-base";
2
+ import type { AudioPlayerProps } from "./types";
3
+
4
+ export const AudioPlayer = (props: AudioPlayerProps) => {
5
+ return <AudioPlayerBase {...props} />;
6
+ };
7
+
8
+ AudioPlayer.displayName = "AudioPlayer";
@@ -0,0 +1,24 @@
1
+ "use client";
2
+
3
+ export { AudioPlayer } from "./audio-player";
4
+ export {
5
+ AudioPlayerBase,
6
+ AudioPlayerProgress,
7
+ AudioPlayerTime,
8
+ AudioPlayerVolume,
9
+ useAudioPlayer,
10
+ } from "./audio-player-base";
11
+ export type {
12
+ AudioPlayerProps,
13
+ AudioPlayerProgressProps,
14
+ AudioPlayerTimeProps,
15
+ AudioPlayerVolumeProps,
16
+ AudioPlayerCtx,
17
+ AudioPlayerVariantProps,
18
+ } from "./types";
19
+ export {
20
+ audioPlayerVariants,
21
+ audioPlayerTrackVariants,
22
+ audioPlayerBarVariants,
23
+ audioPlayerTimeVariants,
24
+ } from "./variants";
@@ -0,0 +1,57 @@
1
+ import type { VariantProps } from "class-variance-authority";
2
+ import type { ComponentPropsWithRef, ReactNode } from "react";
3
+
4
+ import type { audioPlayerVariants } from "./variants";
5
+
6
+ export type AudioPlayerVariantProps = VariantProps<typeof audioPlayerVariants>;
7
+
8
+ export type AudioPlayerProps = AudioPlayerVariantProps &
9
+ Omit<ComponentPropsWithRef<"div">, "children"> & {
10
+ src: string;
11
+ children?: ReactNode;
12
+ autoPlay?: boolean;
13
+ loop?: boolean;
14
+ onEnded?: () => void;
15
+ onPlay?: () => void;
16
+ onPause?: () => void;
17
+ onTimeUpdate?: (currentTime: number, duration: number) => void;
18
+ };
19
+
20
+ export type AudioPlayerProgressProps = Omit<
21
+ ComponentPropsWithRef<"div">,
22
+ "children"
23
+ > & {
24
+ className?: string;
25
+ };
26
+
27
+ export type AudioPlayerTimeProps = {
28
+ className?: string;
29
+ format?: (seconds: number) => string;
30
+ };
31
+
32
+ export type AudioPlayerVolumeProps = Omit<
33
+ ComponentPropsWithRef<"div">,
34
+ "children"
35
+ > & {
36
+ className?: string;
37
+ };
38
+
39
+ export type AudioPlayerCtx = {
40
+ isPlaying: boolean;
41
+ currentTime: number;
42
+ duration: number;
43
+ progress: number;
44
+ volume: number;
45
+ muted: boolean;
46
+ play: () => void;
47
+ pause: () => void;
48
+ toggle: () => void;
49
+ reset: () => void;
50
+ seek: (seconds: number) => void;
51
+ seekByPercent: (percent: number) => void;
52
+ setVolume: (volume: number) => void;
53
+ toggleMute: () => void;
54
+ size: NonNullable<AudioPlayerVariantProps["size"]>;
55
+ shape: NonNullable<AudioPlayerVariantProps["shape"]>;
56
+ appearance: NonNullable<AudioPlayerVariantProps["appearance"]>;
57
+ };
@@ -0,0 +1,43 @@
1
+ import { cva } from "class-variance-authority";
2
+
3
+ import {
4
+ zuiAudioPlayerAppearances,
5
+ zuiAudioPlayerBarBase,
6
+ zuiAudioPlayerBase,
7
+ zuiAudioPlayerShapes,
8
+ zuiAudioPlayerSizes,
9
+ zuiAudioPlayerTimeBase,
10
+ zuiAudioPlayerTrackBase,
11
+ zuiAudioPlayerTrackSizes,
12
+ } from "../../design-system/audio-player";
13
+
14
+ export const audioPlayerVariants = cva(
15
+ [...zuiAudioPlayerBase, "flex flex-col"],
16
+ {
17
+ variants: {
18
+ appearance: zuiAudioPlayerAppearances,
19
+ size: zuiAudioPlayerSizes,
20
+ shape: zuiAudioPlayerShapes,
21
+ },
22
+ defaultVariants: {
23
+ appearance: "default",
24
+ size: "md",
25
+ shape: "rounded",
26
+ },
27
+ },
28
+ );
29
+
30
+ export const audioPlayerTrackVariants = cva([...zuiAudioPlayerTrackBase], {
31
+ variants: {
32
+ size: zuiAudioPlayerTrackSizes,
33
+ shape: zuiAudioPlayerShapes,
34
+ },
35
+ defaultVariants: {
36
+ size: "md",
37
+ shape: "rounded",
38
+ },
39
+ });
40
+
41
+ export const audioPlayerBarVariants = cva(zuiAudioPlayerBarBase);
42
+
43
+ export const audioPlayerTimeVariants = cva(zuiAudioPlayerTimeBase);