@nationaldesignstudio/react 0.0.7 → 0.0.9

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 (170) hide show
  1. package/README.md +0 -4
  2. package/dist/assets/fonts/PPNeueMontreal-Variable.woff2 +0 -0
  3. package/dist/assets/react.svg +1 -0
  4. package/dist/components/atoms/accordion/accordion.d.ts +50 -0
  5. package/dist/components/{button → atoms/button}/button.d.ts +5 -4
  6. package/dist/components/{button → atoms/button}/icon-button.d.ts +20 -0
  7. package/dist/components/atoms/pager-control/pager-control.d.ts +62 -0
  8. package/dist/components/{card → organisms/card}/card.d.ts +6 -2
  9. package/dist/components/{navbar → organisms/navbar}/navbar.d.ts +28 -1
  10. package/dist/components/sections/banner/banner.d.ts +64 -0
  11. package/dist/components/sections/card-grid/card-grid.d.ts +53 -0
  12. package/dist/components/sections/faq-section/faq-section.d.ts +44 -0
  13. package/dist/components/sections/hero/hero.d.ts +73 -0
  14. package/dist/components/sections/river/river.d.ts +63 -0
  15. package/dist/components/sections/tout/tout.d.ts +73 -0
  16. package/dist/components/sections/two-column-section/two-column-section.d.ts +58 -0
  17. package/dist/index.d.ts +28 -12
  18. package/dist/index.js +6108 -953
  19. package/dist/index.js.map +1 -1
  20. package/dist/tailwind.css +23 -0
  21. package/dist/tokens.css +2009 -103
  22. package/package.json +23 -5
  23. package/src/App.css +0 -0
  24. package/src/App.tsx +7 -0
  25. package/src/assets/fonts/PPNeueMontreal-Variable.woff2 +0 -0
  26. package/src/assets/react.svg +1 -0
  27. package/src/components/atoms/accordion/accordion.stories.tsx +228 -0
  28. package/src/components/atoms/accordion/accordion.tsx +137 -0
  29. package/src/components/atoms/accordion/index.ts +6 -0
  30. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-chromium-darwin.png +0 -0
  31. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-chromium-linux.png +0 -0
  32. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-chromium-darwin.png +0 -0
  33. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-chromium-linux.png +0 -0
  34. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-quiet-chromium-darwin.png +0 -0
  35. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-quiet-chromium-linux.png +0 -0
  36. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-disabled-chromium-darwin.png +0 -0
  37. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-disabled-chromium-linux.png +0 -0
  38. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-chromium-darwin.png +0 -0
  39. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-chromium-linux.png +0 -0
  40. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-chromium-darwin.png +0 -0
  41. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-chromium-linux.png +0 -0
  42. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-quiet-chromium-darwin.png +0 -0
  43. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-quiet-chromium-linux.png +0 -0
  44. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-large-chromium-darwin.png +0 -0
  45. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-large-chromium-linux.png +0 -0
  46. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-medium-chromium-darwin.png +0 -0
  47. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-medium-chromium-linux.png +0 -0
  48. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-small-chromium-darwin.png +0 -0
  49. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-small-chromium-linux.png +0 -0
  50. package/src/components/atoms/button/button.stories.tsx +84 -0
  51. package/src/components/atoms/button/button.test.tsx +141 -0
  52. package/src/components/atoms/button/button.tsx +95 -0
  53. package/src/components/atoms/button/button.visual.test.tsx +102 -0
  54. package/src/components/atoms/button/icon-button.stories.tsx +166 -0
  55. package/src/components/atoms/button/icon-button.tsx +125 -0
  56. package/src/components/atoms/button/index.ts +6 -0
  57. package/src/components/atoms/pager-control/index.ts +5 -0
  58. package/src/components/atoms/pager-control/pager-control.stories.tsx +209 -0
  59. package/src/components/atoms/pager-control/pager-control.test.tsx +149 -0
  60. package/src/components/atoms/pager-control/pager-control.tsx +328 -0
  61. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-default-vertical-chromium-darwin.png +0 -0
  62. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-default-vertical-chromium-linux.png +0 -0
  63. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-horizontal-chromium-darwin.png +0 -0
  64. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-horizontal-chromium-linux.png +0 -0
  65. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-minimal-chromium-darwin.png +0 -0
  66. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-minimal-chromium-linux.png +0 -0
  67. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-actions-chromium-darwin.png +0 -0
  68. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-actions-chromium-linux.png +0 -0
  69. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-eyebrow-chromium-darwin.png +0 -0
  70. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-eyebrow-chromium-linux.png +0 -0
  71. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-image-chromium-darwin.png +0 -0
  72. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-image-chromium-linux.png +0 -0
  73. package/src/components/organisms/card/card.stories.tsx +293 -0
  74. package/src/components/organisms/card/card.test.tsx +245 -0
  75. package/src/components/organisms/card/card.tsx +227 -0
  76. package/src/components/organisms/card/card.visual.test.tsx +197 -0
  77. package/src/components/organisms/card/index.ts +19 -0
  78. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-active-link-chromium-darwin.png +0 -0
  79. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-active-link-chromium-linux.png +0 -0
  80. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-brand-only-chromium-darwin.png +0 -0
  81. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-brand-only-chromium-linux.png +0 -0
  82. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-default-chromium-darwin.png +0 -0
  83. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-default-chromium-linux.png +0 -0
  84. package/src/components/organisms/navbar/index.ts +18 -0
  85. package/src/components/organisms/navbar/navbar.stories.tsx +313 -0
  86. package/src/components/organisms/navbar/navbar.test.tsx +190 -0
  87. package/src/components/organisms/navbar/navbar.tsx +317 -0
  88. package/src/components/organisms/navbar/navbar.visual.test.tsx +85 -0
  89. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-icon-chromium-darwin.png +0 -0
  90. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-icon-chromium-linux.png +0 -0
  91. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-text-chromium-darwin.png +0 -0
  92. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-text-chromium-linux.png +0 -0
  93. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-default-chromium-darwin.png +0 -0
  94. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-default-chromium-linux.png +0 -0
  95. package/src/components/organisms/us-gov-banner/index.ts +1 -0
  96. package/src/components/organisms/us-gov-banner/us-gov-banner.stories.tsx +35 -0
  97. package/src/components/organisms/us-gov-banner/us-gov-banner.test.tsx +107 -0
  98. package/src/components/organisms/us-gov-banner/us-gov-banner.tsx +73 -0
  99. package/src/components/organisms/us-gov-banner/us-gov-banner.visual.test.tsx +46 -0
  100. package/src/components/sections/banner/banner.stories.tsx +150 -0
  101. package/src/components/sections/banner/banner.test.tsx +185 -0
  102. package/src/components/sections/banner/banner.tsx +130 -0
  103. package/src/components/sections/banner/index.ts +2 -0
  104. package/src/components/sections/card-grid/card-grid.stories.tsx +351 -0
  105. package/src/components/sections/card-grid/card-grid.tsx +118 -0
  106. package/src/components/sections/card-grid/index.ts +1 -0
  107. package/src/components/sections/faq-section/faq-section.tsx +77 -0
  108. package/src/components/sections/faq-section/index.ts +2 -0
  109. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-desktop-chromium-darwin.png +0 -0
  110. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-desktop-chromium-linux.png +0 -0
  111. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-mobile-chromium-darwin.png +0 -0
  112. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-mobile-chromium-linux.png +0 -0
  113. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-tablet-chromium-darwin.png +0 -0
  114. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-tablet-chromium-linux.png +0 -0
  115. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-desktop-chromium-darwin.png +0 -0
  116. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-desktop-chromium-linux.png +0 -0
  117. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-mobile-chromium-darwin.png +0 -0
  118. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-mobile-chromium-linux.png +0 -0
  119. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-tablet-chromium-darwin.png +0 -0
  120. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-tablet-chromium-linux.png +0 -0
  121. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-desktop-chromium-darwin.png +0 -0
  122. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-desktop-chromium-linux.png +0 -0
  123. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-mobile-chromium-darwin.png +0 -0
  124. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-mobile-chromium-linux.png +0 -0
  125. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-tablet-chromium-darwin.png +0 -0
  126. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-tablet-chromium-linux.png +0 -0
  127. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-custom-class-chromium-darwin.png +0 -0
  128. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-custom-class-chromium-linux.png +0 -0
  129. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-default-chromium-linux.png +0 -0
  130. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-long-title-chromium-darwin.png +0 -0
  131. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-long-title-chromium-linux.png +0 -0
  132. package/src/components/sections/hero/hero.stories.tsx +145 -0
  133. package/src/components/sections/hero/hero.test.tsx +135 -0
  134. package/src/components/sections/hero/hero.tsx +191 -0
  135. package/src/components/sections/hero/hero.visual.test.tsx +140 -0
  136. package/src/components/sections/hero/index.ts +1 -0
  137. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-h3-heading-chromium-darwin.png +0 -0
  138. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-h3-heading-chromium-linux.png +0 -0
  139. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-paragraphs-chromium-darwin.png +0 -0
  140. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-paragraphs-chromium-linux.png +0 -0
  141. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-sections-chromium-darwin.png +0 -0
  142. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-sections-chromium-linux.png +0 -0
  143. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-single-section-chromium-darwin.png +0 -0
  144. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-single-section-chromium-linux.png +0 -0
  145. package/src/components/sections/prose/index.ts +6 -0
  146. package/src/components/sections/prose/prose.stories.tsx +144 -0
  147. package/src/components/sections/prose/prose.test.tsx +178 -0
  148. package/src/components/sections/prose/prose.tsx +88 -0
  149. package/src/components/sections/prose/prose.visual.test.tsx +105 -0
  150. package/src/components/sections/river/index.ts +1 -0
  151. package/src/components/sections/river/river.stories.tsx +237 -0
  152. package/src/components/sections/river/river.test.tsx +268 -0
  153. package/src/components/sections/river/river.tsx +175 -0
  154. package/src/components/sections/tout/index.ts +1 -0
  155. package/src/components/sections/tout/tout.stories.tsx +154 -0
  156. package/src/components/sections/tout/tout.test.tsx +242 -0
  157. package/src/components/sections/tout/tout.tsx +206 -0
  158. package/src/components/sections/two-column-section/index.ts +5 -0
  159. package/src/components/sections/two-column-section/two-column-section.stories.tsx +285 -0
  160. package/src/components/sections/two-column-section/two-column-section.tsx +152 -0
  161. package/src/index.ts +98 -0
  162. package/src/lib/utils.ts +6 -0
  163. package/src/main.tsx +13 -0
  164. package/src/stories/Introduction.mdx +114 -0
  165. package/src/stories/TokenShowcase.stories.tsx +92 -0
  166. package/src/stories/TokenShowcase.tsx +1352 -0
  167. package/src/styles.css +11 -0
  168. package/dist/components/hero/hero.d.ts +0 -17
  169. /package/dist/components/{us-gov-banner → organisms/us-gov-banner}/us-gov-banner.d.ts +0 -0
  170. /package/dist/components/{prose → sections/prose}/prose.d.ts +0 -0
@@ -0,0 +1,209 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import * as React from "react";
3
+ import { PagerControl } from ".";
4
+
5
+ const meta: Meta<typeof PagerControl> = {
6
+ title: "Atoms/PagerControl",
7
+ } as Meta<typeof PagerControl>;
8
+
9
+ export default meta;
10
+ type Story = StoryObj<typeof PagerControl>;
11
+
12
+ export const Playground: Story = {
13
+ render: (args) => <PagerControl {...args} />,
14
+ };
15
+ Playground.argTypes = {
16
+ count: {
17
+ control: {
18
+ type: "number",
19
+ min: 1,
20
+ max: 10,
21
+ },
22
+ },
23
+ activeIndex: {
24
+ control: {
25
+ type: "number",
26
+ min: 0,
27
+ },
28
+ },
29
+ duration: {
30
+ control: {
31
+ type: "number",
32
+ min: 0,
33
+ max: 10000,
34
+ step: 500,
35
+ },
36
+ },
37
+ autoPlay: {
38
+ control: {
39
+ type: "boolean",
40
+ },
41
+ },
42
+ pauseOnHover: {
43
+ control: {
44
+ type: "boolean",
45
+ },
46
+ },
47
+ loop: {
48
+ control: {
49
+ type: "boolean",
50
+ },
51
+ },
52
+ size: {
53
+ control: {
54
+ type: "radio",
55
+ },
56
+ options: ["sm", "default", "lg"],
57
+ },
58
+ variant: {
59
+ control: {
60
+ type: "radio",
61
+ },
62
+ options: ["charcoal", "ivory"],
63
+ },
64
+ };
65
+ Playground.args = {
66
+ count: 4,
67
+ duration: 5000,
68
+ autoPlay: true,
69
+ pauseOnHover: true,
70
+ loop: true,
71
+ size: "default",
72
+ variant: "charcoal",
73
+ };
74
+
75
+ // =============================================================================
76
+ // Variants
77
+ // =============================================================================
78
+
79
+ export const Charcoal = () => <PagerControl count={4} variant="charcoal" />;
80
+
81
+ export const Ivory = () => <PagerControl count={4} variant="ivory" />;
82
+
83
+ // =============================================================================
84
+ // Sizes
85
+ // =============================================================================
86
+
87
+ export const Small = () => <PagerControl count={4} size="sm" />;
88
+
89
+ export const Default = () => <PagerControl count={4} size="default" />;
90
+
91
+ export const Large = () => <PagerControl count={4} size="lg" />;
92
+
93
+ // =============================================================================
94
+ // Counts
95
+ // =============================================================================
96
+
97
+ export const TwoPages = () => <PagerControl count={2} />;
98
+
99
+ export const FivePages = () => <PagerControl count={5} />;
100
+
101
+ export const EightPages = () => <PagerControl count={8} />;
102
+
103
+ // =============================================================================
104
+ // Durations
105
+ // =============================================================================
106
+
107
+ export const FastDuration = () => <PagerControl count={4} duration={2000} />;
108
+
109
+ export const SlowDuration = () => <PagerControl count={4} duration={8000} />;
110
+
111
+ // =============================================================================
112
+ // Controls
113
+ // =============================================================================
114
+
115
+ export const NoAutoPlay = () => <PagerControl count={4} autoPlay={false} />;
116
+
117
+ export const NoLoop = () => (
118
+ <PagerControl count={4} loop={false} duration={3000} />
119
+ );
120
+
121
+ export const NoPauseOnHover = () => (
122
+ <PagerControl count={4} pauseOnHover={false} />
123
+ );
124
+
125
+ // =============================================================================
126
+ // Controlled
127
+ // =============================================================================
128
+
129
+ export const Controlled = () => {
130
+ const [activeIndex, setActiveIndex] = React.useState(0);
131
+
132
+ return (
133
+ <div className="flex flex-col items-center gap-spacing-24">
134
+ <PagerControl
135
+ count={4}
136
+ activeIndex={activeIndex}
137
+ onChange={setActiveIndex}
138
+ autoPlay={false}
139
+ />
140
+ <div className="flex gap-spacing-10">
141
+ <button
142
+ type="button"
143
+ onClick={() => setActiveIndex((prev) => Math.max(0, prev - 1))}
144
+ className="rounded bg-gray-200 px-spacing-12 py-spacing-6"
145
+ >
146
+ Previous
147
+ </button>
148
+ <span className="px-spacing-12 py-spacing-6">
149
+ Page {activeIndex + 1} of 4
150
+ </span>
151
+ <button
152
+ type="button"
153
+ onClick={() => setActiveIndex((prev) => Math.min(3, prev + 1))}
154
+ className="rounded bg-gray-200 px-spacing-12 py-spacing-6"
155
+ >
156
+ Next
157
+ </button>
158
+ </div>
159
+ </div>
160
+ );
161
+ };
162
+
163
+ // =============================================================================
164
+ // Integration Example
165
+ // =============================================================================
166
+
167
+ export const WithCarousel = () => {
168
+ const [activeIndex, setActiveIndex] = React.useState(0);
169
+ const slides = [
170
+ { id: 1, color: "bg-blue-100", title: "Slide 1" },
171
+ { id: 2, color: "bg-green-100", title: "Slide 2" },
172
+ { id: 3, color: "bg-yellow-100", title: "Slide 3" },
173
+ { id: 4, color: "bg-purple-100", title: "Slide 4" },
174
+ ];
175
+
176
+ return (
177
+ <div className="flex w-[400px] flex-col gap-spacing-16">
178
+ <div className="relative h-[200px] overflow-hidden rounded-[12px]">
179
+ {slides.map((slide, index) => (
180
+ <div
181
+ key={slide.id}
182
+ className={cn(
183
+ "absolute inset-0 flex items-center justify-center transition-transform duration-500",
184
+ slide.color,
185
+ )}
186
+ style={{
187
+ transform: `translateX(${(index - activeIndex) * 100}%)`,
188
+ }}
189
+ >
190
+ <span className="text-xl font-semibold">{slide.title}</span>
191
+ </div>
192
+ ))}
193
+ </div>
194
+ <div className="flex justify-center">
195
+ <PagerControl
196
+ count={slides.length}
197
+ activeIndex={activeIndex}
198
+ onChange={setActiveIndex}
199
+ duration={5000}
200
+ />
201
+ </div>
202
+ </div>
203
+ );
204
+ };
205
+
206
+ // Helper for carousel story
207
+ function cn(...classes: (string | boolean | undefined)[]) {
208
+ return classes.filter(Boolean).join(" ");
209
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { page, userEvent } from "vitest/browser";
3
+ import { render } from "vitest-browser-react";
4
+ import { PagerControl } from "./pager-control";
5
+
6
+ describe("PagerControl", () => {
7
+ describe("Accessibility", () => {
8
+ test("has correct tablist role", async () => {
9
+ render(<PagerControl count={4} autoPlay={false} />);
10
+ await expect
11
+ .element(page.getByRole("tablist", { name: "Page indicators" }))
12
+ .toBeInTheDocument();
13
+ });
14
+
15
+ test("renders correct number of tab buttons", async () => {
16
+ render(<PagerControl count={4} autoPlay={false} />);
17
+ const tabs = page.getByRole("tab").all();
18
+ expect(await tabs).toHaveLength(4);
19
+ });
20
+
21
+ test("active tab has aria-selected true", async () => {
22
+ render(<PagerControl count={4} activeIndex={1} autoPlay={false} />);
23
+ const tabs = page.getByRole("tab").all();
24
+ const tabElements = await tabs;
25
+ await expect
26
+ .element(tabElements[0])
27
+ .toHaveAttribute("aria-selected", "false");
28
+ await expect
29
+ .element(tabElements[1])
30
+ .toHaveAttribute("aria-selected", "true");
31
+ });
32
+
33
+ test("tabs have accessible labels", async () => {
34
+ render(<PagerControl count={3} activeIndex={0} autoPlay={false} />);
35
+ await expect
36
+ .element(page.getByRole("tab", { name: "Page 1 of 3, current" }))
37
+ .toBeInTheDocument();
38
+ await expect
39
+ .element(page.getByRole("tab", { name: "Go to page 2 of 3" }))
40
+ .toBeInTheDocument();
41
+ });
42
+
43
+ test("is focusable via keyboard", async () => {
44
+ render(<PagerControl count={4} autoPlay={false} />);
45
+ await userEvent.keyboard("{Tab}");
46
+ const firstTab = page.getByRole("tab").all();
47
+ await expect.element((await firstTab)[0]).toHaveFocus();
48
+ });
49
+ });
50
+
51
+ describe("Interactions", () => {
52
+ test("calls onChange when dot is clicked", async () => {
53
+ const handleChange = vi.fn();
54
+ render(
55
+ <PagerControl
56
+ count={4}
57
+ activeIndex={0}
58
+ onChange={handleChange}
59
+ autoPlay={false}
60
+ />,
61
+ );
62
+ await page.getByRole("tab", { name: "Go to page 3 of 4" }).click();
63
+ expect(handleChange).toHaveBeenCalledWith(2);
64
+ });
65
+
66
+ test("clicking active dot calls onChange with same index", async () => {
67
+ const handleChange = vi.fn();
68
+ render(
69
+ <PagerControl
70
+ count={4}
71
+ activeIndex={1}
72
+ onChange={handleChange}
73
+ autoPlay={false}
74
+ />,
75
+ );
76
+ await page.getByRole("tab", { name: "Page 2 of 4, current" }).click();
77
+ expect(handleChange).toHaveBeenCalledWith(1);
78
+ });
79
+
80
+ test("updates internal state when uncontrolled", async () => {
81
+ render(<PagerControl count={4} autoPlay={false} />);
82
+ // Initially first is active
83
+ await expect
84
+ .element(page.getByRole("tab", { name: "Page 1 of 4, current" }))
85
+ .toBeInTheDocument();
86
+
87
+ // Click third dot
88
+ await page.getByRole("tab", { name: "Go to page 3 of 4" }).click();
89
+
90
+ // Now third should be active
91
+ await expect
92
+ .element(page.getByRole("tab", { name: "Page 3 of 4, current" }))
93
+ .toBeInTheDocument();
94
+ });
95
+ });
96
+
97
+ describe("Variants", () => {
98
+ test("applies charcoal variant classes by default", async () => {
99
+ render(<PagerControl count={4} autoPlay={false} />);
100
+ const tabs = await page.getByRole("tab").all();
101
+ // Active dot track should have bg-alpha-black-30
102
+ await expect.element(tabs[0]).toHaveClass(/bg-alpha-black-30/);
103
+ // Inactive dots should have bg-alpha-black-30
104
+ await expect.element(tabs[1]).toHaveClass(/bg-alpha-black-30/);
105
+ });
106
+
107
+ test("applies ivory variant classes", async () => {
108
+ render(<PagerControl count={4} variant="ivory" autoPlay={false} />);
109
+ const tabs = await page.getByRole("tab").all();
110
+ // Active dot track should have bg-alpha-white-30
111
+ await expect.element(tabs[0]).toHaveClass(/bg-alpha-white-30/);
112
+ // Inactive dots should have bg-alpha-white-30
113
+ await expect.element(tabs[1]).toHaveClass(/bg-alpha-white-30/);
114
+ });
115
+
116
+ test("applies size classes correctly", async () => {
117
+ render(<PagerControl count={4} size="lg" autoPlay={false} />);
118
+ const tabs = await page.getByRole("tab").all();
119
+ // All dots should have lg height
120
+ await expect.element(tabs[0]).toHaveClass(/h-\[14px\]/);
121
+ await expect.element(tabs[1]).toHaveClass(/h-\[14px\]/);
122
+ });
123
+ });
124
+
125
+ describe("Count", () => {
126
+ test("renders correct number of dots for count=2", async () => {
127
+ render(<PagerControl count={2} autoPlay={false} />);
128
+ const tabs = await page.getByRole("tab").all();
129
+ expect(tabs).toHaveLength(2);
130
+ });
131
+
132
+ test("renders correct number of dots for count=8", async () => {
133
+ render(<PagerControl count={8} autoPlay={false} />);
134
+ const tabs = await page.getByRole("tab").all();
135
+ expect(tabs).toHaveLength(8);
136
+ });
137
+ });
138
+
139
+ describe("Active dot width", () => {
140
+ test("active dot is wider than inactive dots", async () => {
141
+ render(<PagerControl count={4} autoPlay={false} />);
142
+ const tabs = await page.getByRole("tab").all();
143
+ // Active dot (first) should have wider width class
144
+ await expect.element(tabs[0]).toHaveClass(/w-\[26px\]/);
145
+ // Inactive dots should have smaller width
146
+ await expect.element(tabs[1]).toHaveClass(/w-\[10px\]/);
147
+ });
148
+ });
149
+ });
@@ -0,0 +1,328 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import * as React from "react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const pagerControlVariants = cva("flex items-center", {
6
+ variants: {
7
+ size: {
8
+ sm: "gap-[1px]",
9
+ default: "gap-[2px]",
10
+ lg: "gap-[3px]",
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ size: "default",
15
+ },
16
+ });
17
+
18
+ const dotBaseVariants = cva(
19
+ "cursor-pointer rounded-full transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]",
20
+ {
21
+ variants: {
22
+ size: {
23
+ sm: "h-[6px]",
24
+ default: "h-[10px]",
25
+ lg: "h-[14px]",
26
+ },
27
+ variant: {
28
+ charcoal: "",
29
+ ivory: "",
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ size: "default",
34
+ variant: "charcoal",
35
+ },
36
+ },
37
+ );
38
+
39
+ export interface PagerControlProps
40
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">,
41
+ VariantProps<typeof pagerControlVariants>,
42
+ VariantProps<typeof dotBaseVariants> {
43
+ /**
44
+ * Total number of pages/items
45
+ */
46
+ count: number;
47
+ /**
48
+ * Current active page index (0-based)
49
+ */
50
+ activeIndex?: number;
51
+ /**
52
+ * Duration in milliseconds for each page before auto-advancing
53
+ * Set to 0 to disable auto-advance
54
+ * @default 5000
55
+ */
56
+ duration?: number;
57
+ /**
58
+ * Whether the pager should auto-advance
59
+ * @default true
60
+ */
61
+ autoPlay?: boolean;
62
+ /**
63
+ * Callback when the active page changes
64
+ */
65
+ onChange?: (index: number) => void;
66
+ /**
67
+ * Whether to pause auto-advance on hover
68
+ * @default true
69
+ */
70
+ pauseOnHover?: boolean;
71
+ /**
72
+ * Whether to loop back to the first page after the last
73
+ * @default true
74
+ */
75
+ loop?: boolean;
76
+ }
77
+
78
+ /**
79
+ * PagerControl component for indicating progress through a series of pages/slides.
80
+ *
81
+ * Features smooth width transitions when switching between dots and an animated
82
+ * progress fill on the active dot that shows time remaining before auto-advancing
83
+ * (similar to Apple's carousel indicators).
84
+ *
85
+ * Variants:
86
+ * - charcoal: Dark dots (for light backgrounds)
87
+ * - ivory: Light dots (for dark backgrounds)
88
+ *
89
+ * Sizes:
90
+ * - sm: Small dots (6px height)
91
+ * - default: Medium dots (10px height)
92
+ * - lg: Large dots (14px height)
93
+ */
94
+ const PagerControl = React.forwardRef<HTMLDivElement, PagerControlProps>(
95
+ (
96
+ {
97
+ className,
98
+ size,
99
+ variant,
100
+ count,
101
+ activeIndex: controlledIndex,
102
+ duration = 5000,
103
+ autoPlay = true,
104
+ onChange,
105
+ pauseOnHover = true,
106
+ loop = true,
107
+ ...props
108
+ },
109
+ ref,
110
+ ) => {
111
+ const [internalIndex, setInternalIndex] = React.useState(0);
112
+ const [isPaused, setIsPaused] = React.useState(false);
113
+ const [progress, setProgress] = React.useState(0);
114
+
115
+ // Use controlled index if provided, otherwise use internal state
116
+ const activeIndex =
117
+ controlledIndex !== undefined ? controlledIndex : internalIndex;
118
+ const isControlled = controlledIndex !== undefined;
119
+
120
+ const animationFrameRef = React.useRef<number | null>(null);
121
+ const startTimeRef = React.useRef<number | null>(null);
122
+ const pausedProgressRef = React.useRef<number>(0);
123
+
124
+ const goToNext = React.useCallback(() => {
125
+ const nextIndex = activeIndex + 1;
126
+ if (nextIndex >= count) {
127
+ if (loop) {
128
+ if (!isControlled) setInternalIndex(0);
129
+ onChange?.(0);
130
+ }
131
+ } else {
132
+ if (!isControlled) setInternalIndex(nextIndex);
133
+ onChange?.(nextIndex);
134
+ }
135
+ }, [activeIndex, count, loop, isControlled, onChange]);
136
+
137
+ const goToIndex = React.useCallback(
138
+ (index: number) => {
139
+ if (!isControlled) setInternalIndex(index);
140
+ onChange?.(index);
141
+ // Reset progress when manually changing
142
+ setProgress(0);
143
+ pausedProgressRef.current = 0;
144
+ startTimeRef.current = null;
145
+ },
146
+ [isControlled, onChange],
147
+ );
148
+
149
+ // Animation loop for smooth progress fill
150
+ React.useEffect(() => {
151
+ if (!autoPlay || duration <= 0 || isPaused) {
152
+ if (animationFrameRef.current) {
153
+ cancelAnimationFrame(animationFrameRef.current);
154
+ animationFrameRef.current = null;
155
+ }
156
+ return;
157
+ }
158
+
159
+ const animate = (timestamp: number) => {
160
+ if (startTimeRef.current === null) {
161
+ startTimeRef.current =
162
+ timestamp - (pausedProgressRef.current / 100) * duration;
163
+ }
164
+
165
+ const elapsed = timestamp - startTimeRef.current;
166
+ const newProgress = Math.min((elapsed / duration) * 100, 100);
167
+ setProgress(newProgress);
168
+
169
+ if (newProgress >= 100) {
170
+ goToNext();
171
+ // Reset for next cycle
172
+ setProgress(0);
173
+ pausedProgressRef.current = 0;
174
+ startTimeRef.current = null;
175
+ } else {
176
+ animationFrameRef.current = requestAnimationFrame(animate);
177
+ }
178
+ };
179
+
180
+ animationFrameRef.current = requestAnimationFrame(animate);
181
+
182
+ return () => {
183
+ if (animationFrameRef.current) {
184
+ cancelAnimationFrame(animationFrameRef.current);
185
+ }
186
+ };
187
+ }, [autoPlay, duration, isPaused, goToNext]);
188
+
189
+ // Handle pause/resume
190
+ const handleMouseEnter = React.useCallback(() => {
191
+ if (pauseOnHover) {
192
+ pausedProgressRef.current = progress;
193
+ startTimeRef.current = null;
194
+ setIsPaused(true);
195
+ }
196
+ }, [pauseOnHover, progress]);
197
+
198
+ const handleMouseLeave = React.useCallback(() => {
199
+ if (pauseOnHover) {
200
+ setIsPaused(false);
201
+ }
202
+ }, [pauseOnHover]);
203
+
204
+ // Reset progress when activeIndex changes externally (controlled mode)
205
+ React.useEffect(() => {
206
+ if (isControlled) {
207
+ setProgress(0);
208
+ pausedProgressRef.current = 0;
209
+ startTimeRef.current = null;
210
+ }
211
+ }, [isControlled]);
212
+
213
+ // Get dot dimensions based on size
214
+ const getDotWidth = (isActive: boolean) => {
215
+ if (isActive) {
216
+ switch (size) {
217
+ case "sm":
218
+ return "w-[16px]";
219
+ case "lg":
220
+ return "w-[36px]";
221
+ default:
222
+ return "w-[26px]";
223
+ }
224
+ }
225
+ switch (size) {
226
+ case "sm":
227
+ return "w-[6px]";
228
+ case "lg":
229
+ return "w-[14px]";
230
+ default:
231
+ return "w-[10px]";
232
+ }
233
+ };
234
+
235
+ // Get background classes for inactive dots
236
+ const getInactiveClasses = () => {
237
+ if (variant === "ivory") {
238
+ return "bg-alpha-white-30 hover:bg-alpha-white-60";
239
+ }
240
+ return "bg-alpha-black-30 hover:bg-alpha-black-60";
241
+ };
242
+
243
+ // Get background class for active dot (the track/background)
244
+ const getActiveTrackClass = () => {
245
+ if (variant === "ivory") {
246
+ return "bg-alpha-white-30";
247
+ }
248
+ return "bg-alpha-black-30";
249
+ };
250
+
251
+ // Get fill color for the progress indicator
252
+ const getProgressFillClass = () => {
253
+ if (variant === "ivory") {
254
+ return "bg-gray-50";
255
+ }
256
+ return "bg-gray-1200";
257
+ };
258
+
259
+ return (
260
+ <div
261
+ ref={ref}
262
+ role="tablist"
263
+ aria-label="Page indicators"
264
+ className={cn(pagerControlVariants({ size, className }))}
265
+ onMouseEnter={handleMouseEnter}
266
+ onMouseLeave={handleMouseLeave}
267
+ {...props}
268
+ >
269
+ {Array.from({ length: count }, (_, index) => {
270
+ const isActive = index === activeIndex;
271
+
272
+ if (isActive) {
273
+ // Active dot with progress fill
274
+ return (
275
+ <button
276
+ // biome-ignore lint/suspicious/noArrayIndexKey: Pagination dots have fixed order based on count
277
+ key={index}
278
+ type="button"
279
+ role="tab"
280
+ aria-selected={true}
281
+ aria-label={`Page ${index + 1} of ${count}, current`}
282
+ className={cn(
283
+ "relative cursor-pointer overflow-hidden rounded-full transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]",
284
+ dotBaseVariants({ size, variant }),
285
+ getDotWidth(true),
286
+ getActiveTrackClass(),
287
+ )}
288
+ onClick={() => goToIndex(index)}
289
+ >
290
+ {/* Progress fill */}
291
+ <div
292
+ className={cn(
293
+ "absolute top-0 bottom-0 left-0 h-full rounded-full",
294
+ getProgressFillClass(),
295
+ )}
296
+ style={{
297
+ width: autoPlay && duration > 0 ? `${progress}%` : "100%",
298
+ }}
299
+ />
300
+ </button>
301
+ );
302
+ }
303
+
304
+ // Inactive dot
305
+ return (
306
+ <button
307
+ // biome-ignore lint/suspicious/noArrayIndexKey: Pagination dots have fixed order based on count
308
+ key={index}
309
+ type="button"
310
+ role="tab"
311
+ aria-selected={false}
312
+ aria-label={`Go to page ${index + 1} of ${count}`}
313
+ className={cn(
314
+ dotBaseVariants({ size, variant }),
315
+ getDotWidth(false),
316
+ getInactiveClasses(),
317
+ )}
318
+ onClick={() => goToIndex(index)}
319
+ />
320
+ );
321
+ })}
322
+ </div>
323
+ );
324
+ },
325
+ );
326
+ PagerControl.displayName = "PagerControl";
327
+
328
+ export { PagerControl, pagerControlVariants };