@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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/index.d.ts +4981 -0
- package/dist/spire-ui.css +1 -0
- package/dist/spire-ui.es.js +18403 -0
- package/dist/spire-ui.umd.js +45 -0
- package/package.json +83 -0
- package/src/components/Accordion/Accordion.test.ts +218 -0
- package/src/components/Accordion/AccordionContent.vue +112 -0
- package/src/components/Accordion/AccordionItem.vue +87 -0
- package/src/components/Accordion/AccordionRoot.vue +111 -0
- package/src/components/Accordion/AccordionTrigger.vue +125 -0
- package/src/components/Accordion/index.ts +11 -0
- package/src/components/Accordion/keys.ts +23 -0
- package/src/components/Avatar/Avatar.test.ts +181 -0
- package/src/components/Avatar/Avatar.vue +150 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.test.ts +141 -0
- package/src/components/Badge/Badge.vue +133 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
- package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
- package/src/components/BadgeContainer/index.ts +2 -0
- package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
- package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
- package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
- package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
- package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
- package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
- package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
- package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
- package/src/components/Breadcrumb/index.ts +13 -0
- package/src/components/Breadcrumb/keys.ts +7 -0
- package/src/components/Button/Button.test.ts +231 -0
- package/src/components/Button/Button.vue +349 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Callout/Callout.test.ts +260 -0
- package/src/components/Callout/Callout.vue +341 -0
- package/src/components/Callout/index.ts +2 -0
- package/src/components/Card/Card.test.ts +565 -0
- package/src/components/Card/Card.vue +209 -0
- package/src/components/Card/CardContent.vue +57 -0
- package/src/components/Card/CardFooter.vue +72 -0
- package/src/components/Card/CardHeader.vue +111 -0
- package/src/components/Card/CardImage.vue +124 -0
- package/src/components/Card/index.ts +14 -0
- package/src/components/Chart/BarChart.vue +208 -0
- package/src/components/Chart/BaseChart.vue +444 -0
- package/src/components/Chart/Chart.test.ts +359 -0
- package/src/components/Chart/DonutChart.vue +283 -0
- package/src/components/Chart/LineChart.vue +211 -0
- package/src/components/Chart/index.ts +20 -0
- package/src/components/Chart/useChartTheme.ts +192 -0
- package/src/components/Checkbox/Checkbox.test.ts +209 -0
- package/src/components/Checkbox/Checkbox.vue +285 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
- package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
- package/src/components/ChoiceChip/index.ts +2 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
- package/src/components/ChoiceChipGroup/index.ts +2 -0
- package/src/components/ColorPicker/ColorArea.vue +159 -0
- package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
- package/src/components/ColorPicker/ColorPicker.vue +339 -0
- package/src/components/ColorPicker/ColorSlider.vue +191 -0
- package/src/components/ColorPicker/index.ts +7 -0
- package/src/components/Combobox/Combobox.test.ts +891 -0
- package/src/components/Combobox/Combobox.vue +934 -0
- package/src/components/Combobox/index.ts +2 -0
- package/src/components/DataTable/DataTable.test.ts +1221 -0
- package/src/components/DataTable/DataTable.vue +1415 -0
- package/src/components/DataTable/index.ts +10 -0
- package/src/components/DatePicker/DatePicker.test.ts +625 -0
- package/src/components/DatePicker/DatePicker.vue +1586 -0
- package/src/components/DatePicker/index.ts +2 -0
- package/src/components/Drawer/Drawer.test.ts +336 -0
- package/src/components/Drawer/Drawer.vue +466 -0
- package/src/components/Drawer/index.ts +2 -0
- package/src/components/Dropdown/Dropdown.test.ts +607 -0
- package/src/components/Dropdown/Dropdown.vue +807 -0
- package/src/components/Dropdown/DropdownItem.vue +227 -0
- package/src/components/Dropdown/DropdownSeparator.vue +14 -0
- package/src/components/Dropdown/DropdownSub.vue +104 -0
- package/src/components/Dropdown/DropdownSubContent.vue +187 -0
- package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
- package/src/components/Dropdown/index.ts +14 -0
- package/src/components/EmptyState/EmptyState.test.ts +180 -0
- package/src/components/EmptyState/EmptyState.vue +137 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/FileUpload/FileUpload.test.ts +1151 -0
- package/src/components/FileUpload/FileUpload.vue +1042 -0
- package/src/components/FileUpload/index.ts +2 -0
- package/src/components/Heading/Heading.test.ts +107 -0
- package/src/components/Heading/Heading.vue +67 -0
- package/src/components/Heading/index.ts +2 -0
- package/src/components/Icon/Icon.test.ts +157 -0
- package/src/components/Icon/Icon.vue +86 -0
- package/src/components/Icon/index.ts +2 -0
- package/src/components/Input/Input.test.ts +273 -0
- package/src/components/Input/Input.vue +388 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Layout/Container.vue +67 -0
- package/src/components/Layout/Grid.vue +159 -0
- package/src/components/Layout/GridItem.vue +154 -0
- package/src/components/Layout/Layout.test.ts +202 -0
- package/src/components/Layout/Stack.vue +128 -0
- package/src/components/Layout/index.ts +9 -0
- package/src/components/Layout/keys.ts +7 -0
- package/src/components/Modal/Modal.test.ts +311 -0
- package/src/components/Modal/Modal.vue +336 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Pagination/Pagination.test.ts +303 -0
- package/src/components/Pagination/Pagination.vue +212 -0
- package/src/components/Pagination/index.ts +3 -0
- package/src/components/Pagination/utils.ts +86 -0
- package/src/components/Popover/Popover.test.ts +285 -0
- package/src/components/Popover/Popover.vue +441 -0
- package/src/components/Popover/index.ts +2 -0
- package/src/components/Progress/Progress.test.ts +361 -0
- package/src/components/Progress/Progress.vue +363 -0
- package/src/components/Progress/index.ts +7 -0
- package/src/components/Radio/Radio.test.ts +216 -0
- package/src/components/Radio/Radio.vue +214 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Rating/Rating.test.ts +319 -0
- package/src/components/Rating/Rating.vue +247 -0
- package/src/components/Rating/index.ts +2 -0
- package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
- package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
- package/src/components/SegmentedControl/index.ts +2 -0
- package/src/components/Select/Select.test.ts +589 -0
- package/src/components/Select/Select.vue +666 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Sidebar/Sidebar.test.ts +301 -0
- package/src/components/Sidebar/SidebarGroup.vue +103 -0
- package/src/components/Sidebar/SidebarItem.vue +196 -0
- package/src/components/Sidebar/SidebarLayout.vue +42 -0
- package/src/components/Sidebar/SidebarRoot.vue +122 -0
- package/src/components/Sidebar/index.ts +11 -0
- package/src/components/Sidebar/keys.ts +14 -0
- package/src/components/Skeleton/Skeleton.test.ts +130 -0
- package/src/components/Skeleton/Skeleton.vue +104 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.test.ts +416 -0
- package/src/components/Slider/Slider.vue +435 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Slider/utils.ts +91 -0
- package/src/components/Spinner/Spinner.test.ts +79 -0
- package/src/components/Spinner/Spinner.vue +159 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/SpireProvider/SpireProvider.vue +71 -0
- package/src/components/SpireProvider/index.ts +11 -0
- package/src/components/Stepper/Stepper.test.ts +221 -0
- package/src/components/Stepper/StepperContent.vue +51 -0
- package/src/components/Stepper/StepperItem.vue +89 -0
- package/src/components/Stepper/StepperRoot.vue +101 -0
- package/src/components/Stepper/StepperSeparator.vue +52 -0
- package/src/components/Stepper/StepperTrigger.vue +144 -0
- package/src/components/Stepper/index.ts +11 -0
- package/src/components/Stepper/keys.ts +27 -0
- package/src/components/Switch/Switch.test.ts +214 -0
- package/src/components/Switch/Switch.vue +235 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Tabs/Tabs.test.ts +363 -0
- package/src/components/Tabs/Tabs.vue +318 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Text/Text.test.ts +154 -0
- package/src/components/Text/Text.vue +100 -0
- package/src/components/Text/index.ts +2 -0
- package/src/components/Textarea/Textarea.test.ts +432 -0
- package/src/components/Textarea/Textarea.vue +411 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/TimePicker/TimePicker.test.ts +352 -0
- package/src/components/TimePicker/TimePicker.vue +569 -0
- package/src/components/TimePicker/index.ts +2 -0
- package/src/components/Timeline/Timeline.test.ts +193 -0
- package/src/components/Timeline/Timeline.vue +111 -0
- package/src/components/Timeline/TimelineItem.vue +167 -0
- package/src/components/Timeline/index.ts +13 -0
- package/src/components/Timeline/keys.ts +21 -0
- package/src/components/Toast/ToastItem.test.ts +289 -0
- package/src/components/Toast/ToastItem.vue +370 -0
- package/src/components/Toast/ToastProvider.test.ts +158 -0
- package/src/components/Toast/ToastProvider.vue +181 -0
- package/src/components/Toast/index.ts +83 -0
- package/src/components/Toast/toastState.test.ts +165 -0
- package/src/components/Toast/toastState.ts +161 -0
- package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
- package/src/components/ToggleButton/ToggleButton.vue +197 -0
- package/src/components/ToggleButton/index.ts +2 -0
- package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
- package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/Tooltip/Tooltip.test.ts +238 -0
- package/src/components/Tooltip/Tooltip.vue +217 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/components/TreeView/TreeView.test.ts +357 -0
- package/src/components/TreeView/TreeView.vue +251 -0
- package/src/components/TreeView/TreeViewItem.vue +288 -0
- package/src/components/TreeView/index.ts +11 -0
- package/src/components/TreeView/keys.ts +35 -0
- package/src/composables/index.ts +12 -0
- package/src/composables/useClickOutside.ts +36 -0
- package/src/composables/useClipboard.ts +35 -0
- package/src/composables/useEventListener.ts +48 -0
- package/src/composables/useFocusTrap.ts +58 -0
- package/src/composables/useHoverReveal.ts +98 -0
- package/src/composables/useId.ts +10 -0
- package/src/composables/useMagnetic.ts +171 -0
- package/src/composables/useRelativePosition.ts +127 -0
- package/src/composables/useRipple.ts +146 -0
- package/src/composables/useScrollLock.ts +25 -0
- package/src/composables/useSpireConfig.ts +27 -0
- package/src/composables/useStagger.ts +224 -0
- package/src/config/icons.test.ts +115 -0
- package/src/config/icons.ts +170 -0
- package/src/index.ts +361 -0
- package/src/styles/depth.css +129 -0
- package/src/styles/effects.css +169 -0
- package/src/styles/fallback.css +152 -0
- package/src/styles/main.css +25 -0
- package/src/styles/mood.css +211 -0
- package/src/styles/motion.css +159 -0
- package/src/styles/reset.css +97 -0
- package/src/styles/theme.css +708 -0
- package/src/styles/tokens.css +183 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/color.ts +277 -0
- package/src/utils/date.test.ts +522 -0
- package/src/utils/date.ts +380 -0
- package/src/utils/index.ts +23 -0
- package/src/utils/object.test.ts +80 -0
- package/src/utils/object.ts +25 -0
- package/src/utils/string.test.ts +64 -0
- package/src/utils/string.ts +32 -0
- 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
|
+
…
|
|
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,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
|
+
}
|