@sabrenski/spire-ui 0.0.1

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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/index.d.ts +4981 -0
  4. package/dist/spire-ui.css +1 -0
  5. package/dist/spire-ui.es.js +18403 -0
  6. package/dist/spire-ui.umd.js +45 -0
  7. package/package.json +83 -0
  8. package/src/components/Accordion/Accordion.test.ts +218 -0
  9. package/src/components/Accordion/AccordionContent.vue +112 -0
  10. package/src/components/Accordion/AccordionItem.vue +87 -0
  11. package/src/components/Accordion/AccordionRoot.vue +111 -0
  12. package/src/components/Accordion/AccordionTrigger.vue +125 -0
  13. package/src/components/Accordion/index.ts +11 -0
  14. package/src/components/Accordion/keys.ts +23 -0
  15. package/src/components/Avatar/Avatar.test.ts +181 -0
  16. package/src/components/Avatar/Avatar.vue +150 -0
  17. package/src/components/Avatar/index.ts +2 -0
  18. package/src/components/Badge/Badge.test.ts +141 -0
  19. package/src/components/Badge/Badge.vue +133 -0
  20. package/src/components/Badge/index.ts +2 -0
  21. package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
  22. package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
  23. package/src/components/BadgeContainer/index.ts +2 -0
  24. package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
  25. package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
  26. package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
  27. package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
  28. package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
  29. package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
  30. package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
  31. package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
  32. package/src/components/Breadcrumb/index.ts +13 -0
  33. package/src/components/Breadcrumb/keys.ts +7 -0
  34. package/src/components/Button/Button.test.ts +231 -0
  35. package/src/components/Button/Button.vue +349 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Callout/Callout.test.ts +260 -0
  38. package/src/components/Callout/Callout.vue +341 -0
  39. package/src/components/Callout/index.ts +2 -0
  40. package/src/components/Card/Card.test.ts +565 -0
  41. package/src/components/Card/Card.vue +209 -0
  42. package/src/components/Card/CardContent.vue +57 -0
  43. package/src/components/Card/CardFooter.vue +72 -0
  44. package/src/components/Card/CardHeader.vue +111 -0
  45. package/src/components/Card/CardImage.vue +124 -0
  46. package/src/components/Card/index.ts +14 -0
  47. package/src/components/Chart/BarChart.vue +208 -0
  48. package/src/components/Chart/BaseChart.vue +444 -0
  49. package/src/components/Chart/Chart.test.ts +359 -0
  50. package/src/components/Chart/DonutChart.vue +283 -0
  51. package/src/components/Chart/LineChart.vue +211 -0
  52. package/src/components/Chart/index.ts +20 -0
  53. package/src/components/Chart/useChartTheme.ts +192 -0
  54. package/src/components/Checkbox/Checkbox.test.ts +209 -0
  55. package/src/components/Checkbox/Checkbox.vue +285 -0
  56. package/src/components/Checkbox/index.ts +2 -0
  57. package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
  58. package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
  59. package/src/components/ChoiceChip/index.ts +2 -0
  60. package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
  61. package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
  62. package/src/components/ChoiceChipGroup/index.ts +2 -0
  63. package/src/components/ColorPicker/ColorArea.vue +159 -0
  64. package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
  65. package/src/components/ColorPicker/ColorPicker.vue +339 -0
  66. package/src/components/ColorPicker/ColorSlider.vue +191 -0
  67. package/src/components/ColorPicker/index.ts +7 -0
  68. package/src/components/Combobox/Combobox.test.ts +891 -0
  69. package/src/components/Combobox/Combobox.vue +934 -0
  70. package/src/components/Combobox/index.ts +2 -0
  71. package/src/components/DataTable/DataTable.test.ts +1221 -0
  72. package/src/components/DataTable/DataTable.vue +1415 -0
  73. package/src/components/DataTable/index.ts +10 -0
  74. package/src/components/DatePicker/DatePicker.test.ts +625 -0
  75. package/src/components/DatePicker/DatePicker.vue +1586 -0
  76. package/src/components/DatePicker/index.ts +2 -0
  77. package/src/components/Drawer/Drawer.test.ts +336 -0
  78. package/src/components/Drawer/Drawer.vue +466 -0
  79. package/src/components/Drawer/index.ts +2 -0
  80. package/src/components/Dropdown/Dropdown.test.ts +607 -0
  81. package/src/components/Dropdown/Dropdown.vue +807 -0
  82. package/src/components/Dropdown/DropdownItem.vue +227 -0
  83. package/src/components/Dropdown/DropdownSeparator.vue +14 -0
  84. package/src/components/Dropdown/DropdownSub.vue +104 -0
  85. package/src/components/Dropdown/DropdownSubContent.vue +187 -0
  86. package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
  87. package/src/components/Dropdown/index.ts +14 -0
  88. package/src/components/EmptyState/EmptyState.test.ts +180 -0
  89. package/src/components/EmptyState/EmptyState.vue +137 -0
  90. package/src/components/EmptyState/index.ts +2 -0
  91. package/src/components/FileUpload/FileUpload.test.ts +1151 -0
  92. package/src/components/FileUpload/FileUpload.vue +1042 -0
  93. package/src/components/FileUpload/index.ts +2 -0
  94. package/src/components/Heading/Heading.test.ts +107 -0
  95. package/src/components/Heading/Heading.vue +67 -0
  96. package/src/components/Heading/index.ts +2 -0
  97. package/src/components/Icon/Icon.test.ts +157 -0
  98. package/src/components/Icon/Icon.vue +86 -0
  99. package/src/components/Icon/index.ts +2 -0
  100. package/src/components/Input/Input.test.ts +273 -0
  101. package/src/components/Input/Input.vue +388 -0
  102. package/src/components/Input/index.ts +2 -0
  103. package/src/components/Layout/Container.vue +67 -0
  104. package/src/components/Layout/Grid.vue +159 -0
  105. package/src/components/Layout/GridItem.vue +154 -0
  106. package/src/components/Layout/Layout.test.ts +202 -0
  107. package/src/components/Layout/Stack.vue +128 -0
  108. package/src/components/Layout/index.ts +9 -0
  109. package/src/components/Layout/keys.ts +7 -0
  110. package/src/components/Modal/Modal.test.ts +311 -0
  111. package/src/components/Modal/Modal.vue +336 -0
  112. package/src/components/Modal/index.ts +2 -0
  113. package/src/components/Pagination/Pagination.test.ts +303 -0
  114. package/src/components/Pagination/Pagination.vue +212 -0
  115. package/src/components/Pagination/index.ts +3 -0
  116. package/src/components/Pagination/utils.ts +86 -0
  117. package/src/components/Popover/Popover.test.ts +285 -0
  118. package/src/components/Popover/Popover.vue +441 -0
  119. package/src/components/Popover/index.ts +2 -0
  120. package/src/components/Progress/Progress.test.ts +361 -0
  121. package/src/components/Progress/Progress.vue +363 -0
  122. package/src/components/Progress/index.ts +7 -0
  123. package/src/components/Radio/Radio.test.ts +216 -0
  124. package/src/components/Radio/Radio.vue +214 -0
  125. package/src/components/Radio/index.ts +2 -0
  126. package/src/components/Rating/Rating.test.ts +319 -0
  127. package/src/components/Rating/Rating.vue +247 -0
  128. package/src/components/Rating/index.ts +2 -0
  129. package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
  130. package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
  131. package/src/components/SegmentedControl/index.ts +2 -0
  132. package/src/components/Select/Select.test.ts +589 -0
  133. package/src/components/Select/Select.vue +666 -0
  134. package/src/components/Select/index.ts +2 -0
  135. package/src/components/Sidebar/Sidebar.test.ts +301 -0
  136. package/src/components/Sidebar/SidebarGroup.vue +103 -0
  137. package/src/components/Sidebar/SidebarItem.vue +196 -0
  138. package/src/components/Sidebar/SidebarLayout.vue +42 -0
  139. package/src/components/Sidebar/SidebarRoot.vue +122 -0
  140. package/src/components/Sidebar/index.ts +11 -0
  141. package/src/components/Sidebar/keys.ts +14 -0
  142. package/src/components/Skeleton/Skeleton.test.ts +130 -0
  143. package/src/components/Skeleton/Skeleton.vue +104 -0
  144. package/src/components/Skeleton/index.ts +2 -0
  145. package/src/components/Slider/Slider.test.ts +416 -0
  146. package/src/components/Slider/Slider.vue +435 -0
  147. package/src/components/Slider/index.ts +2 -0
  148. package/src/components/Slider/utils.ts +91 -0
  149. package/src/components/Spinner/Spinner.test.ts +79 -0
  150. package/src/components/Spinner/Spinner.vue +159 -0
  151. package/src/components/Spinner/index.ts +2 -0
  152. package/src/components/SpireProvider/SpireProvider.vue +71 -0
  153. package/src/components/SpireProvider/index.ts +11 -0
  154. package/src/components/Stepper/Stepper.test.ts +221 -0
  155. package/src/components/Stepper/StepperContent.vue +51 -0
  156. package/src/components/Stepper/StepperItem.vue +89 -0
  157. package/src/components/Stepper/StepperRoot.vue +101 -0
  158. package/src/components/Stepper/StepperSeparator.vue +52 -0
  159. package/src/components/Stepper/StepperTrigger.vue +144 -0
  160. package/src/components/Stepper/index.ts +11 -0
  161. package/src/components/Stepper/keys.ts +27 -0
  162. package/src/components/Switch/Switch.test.ts +214 -0
  163. package/src/components/Switch/Switch.vue +235 -0
  164. package/src/components/Switch/index.ts +2 -0
  165. package/src/components/Tabs/Tabs.test.ts +363 -0
  166. package/src/components/Tabs/Tabs.vue +318 -0
  167. package/src/components/Tabs/index.ts +2 -0
  168. package/src/components/Text/Text.test.ts +154 -0
  169. package/src/components/Text/Text.vue +100 -0
  170. package/src/components/Text/index.ts +2 -0
  171. package/src/components/Textarea/Textarea.test.ts +432 -0
  172. package/src/components/Textarea/Textarea.vue +411 -0
  173. package/src/components/Textarea/index.ts +2 -0
  174. package/src/components/TimePicker/TimePicker.test.ts +352 -0
  175. package/src/components/TimePicker/TimePicker.vue +569 -0
  176. package/src/components/TimePicker/index.ts +2 -0
  177. package/src/components/Timeline/Timeline.test.ts +193 -0
  178. package/src/components/Timeline/Timeline.vue +111 -0
  179. package/src/components/Timeline/TimelineItem.vue +167 -0
  180. package/src/components/Timeline/index.ts +13 -0
  181. package/src/components/Timeline/keys.ts +21 -0
  182. package/src/components/Toast/ToastItem.test.ts +289 -0
  183. package/src/components/Toast/ToastItem.vue +370 -0
  184. package/src/components/Toast/ToastProvider.test.ts +158 -0
  185. package/src/components/Toast/ToastProvider.vue +181 -0
  186. package/src/components/Toast/index.ts +83 -0
  187. package/src/components/Toast/toastState.test.ts +165 -0
  188. package/src/components/Toast/toastState.ts +161 -0
  189. package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
  190. package/src/components/ToggleButton/ToggleButton.vue +197 -0
  191. package/src/components/ToggleButton/index.ts +2 -0
  192. package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
  193. package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
  194. package/src/components/ToggleGroup/index.ts +2 -0
  195. package/src/components/Tooltip/Tooltip.test.ts +238 -0
  196. package/src/components/Tooltip/Tooltip.vue +217 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/components/TreeView/TreeView.test.ts +357 -0
  199. package/src/components/TreeView/TreeView.vue +251 -0
  200. package/src/components/TreeView/TreeViewItem.vue +288 -0
  201. package/src/components/TreeView/index.ts +11 -0
  202. package/src/components/TreeView/keys.ts +35 -0
  203. package/src/composables/index.ts +12 -0
  204. package/src/composables/useClickOutside.ts +36 -0
  205. package/src/composables/useClipboard.ts +35 -0
  206. package/src/composables/useEventListener.ts +48 -0
  207. package/src/composables/useFocusTrap.ts +58 -0
  208. package/src/composables/useHoverReveal.ts +98 -0
  209. package/src/composables/useId.ts +10 -0
  210. package/src/composables/useMagnetic.ts +171 -0
  211. package/src/composables/useRelativePosition.ts +127 -0
  212. package/src/composables/useRipple.ts +146 -0
  213. package/src/composables/useScrollLock.ts +25 -0
  214. package/src/composables/useSpireConfig.ts +27 -0
  215. package/src/composables/useStagger.ts +224 -0
  216. package/src/config/icons.test.ts +115 -0
  217. package/src/config/icons.ts +170 -0
  218. package/src/index.ts +361 -0
  219. package/src/styles/depth.css +129 -0
  220. package/src/styles/effects.css +169 -0
  221. package/src/styles/fallback.css +152 -0
  222. package/src/styles/main.css +25 -0
  223. package/src/styles/mood.css +211 -0
  224. package/src/styles/motion.css +159 -0
  225. package/src/styles/reset.css +97 -0
  226. package/src/styles/theme.css +708 -0
  227. package/src/styles/tokens.css +183 -0
  228. package/src/utils/.gitkeep +0 -0
  229. package/src/utils/color.ts +277 -0
  230. package/src/utils/date.test.ts +522 -0
  231. package/src/utils/date.ts +380 -0
  232. package/src/utils/index.ts +23 -0
  233. package/src/utils/object.test.ts +80 -0
  234. package/src/utils/object.ts +25 -0
  235. package/src/utils/string.test.ts +64 -0
  236. package/src/utils/string.ts +32 -0
  237. package/src/utils/time.ts +156 -0
@@ -0,0 +1,303 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Pagination from './Pagination.vue'
4
+ import { generatePageRange } from './utils'
5
+
6
+ describe('generatePageRange', () => {
7
+ it('returns empty array for zero total pages', () => {
8
+ expect(generatePageRange(1, 0)).toEqual([])
9
+ })
10
+
11
+ it('returns single page for total of 1', () => {
12
+ expect(generatePageRange(1, 1)).toEqual([1])
13
+ })
14
+
15
+ it('returns all pages when total is small', () => {
16
+ expect(generatePageRange(1, 5, 1, true)).toEqual([1, 2, 3, 4, 5])
17
+ expect(generatePageRange(3, 5, 1, true)).toEqual([1, 2, 3, 4, 5])
18
+ })
19
+
20
+ it('shows ellipsis for large page counts with showEdges', () => {
21
+ const result = generatePageRange(5, 10, 1, true)
22
+ expect(result).toContain('ellipsis')
23
+ expect(result[0]).toBe(1)
24
+ expect(result[result.length - 1]).toBe(10)
25
+ })
26
+
27
+ it('shows left ellipsis when current page is far from start', () => {
28
+ const result = generatePageRange(8, 10, 1, true)
29
+ expect(result).toEqual([1, 'ellipsis', 7, 8, 9, 10])
30
+ })
31
+
32
+ it('shows right ellipsis when current page is far from end', () => {
33
+ const result = generatePageRange(3, 10, 1, true)
34
+ expect(result).toEqual([1, 2, 3, 4, 'ellipsis', 10])
35
+ })
36
+
37
+ it('shows both ellipses when current page is in middle', () => {
38
+ const result = generatePageRange(5, 10, 1, true)
39
+ expect(result).toEqual([1, 'ellipsis', 4, 5, 6, 'ellipsis', 10])
40
+ })
41
+
42
+ it('respects siblingCount', () => {
43
+ const result = generatePageRange(10, 20, 2, true)
44
+ expect(result).toContain(8)
45
+ expect(result).toContain(9)
46
+ expect(result).toContain(10)
47
+ expect(result).toContain(11)
48
+ expect(result).toContain(12)
49
+ })
50
+
51
+ it('clamps current page to valid range', () => {
52
+ const resultHigh = generatePageRange(100, 10, 1, true)
53
+ expect(resultHigh).toContain(10)
54
+
55
+ const resultLow = generatePageRange(-5, 10, 1, true)
56
+ expect(resultLow[0]).toBe(1)
57
+ })
58
+
59
+ it('works without showEdges', () => {
60
+ const result = generatePageRange(5, 10, 1, false)
61
+ expect(result[0]).toBe('ellipsis')
62
+ expect(result).toContain(4)
63
+ expect(result).toContain(5)
64
+ expect(result).toContain(6)
65
+ expect(result[result.length - 1]).toBe('ellipsis')
66
+ })
67
+ })
68
+
69
+ describe('Pagination', () => {
70
+ describe('Rendering', () => {
71
+ it('renders with required props', () => {
72
+ const wrapper = mount(Pagination, {
73
+ props: { modelValue: 1, total: 100 }
74
+ })
75
+ expect(wrapper.find('.ui-pagination').exists()).toBe(true)
76
+ })
77
+
78
+ it('renders navigation buttons', () => {
79
+ const wrapper = mount(Pagination, {
80
+ props: { modelValue: 5, total: 100 }
81
+ })
82
+ const buttons = wrapper.findAllComponents({ name: 'Button' })
83
+ expect(buttons.length).toBeGreaterThan(2)
84
+ })
85
+
86
+ it('renders page numbers', () => {
87
+ const wrapper = mount(Pagination, {
88
+ props: { modelValue: 1, total: 50, pageSize: 10 }
89
+ })
90
+ expect(wrapper.text()).toContain('1')
91
+ expect(wrapper.text()).toContain('5')
92
+ })
93
+
94
+ it('renders ellipsis when needed', () => {
95
+ const wrapper = mount(Pagination, {
96
+ props: { modelValue: 5, total: 100, pageSize: 10 }
97
+ })
98
+ expect(wrapper.find('.ui-pagination__ellipsis').exists()).toBe(true)
99
+ })
100
+
101
+ it('does not render when total is 0', () => {
102
+ const wrapper = mount(Pagination, {
103
+ props: { modelValue: 1, total: 0 }
104
+ })
105
+ expect(wrapper.find('.ui-pagination__item').exists()).toBe(false)
106
+ })
107
+ })
108
+
109
+ describe('Navigation', () => {
110
+ it('emits update:modelValue when page is clicked', async () => {
111
+ const wrapper = mount(Pagination, {
112
+ props: { modelValue: 1, total: 50, pageSize: 10 }
113
+ })
114
+ const pageButtons = wrapper.findAll('.ui-pagination__button')
115
+ await pageButtons[1].trigger('click')
116
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
117
+ })
118
+
119
+ it('emits correct page on previous click', async () => {
120
+ const wrapper = mount(Pagination, {
121
+ props: { modelValue: 3, total: 50, pageSize: 10 }
122
+ })
123
+ const prevButton = wrapper.findAllComponents({ name: 'Button' })[0]
124
+ await prevButton.trigger('click')
125
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([2])
126
+ })
127
+
128
+ it('emits correct page on next click', async () => {
129
+ const wrapper = mount(Pagination, {
130
+ props: { modelValue: 3, total: 50, pageSize: 10 }
131
+ })
132
+ const buttons = wrapper.findAllComponents({ name: 'Button' })
133
+ const nextButton = buttons[buttons.length - 1]
134
+ await nextButton.trigger('click')
135
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([4])
136
+ })
137
+
138
+ it('disables previous button on first page', () => {
139
+ const wrapper = mount(Pagination, {
140
+ props: { modelValue: 1, total: 50, pageSize: 10 }
141
+ })
142
+ const prevButton = wrapper.findAllComponents({ name: 'Button' })[0]
143
+ expect(prevButton.props('disabled')).toBe(true)
144
+ })
145
+
146
+ it('disables next button on last page', () => {
147
+ const wrapper = mount(Pagination, {
148
+ props: { modelValue: 5, total: 50, pageSize: 10 }
149
+ })
150
+ const buttons = wrapper.findAllComponents({ name: 'Button' })
151
+ const nextButton = buttons[buttons.length - 1]
152
+ expect(nextButton.props('disabled')).toBe(true)
153
+ })
154
+
155
+ it('does not emit when clicking current page', async () => {
156
+ const wrapper = mount(Pagination, {
157
+ props: { modelValue: 1, total: 50, pageSize: 10 }
158
+ })
159
+ const currentButton = wrapper.find('.ui-pagination__button--current')
160
+ await currentButton.trigger('click')
161
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
162
+ })
163
+ })
164
+
165
+ describe('Props', () => {
166
+ it('calculates total pages correctly', () => {
167
+ const wrapper = mount(Pagination, {
168
+ props: { modelValue: 1, total: 95, pageSize: 10 }
169
+ })
170
+ expect(wrapper.text()).toContain('10')
171
+ })
172
+
173
+ it('respects pageSize prop', () => {
174
+ const wrapper = mount(Pagination, {
175
+ props: { modelValue: 1, total: 100, pageSize: 20 }
176
+ })
177
+ expect(wrapper.text()).toContain('5')
178
+ expect(wrapper.text()).not.toContain('10')
179
+ })
180
+
181
+ it('respects siblingCount prop', () => {
182
+ const wrapper = mount(Pagination, {
183
+ props: { modelValue: 10, total: 200, pageSize: 10, siblingCount: 2 }
184
+ })
185
+ expect(wrapper.text()).toContain('8')
186
+ expect(wrapper.text()).toContain('12')
187
+ })
188
+
189
+ it('disables all buttons when disabled prop is true', () => {
190
+ const wrapper = mount(Pagination, {
191
+ props: { modelValue: 3, total: 50, pageSize: 10, disabled: true }
192
+ })
193
+ const buttons = wrapper.findAllComponents({ name: 'Button' })
194
+ buttons.forEach(button => {
195
+ expect(button.props('disabled')).toBe(true)
196
+ })
197
+ })
198
+
199
+ it('applies disabled class when disabled', () => {
200
+ const wrapper = mount(Pagination, {
201
+ props: { modelValue: 1, total: 50, disabled: true }
202
+ })
203
+ expect(wrapper.find('.ui-pagination--disabled').exists()).toBe(true)
204
+ })
205
+ })
206
+
207
+ describe('Sizes', () => {
208
+ it('applies sm size', () => {
209
+ const wrapper = mount(Pagination, {
210
+ props: { modelValue: 1, total: 50, size: 'sm' }
211
+ })
212
+ expect(wrapper.find('.ui-pagination--sm').exists()).toBe(true)
213
+ })
214
+
215
+ it('applies md size by default', () => {
216
+ const wrapper = mount(Pagination, {
217
+ props: { modelValue: 1, total: 50 }
218
+ })
219
+ expect(wrapper.find('.ui-pagination--md').exists()).toBe(true)
220
+ })
221
+
222
+ it('applies lg size', () => {
223
+ const wrapper = mount(Pagination, {
224
+ props: { modelValue: 1, total: 50, size: 'lg' }
225
+ })
226
+ expect(wrapper.find('.ui-pagination--lg').exists()).toBe(true)
227
+ })
228
+ })
229
+
230
+ describe('Accessibility', () => {
231
+ it('has navigation role', () => {
232
+ const wrapper = mount(Pagination, {
233
+ props: { modelValue: 1, total: 50 }
234
+ })
235
+ expect(wrapper.find('[role="navigation"]').exists()).toBe(true)
236
+ })
237
+
238
+ it('has aria-label on navigation', () => {
239
+ const wrapper = mount(Pagination, {
240
+ props: { modelValue: 1, total: 50 }
241
+ })
242
+ expect(wrapper.find('[aria-label="Pagination"]').exists()).toBe(true)
243
+ })
244
+
245
+ it('has aria-label on navigation buttons', () => {
246
+ const wrapper = mount(Pagination, {
247
+ props: { modelValue: 3, total: 50 }
248
+ })
249
+ const prevButton = wrapper.findAllComponents({ name: 'Button' })[0]
250
+ expect(prevButton.attributes('aria-label')).toBe('Go to previous page')
251
+ })
252
+
253
+ it('has aria-current on current page', () => {
254
+ const wrapper = mount(Pagination, {
255
+ props: { modelValue: 3, total: 50, pageSize: 10 }
256
+ })
257
+ const currentButton = wrapper.find('.ui-pagination__button--current')
258
+ expect(currentButton.attributes('aria-current')).toBe('page')
259
+ })
260
+
261
+ it('has aria-label on page buttons', () => {
262
+ const wrapper = mount(Pagination, {
263
+ props: { modelValue: 1, total: 50, pageSize: 10 }
264
+ })
265
+ const pageButton = wrapper.find('.ui-pagination__button')
266
+ expect(pageButton.attributes('aria-label')).toContain('Go to page')
267
+ })
268
+
269
+ it('ellipsis has aria-hidden', () => {
270
+ const wrapper = mount(Pagination, {
271
+ props: { modelValue: 5, total: 100, pageSize: 10 }
272
+ })
273
+ const ellipsis = wrapper.find('.ui-pagination__ellipsis')
274
+ expect(ellipsis.attributes('aria-hidden')).toBe('true')
275
+ })
276
+ })
277
+
278
+ describe('Edge Cases', () => {
279
+ it('handles total less than pageSize', () => {
280
+ const wrapper = mount(Pagination, {
281
+ props: { modelValue: 1, total: 5, pageSize: 10 }
282
+ })
283
+ expect(wrapper.text()).toContain('1')
284
+ expect(wrapper.find('.ui-pagination__ellipsis').exists()).toBe(false)
285
+ })
286
+
287
+ it('handles modelValue greater than total pages', () => {
288
+ const wrapper = mount(Pagination, {
289
+ props: { modelValue: 100, total: 50, pageSize: 10 }
290
+ })
291
+ const currentButton = wrapper.find('.ui-pagination__button--current')
292
+ expect(currentButton.text()).toBe('5')
293
+ })
294
+
295
+ it('handles modelValue less than 1', () => {
296
+ const wrapper = mount(Pagination, {
297
+ props: { modelValue: -5, total: 50, pageSize: 10 }
298
+ })
299
+ const currentButton = wrapper.find('.ui-pagination__button--current')
300
+ expect(currentButton.text()).toBe('1')
301
+ })
302
+ })
303
+ })
@@ -0,0 +1,212 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import Button from '../Button/Button.vue'
4
+ import { useInternalIcon } from '../../config/icons'
5
+ import { generatePageRange, type PageItem } from './utils'
6
+
7
+ const ChevronLeftIcon = useInternalIcon('chevronLeft')
8
+ const ChevronRightIcon = useInternalIcon('chevronRight')
9
+
10
+ export interface PaginationProps {
11
+ /** Current active page (v-model) */
12
+ modelValue: number
13
+ /** Total count of items */
14
+ total: number
15
+ /** Items per page */
16
+ pageSize?: number
17
+ /** Number of pages to show on each side of current page */
18
+ siblingCount?: number
19
+ /** Whether to always show first/last page numbers */
20
+ showEdges?: boolean
21
+ /** Size variant */
22
+ size?: 'sm' | 'md' | 'lg'
23
+ /** Disable all controls */
24
+ disabled?: boolean
25
+ }
26
+
27
+ const props = withDefaults(defineProps<PaginationProps>(), {
28
+ pageSize: 10,
29
+ siblingCount: 1,
30
+ showEdges: true,
31
+ size: 'md',
32
+ disabled: false
33
+ })
34
+
35
+ const emit = defineEmits<{
36
+ (e: 'update:modelValue', value: number): void
37
+ }>()
38
+
39
+ const totalPages = computed(() => {
40
+ if (props.total <= 0 || props.pageSize <= 0) return 0
41
+ return Math.ceil(props.total / props.pageSize)
42
+ })
43
+
44
+ const currentPage = computed(() => {
45
+ return Math.max(1, Math.min(props.modelValue, totalPages.value))
46
+ })
47
+
48
+ const visiblePages = computed<PageItem[]>(() => {
49
+ return generatePageRange(
50
+ currentPage.value,
51
+ totalPages.value,
52
+ props.siblingCount,
53
+ props.showEdges
54
+ )
55
+ })
56
+
57
+ const isFirstPage = computed(() => currentPage.value <= 1)
58
+ const isLastPage = computed(() => currentPage.value >= totalPages.value)
59
+
60
+ function goToPage(page: number) {
61
+ if (props.disabled) return
62
+ const newPage = Math.max(1, Math.min(page, totalPages.value))
63
+ if (newPage !== props.modelValue) {
64
+ emit('update:modelValue', newPage)
65
+ }
66
+ }
67
+
68
+ function goToPrevious() {
69
+ if (!isFirstPage.value) {
70
+ goToPage(currentPage.value - 1)
71
+ }
72
+ }
73
+
74
+ function goToNext() {
75
+ if (!isLastPage.value) {
76
+ goToPage(currentPage.value + 1)
77
+ }
78
+ }
79
+
80
+ const buttonSize = computed(() => {
81
+ if (props.size === 'sm') return 'xs'
82
+ if (props.size === 'lg') return 'md'
83
+ return 'sm'
84
+ })
85
+ </script>
86
+
87
+ <template>
88
+ <nav
89
+ class="ui-pagination"
90
+ :class="[
91
+ `ui-pagination--${size}`,
92
+ { 'ui-pagination--disabled': disabled }
93
+ ]"
94
+ role="navigation"
95
+ aria-label="Pagination"
96
+ >
97
+ <Button
98
+ variant="ghost"
99
+ :size="buttonSize"
100
+ :disabled="disabled || isFirstPage"
101
+ :icon-left="ChevronLeftIcon"
102
+ aria-label="Go to previous page"
103
+ @click="goToPrevious"
104
+ />
105
+
106
+ <ul class="ui-pagination__list" role="list">
107
+ <li
108
+ v-for="(item, index) in visiblePages"
109
+ :key="item === 'ellipsis' ? `ellipsis-${index}` : item"
110
+ class="ui-pagination__item"
111
+ >
112
+ <span
113
+ v-if="item === 'ellipsis'"
114
+ class="ui-pagination__ellipsis"
115
+ aria-hidden="true"
116
+ >
117
+ &hellip;
118
+ </span>
119
+ <Button
120
+ v-else
121
+ :variant="item === currentPage ? 'secondary' : 'ghost'"
122
+ :size="buttonSize"
123
+ :disabled="disabled"
124
+ :aria-label="`Go to page ${item}`"
125
+ :aria-current="item === currentPage ? 'page' : undefined"
126
+ class="ui-pagination__button"
127
+ :class="{ 'ui-pagination__button--current': item === currentPage }"
128
+ @click="goToPage(item)"
129
+ >
130
+ {{ item }}
131
+ </Button>
132
+ </li>
133
+ </ul>
134
+
135
+ <Button
136
+ variant="ghost"
137
+ :size="buttonSize"
138
+ :disabled="disabled || isLastPage"
139
+ :icon-left="ChevronRightIcon"
140
+ aria-label="Go to next page"
141
+ @click="goToNext"
142
+ />
143
+ </nav>
144
+ </template>
145
+
146
+ <style scoped>
147
+ .ui-pagination {
148
+ display: flex;
149
+ align-items: center;
150
+ gap: var(--space-1);
151
+ font-family: var(--font-sans);
152
+ }
153
+
154
+ .ui-pagination--disabled {
155
+ opacity: 0.5;
156
+ pointer-events: none;
157
+ }
158
+
159
+ .ui-pagination__list {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: var(--space-1);
163
+ list-style: none;
164
+ margin: 0;
165
+ padding: 0;
166
+ }
167
+
168
+ .ui-pagination__item {
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ }
173
+
174
+ .ui-pagination__ellipsis {
175
+ display: flex;
176
+ align-items: center;
177
+ justify-content: center;
178
+ min-width: var(--input-height-sm);
179
+ height: var(--input-height-sm);
180
+ font-size: var(--text-sm);
181
+ color: var(--text-tertiary);
182
+ user-select: none;
183
+ }
184
+
185
+ .ui-pagination--sm .ui-pagination__ellipsis {
186
+ min-width: var(--input-height-xs);
187
+ height: var(--input-height-xs);
188
+ font-size: var(--text-xs);
189
+ }
190
+
191
+ .ui-pagination--lg .ui-pagination__ellipsis {
192
+ min-width: var(--input-height-md);
193
+ height: var(--input-height-md);
194
+ font-size: var(--text-md);
195
+ }
196
+
197
+ .ui-pagination__button {
198
+ min-width: var(--input-height-sm);
199
+ }
200
+
201
+ .ui-pagination--sm .ui-pagination__button {
202
+ min-width: var(--input-height-xs);
203
+ }
204
+
205
+ .ui-pagination--lg .ui-pagination__button {
206
+ min-width: var(--input-height-md);
207
+ }
208
+
209
+ .ui-pagination__button--current {
210
+ font-weight: var(--font-semibold);
211
+ }
212
+ </style>
@@ -0,0 +1,3 @@
1
+ export { default as Pagination } from './Pagination.vue'
2
+ export type { PaginationProps } from './Pagination.vue'
3
+ export { generatePageRange, type PageItem } from './utils'
@@ -0,0 +1,86 @@
1
+ export type PageItem = number | 'ellipsis'
2
+
3
+ /**
4
+ * Generates an array of page numbers and ellipsis markers for pagination display.
5
+ *
6
+ * Time complexity: O(n) where n is siblingCount
7
+ * Space complexity: O(n) for the result array
8
+ *
9
+ * @param currentPage - The currently active page (1-indexed)
10
+ * @param totalPages - Total number of pages
11
+ * @param siblingCount - Number of pages to show on each side of current page
12
+ * @param showEdges - Whether to always show first and last page numbers
13
+ * @returns Array of page numbers and 'ellipsis' markers
14
+ */
15
+ export function generatePageRange(
16
+ currentPage: number,
17
+ totalPages: number,
18
+ siblingCount: number = 1,
19
+ showEdges: boolean = true
20
+ ): PageItem[] {
21
+ if (totalPages <= 0) return []
22
+ if (totalPages === 1) return [1]
23
+
24
+ const clampedCurrent = Math.max(1, Math.min(currentPage, totalPages))
25
+
26
+ const leftSiblingIndex = Math.max(clampedCurrent - siblingCount, 1)
27
+ const rightSiblingIndex = Math.min(clampedCurrent + siblingCount, totalPages)
28
+
29
+ if (!showEdges) {
30
+ const pages: PageItem[] = []
31
+
32
+ const showLeftDots = leftSiblingIndex > 1
33
+ const showRightDots = rightSiblingIndex < totalPages
34
+
35
+ if (showLeftDots) {
36
+ pages.push('ellipsis')
37
+ }
38
+
39
+ for (let i = leftSiblingIndex; i <= rightSiblingIndex; i++) {
40
+ pages.push(i)
41
+ }
42
+
43
+ if (showRightDots) {
44
+ pages.push('ellipsis')
45
+ }
46
+
47
+ return pages
48
+ }
49
+
50
+ const totalSlots = siblingCount * 2 + 5
51
+ if (totalPages <= totalSlots) {
52
+ const pages: PageItem[] = []
53
+ for (let i = 1; i <= totalPages; i++) {
54
+ pages.push(i)
55
+ }
56
+ return pages
57
+ }
58
+
59
+ const showLeftEllipsis = leftSiblingIndex > 2
60
+ const showRightEllipsis = rightSiblingIndex < totalPages - 1
61
+
62
+ const pages: PageItem[] = []
63
+
64
+ pages.push(1)
65
+
66
+ if (showLeftEllipsis) {
67
+ pages.push('ellipsis')
68
+ }
69
+
70
+ const rangeStart = showLeftEllipsis ? leftSiblingIndex : 2
71
+ const rangeEnd = showRightEllipsis ? rightSiblingIndex : totalPages - 1
72
+
73
+ for (let i = rangeStart; i <= rangeEnd; i++) {
74
+ pages.push(i)
75
+ }
76
+
77
+ if (showRightEllipsis) {
78
+ pages.push('ellipsis')
79
+ }
80
+
81
+ if (totalPages > 1) {
82
+ pages.push(totalPages)
83
+ }
84
+
85
+ return pages
86
+ }