@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,311 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Modal from './Modal.vue'
4
+
5
+ // Track open state per dialog element using WeakMap (module-level to persist across mocks)
6
+ const openStates = new WeakMap<HTMLDialogElement, boolean>()
7
+
8
+ // Create mock functions at module level
9
+ const showModalMock = vi.fn(function(this: HTMLDialogElement) {
10
+ this.setAttribute('open', '')
11
+ openStates.set(this, true)
12
+ })
13
+
14
+ const closeMock = vi.fn(function(this: HTMLDialogElement) {
15
+ this.removeAttribute('open')
16
+ openStates.set(this, false)
17
+ })
18
+
19
+ // Mock dialog methods (JSDOM doesn't fully support native dialog)
20
+ beforeEach(() => {
21
+ // Clear mock call history but keep implementation
22
+ showModalMock.mockClear()
23
+ closeMock.mockClear()
24
+
25
+ HTMLDialogElement.prototype.showModal = showModalMock
26
+ HTMLDialogElement.prototype.close = closeMock
27
+
28
+ // Override the 'open' property getter to read from our state map
29
+ Object.defineProperty(HTMLDialogElement.prototype, 'open', {
30
+ get: function(this: HTMLDialogElement) {
31
+ return openStates.get(this) ?? this.hasAttribute('open')
32
+ },
33
+ configurable: true
34
+ })
35
+ })
36
+
37
+ afterEach(() => {
38
+ document.body.style.overflow = ''
39
+ })
40
+
41
+ describe('Modal', () => {
42
+ describe('Rendering', () => {
43
+ it('renders as a dialog element', () => {
44
+ const wrapper = mount(Modal)
45
+ expect(wrapper.find('dialog').exists()).toBe(true)
46
+ })
47
+
48
+ it('renders with ui-modal class', () => {
49
+ const wrapper = mount(Modal)
50
+ expect(wrapper.find('.ui-modal').exists()).toBe(true)
51
+ })
52
+
53
+ it('renders slot content in body', () => {
54
+ const wrapper = mount(Modal, {
55
+ props: { modelValue: true },
56
+ slots: {
57
+ default: '<p>Modal content</p>'
58
+ }
59
+ })
60
+ expect(wrapper.find('.ui-modal__body').html()).toContain('Modal content')
61
+ })
62
+
63
+ it('renders title when provided', () => {
64
+ const wrapper = mount(Modal, {
65
+ props: { modelValue: true, title: 'My Modal' }
66
+ })
67
+ expect(wrapper.find('.ui-modal__title').text()).toBe('My Modal')
68
+ })
69
+
70
+ it('does not render header when no title or header slot', () => {
71
+ const wrapper = mount(Modal, {
72
+ props: { modelValue: true }
73
+ })
74
+ expect(wrapper.find('.ui-modal__header').exists()).toBe(false)
75
+ })
76
+
77
+ it('renders header slot instead of title', () => {
78
+ const wrapper = mount(Modal, {
79
+ props: { modelValue: true },
80
+ slots: {
81
+ header: '<span class="custom-header">Custom Header</span>'
82
+ }
83
+ })
84
+ expect(wrapper.find('.custom-header').exists()).toBe(true)
85
+ })
86
+
87
+ it('renders footer slot when provided', () => {
88
+ const wrapper = mount(Modal, {
89
+ props: { modelValue: true },
90
+ slots: {
91
+ footer: '<button>Save</button>'
92
+ }
93
+ })
94
+ expect(wrapper.find('.ui-modal__footer').exists()).toBe(true)
95
+ expect(wrapper.find('.ui-modal__footer button').exists()).toBe(true)
96
+ })
97
+
98
+ it('does not render footer when no slot provided', () => {
99
+ const wrapper = mount(Modal, {
100
+ props: { modelValue: true }
101
+ })
102
+ expect(wrapper.find('.ui-modal__footer').exists()).toBe(false)
103
+ })
104
+ })
105
+
106
+ describe('Sizes', () => {
107
+ it('applies sm size class', () => {
108
+ const wrapper = mount(Modal, {
109
+ props: { size: 'sm' }
110
+ })
111
+ expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--sm')
112
+ })
113
+
114
+ it('applies md size class by default', () => {
115
+ const wrapper = mount(Modal)
116
+ expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--md')
117
+ })
118
+
119
+ it('applies lg size class', () => {
120
+ const wrapper = mount(Modal, {
121
+ props: { size: 'lg' }
122
+ })
123
+ expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--lg')
124
+ })
125
+
126
+ it('applies xl size class', () => {
127
+ const wrapper = mount(Modal, {
128
+ props: { size: 'xl' }
129
+ })
130
+ expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--xl')
131
+ })
132
+
133
+ it('applies full size class', () => {
134
+ const wrapper = mount(Modal, {
135
+ props: { size: 'full' }
136
+ })
137
+ expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--full')
138
+ })
139
+ })
140
+
141
+ describe('Open/Close', () => {
142
+ it('calls showModal when modelValue becomes true', async () => {
143
+ const wrapper = mount(Modal, {
144
+ props: { modelValue: false }
145
+ })
146
+
147
+ await wrapper.setProps({ modelValue: true })
148
+
149
+ expect(showModalMock).toHaveBeenCalled()
150
+ })
151
+
152
+ it('calls close when modelValue becomes false', async () => {
153
+ const wrapper = mount(Modal, {
154
+ props: { modelValue: true }
155
+ })
156
+
157
+ await wrapper.setProps({ modelValue: false })
158
+
159
+ expect(closeMock).toHaveBeenCalled()
160
+ })
161
+
162
+ it('locks body scroll when opened', async () => {
163
+ const wrapper = mount(Modal, {
164
+ props: { modelValue: false }
165
+ })
166
+
167
+ await wrapper.setProps({ modelValue: true })
168
+
169
+ expect(document.body.style.overflow).toBe('hidden')
170
+ })
171
+
172
+ it('unlocks body scroll when closed', async () => {
173
+ const wrapper = mount(Modal, {
174
+ props: { modelValue: true }
175
+ })
176
+
177
+ await wrapper.setProps({ modelValue: false })
178
+
179
+ expect(document.body.style.overflow).toBe('')
180
+ })
181
+ })
182
+
183
+ describe('Close button', () => {
184
+ it('renders close button when header is visible', () => {
185
+ const wrapper = mount(Modal, {
186
+ props: { modelValue: true, title: 'Test' }
187
+ })
188
+ expect(wrapper.find('.ui-modal__close').exists()).toBe(true)
189
+ })
190
+
191
+ it('emits update:modelValue false when close button clicked', async () => {
192
+ const wrapper = mount(Modal, {
193
+ props: { modelValue: true, title: 'Test' }
194
+ })
195
+
196
+ await wrapper.find('.ui-modal__close').trigger('click')
197
+
198
+ expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
199
+ })
200
+
201
+ it('has aria-label', () => {
202
+ const wrapper = mount(Modal, {
203
+ props: { modelValue: true, title: 'Test' }
204
+ })
205
+ expect(wrapper.find('.ui-modal__close').attributes('aria-label')).toBe('Close modal')
206
+ })
207
+ })
208
+
209
+ describe('Backdrop click', () => {
210
+ it('emits update:modelValue false when backdrop clicked', async () => {
211
+ const wrapper = mount(Modal, {
212
+ props: { modelValue: true, title: 'Test' }
213
+ })
214
+
215
+ const dialog = wrapper.find('dialog')
216
+ // Simulate backdrop click (target === dialog element)
217
+ await dialog.trigger('click')
218
+
219
+ expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
220
+ })
221
+
222
+ it('does not close when content clicked', async () => {
223
+ const wrapper = mount(Modal, {
224
+ props: { modelValue: true, title: 'Test' }
225
+ })
226
+
227
+ await wrapper.find('.ui-modal__box').trigger('click')
228
+
229
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
230
+ })
231
+
232
+ it('does not close on backdrop click when persistent', async () => {
233
+ const wrapper = mount(Modal, {
234
+ props: { modelValue: true, title: 'Test', persistent: true }
235
+ })
236
+
237
+ const dialog = wrapper.find('dialog')
238
+ await dialog.trigger('click')
239
+
240
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
241
+ })
242
+ })
243
+
244
+ describe('Escape key', () => {
245
+ it('emits update:modelValue false on cancel event', async () => {
246
+ const wrapper = mount(Modal, {
247
+ props: { modelValue: true, title: 'Test' }
248
+ })
249
+
250
+ await wrapper.find('dialog').trigger('cancel')
251
+
252
+ expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
253
+ })
254
+
255
+ it('prevents default on cancel when persistent', async () => {
256
+ const wrapper = mount(Modal, {
257
+ props: { modelValue: true, title: 'Test', persistent: true }
258
+ })
259
+
260
+ const event = new Event('cancel', { cancelable: true })
261
+ const preventDefault = vi.spyOn(event, 'preventDefault')
262
+
263
+ wrapper.find('dialog').element.dispatchEvent(event)
264
+
265
+ expect(preventDefault).toHaveBeenCalled()
266
+ })
267
+ })
268
+
269
+ describe('Close event', () => {
270
+ it('emits close event on dialog close', async () => {
271
+ const wrapper = mount(Modal, {
272
+ props: { modelValue: true, title: 'Test' }
273
+ })
274
+
275
+ await wrapper.find('dialog').trigger('close')
276
+
277
+ expect(wrapper.emitted('close')).toHaveLength(1)
278
+ })
279
+
280
+ it('syncs modelValue on dialog close', async () => {
281
+ const wrapper = mount(Modal, {
282
+ props: { modelValue: true, title: 'Test' }
283
+ })
284
+
285
+ await wrapper.find('dialog').trigger('close')
286
+
287
+ expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
288
+ })
289
+ })
290
+
291
+ describe('Accessibility', () => {
292
+ it('uses native dialog element', () => {
293
+ const wrapper = mount(Modal)
294
+ expect(wrapper.find('dialog').exists()).toBe(true)
295
+ })
296
+
297
+ it('close button has aria-label', () => {
298
+ const wrapper = mount(Modal, {
299
+ props: { modelValue: true, title: 'Test' }
300
+ })
301
+ expect(wrapper.find('.ui-modal__close').attributes('aria-label')).toBe('Close modal')
302
+ })
303
+
304
+ it('close button icon is aria-hidden', () => {
305
+ const wrapper = mount(Modal, {
306
+ props: { modelValue: true, title: 'Test' }
307
+ })
308
+ expect(wrapper.find('.ui-modal__close svg').attributes('aria-hidden')).toBe('true')
309
+ })
310
+ })
311
+ })
@@ -0,0 +1,336 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onMounted, useSlots } from 'vue'
3
+ import { useScrollLock } from '../../composables'
4
+
5
+ export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
6
+
7
+ export interface ModalProps {
8
+ /** Controls visibility (v-model) */
9
+ modelValue?: boolean
10
+ /** Optional header title */
11
+ title?: string
12
+ /** Modal size */
13
+ size?: ModalSize
14
+ /** Prevents closing via backdrop click or Escape key */
15
+ persistent?: boolean
16
+ }
17
+
18
+ const props = withDefaults(defineProps<ModalProps>(), {
19
+ modelValue: false,
20
+ size: 'md',
21
+ persistent: false
22
+ })
23
+
24
+ const emit = defineEmits<{
25
+ (e: 'update:modelValue', value: boolean): void
26
+ (e: 'close'): void
27
+ }>()
28
+
29
+ const slots = useSlots()
30
+ const dialogRef = ref<HTMLDialogElement | null>(null)
31
+
32
+ const isOpen = computed(() => props.modelValue)
33
+ useScrollLock(isOpen)
34
+
35
+ function close() {
36
+ emit('update:modelValue', false)
37
+ }
38
+
39
+ function handleBackdropClick(e: MouseEvent) {
40
+ if (props.persistent) return
41
+
42
+ if (e.target === dialogRef.value) {
43
+ close()
44
+ }
45
+ }
46
+
47
+ function handleCancel(e: Event) {
48
+ if (props.persistent) {
49
+ e.preventDefault()
50
+ return
51
+ }
52
+ emit('update:modelValue', false)
53
+ }
54
+
55
+ function handleClose() {
56
+ emit('update:modelValue', false)
57
+ emit('close')
58
+ }
59
+
60
+ function syncDialogState(open: boolean) {
61
+ const dialog = dialogRef.value
62
+ if (!dialog) return
63
+
64
+ if (open && !dialog.open) {
65
+ dialog.showModal()
66
+ } else if (!open && dialog.open) {
67
+ dialog.close()
68
+ }
69
+ }
70
+
71
+ onMounted(() => {
72
+ if (props.modelValue) {
73
+ syncDialogState(true)
74
+ }
75
+ })
76
+
77
+ watch(
78
+ () => props.modelValue,
79
+ (open) => {
80
+ syncDialogState(open)
81
+ }
82
+ )
83
+
84
+ const hasHeader = () => props.title || slots.header
85
+ </script>
86
+
87
+ <template>
88
+ <dialog
89
+ ref="dialogRef"
90
+ class="ui-modal"
91
+ :class="[`ui-modal--${size}`]"
92
+ @click="handleBackdropClick"
93
+ @cancel="handleCancel"
94
+ @close="handleClose"
95
+ >
96
+ <div class="ui-modal__box">
97
+ <header v-if="hasHeader()" class="ui-modal__header">
98
+ <slot name="header">
99
+ <h3 class="ui-modal__title">{{ title }}</h3>
100
+ </slot>
101
+ <button
102
+ type="button"
103
+ class="ui-modal__close"
104
+ aria-label="Close modal"
105
+ @click="close"
106
+ >
107
+ <svg
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ stroke-width="2"
112
+ stroke-linecap="round"
113
+ stroke-linejoin="round"
114
+ aria-hidden="true"
115
+ >
116
+ <path d="M6 18L18 6M6 6l12 12" />
117
+ </svg>
118
+ </button>
119
+ </header>
120
+
121
+ <div class="ui-modal__body">
122
+ <slot />
123
+ </div>
124
+
125
+ <footer v-if="$slots.footer" class="ui-modal__footer">
126
+ <slot name="footer" />
127
+ </footer>
128
+ </div>
129
+ </dialog>
130
+ </template>
131
+
132
+ <style scoped>
133
+ .ui-modal {
134
+ border: none;
135
+ padding: 0;
136
+ background: transparent;
137
+ color: inherit;
138
+
139
+ margin: auto;
140
+ max-width: calc(100vw - var(--space-8));
141
+ max-height: calc(100vh - var(--space-8));
142
+
143
+ width: var(--modal-width, 600px);
144
+
145
+ overflow: hidden;
146
+ }
147
+
148
+ .ui-modal--sm {
149
+ --modal-width: 400px;
150
+ }
151
+
152
+ .ui-modal--md {
153
+ --modal-width: 600px;
154
+ }
155
+
156
+ .ui-modal--lg {
157
+ --modal-width: 800px;
158
+ }
159
+
160
+ .ui-modal--xl {
161
+ --modal-width: 1140px;
162
+ }
163
+
164
+ .ui-modal--full {
165
+ width: 100vw;
166
+ height: 100vh;
167
+ max-width: none;
168
+ max-height: none;
169
+ }
170
+
171
+ .ui-modal--full .ui-modal__box {
172
+ border-radius: 0;
173
+ height: 100%;
174
+ }
175
+
176
+ .ui-modal__box {
177
+ background: var(--modal-bg);
178
+ border: 1px solid var(--modal-border);
179
+ border-radius: var(--radius-xl);
180
+ box-shadow: var(--shadow-xl);
181
+ display: flex;
182
+ flex-direction: column;
183
+ overflow: hidden;
184
+ max-height: calc(100vh - var(--space-8));
185
+ }
186
+
187
+ .ui-modal__header {
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: space-between;
191
+ gap: var(--space-4);
192
+ padding: var(--space-4) var(--space-6);
193
+ border-bottom: 1px solid var(--modal-border);
194
+ flex-shrink: 0;
195
+ }
196
+
197
+ .ui-modal__title {
198
+ margin: 0;
199
+ font-family: var(--font-sans);
200
+ font-size: var(--heading-lg);
201
+ font-weight: var(--font-semibold);
202
+ color: var(--modal-title);
203
+ line-height: 1.3;
204
+ }
205
+
206
+ .ui-modal__close {
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: center;
210
+ width: 2rem;
211
+ height: 2rem;
212
+ padding: 0;
213
+ margin: calc(-1 * var(--space-1));
214
+ background: transparent;
215
+ border: none;
216
+ border-radius: var(--radius-md);
217
+ color: var(--modal-close);
218
+ cursor: pointer;
219
+ flex-shrink: 0;
220
+ transition:
221
+ background-color var(--duration-fast) var(--ease-default),
222
+ color var(--duration-fast) var(--ease-default);
223
+ }
224
+
225
+ .ui-modal__close:hover {
226
+ background: var(--modal-close-hover-bg);
227
+ color: var(--modal-close-hover);
228
+ }
229
+
230
+ .ui-modal__close:focus-visible {
231
+ outline: 2px solid var(--ring-color);
232
+ outline-offset: 2px;
233
+ }
234
+
235
+ .ui-modal__close svg {
236
+ width: 1.25rem;
237
+ height: 1.25rem;
238
+ }
239
+
240
+ .ui-modal__body {
241
+ flex: 1;
242
+ padding: var(--space-6);
243
+ overflow-y: auto;
244
+ overscroll-behavior: contain;
245
+ color: var(--modal-text);
246
+ }
247
+
248
+ .ui-modal__footer {
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: flex-end;
252
+ gap: var(--space-3);
253
+ padding: var(--space-4) var(--space-6);
254
+ border-top: 1px solid var(--modal-border);
255
+ flex-shrink: 0;
256
+ }
257
+
258
+ @media (max-width: 640px) {
259
+ .ui-modal {
260
+ max-width: calc(100vw - var(--space-4));
261
+ max-height: calc(100vh - var(--space-4));
262
+ }
263
+
264
+ .ui-modal--sm,
265
+ .ui-modal--md,
266
+ .ui-modal--lg,
267
+ .ui-modal--xl {
268
+ --modal-width: 100%;
269
+ }
270
+
271
+ .ui-modal__header {
272
+ padding: var(--space-3) var(--space-4);
273
+ }
274
+
275
+ .ui-modal__body {
276
+ padding: var(--space-4);
277
+ }
278
+
279
+ .ui-modal__footer {
280
+ padding: var(--space-3) var(--space-4);
281
+ }
282
+ }
283
+ </style>
284
+
285
+ <style>
286
+ .ui-modal {
287
+ transition:
288
+ opacity var(--duration-slow) var(--ease-default),
289
+ transform var(--duration-slow) var(--ease-out-expo),
290
+ overlay var(--duration-slow) var(--ease-default) allow-discrete,
291
+ display var(--duration-slow) var(--ease-default) allow-discrete;
292
+ }
293
+
294
+ .ui-modal::backdrop {
295
+ background-color: transparent;
296
+ backdrop-filter: blur(0px);
297
+ transition:
298
+ background-color var(--duration-slow) var(--ease-default),
299
+ backdrop-filter var(--duration-slow) var(--ease-default),
300
+ overlay var(--duration-slow) allow-discrete,
301
+ display var(--duration-slow) allow-discrete;
302
+ }
303
+
304
+ .ui-modal[open]::backdrop {
305
+ background-color: var(--modal-backdrop);
306
+ backdrop-filter: blur(2px);
307
+ }
308
+
309
+ @starting-style {
310
+ .ui-modal[open]::backdrop {
311
+ background-color: transparent;
312
+ backdrop-filter: blur(0px);
313
+ }
314
+ }
315
+
316
+ .ui-modal .ui-modal__box {
317
+ opacity: 0;
318
+ transform: scale(0.95) translateY(10px);
319
+
320
+ transition:
321
+ opacity var(--duration-slow) var(--ease-default),
322
+ transform var(--duration-slow) var(--ease-out-expo);
323
+ }
324
+
325
+ .ui-modal[open] .ui-modal__box {
326
+ opacity: 1;
327
+ transform: scale(1) translateY(0);
328
+ }
329
+
330
+ @starting-style {
331
+ .ui-modal[open] .ui-modal__box {
332
+ opacity: 0;
333
+ transform: scale(0.95) translateY(10px);
334
+ }
335
+ }
336
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Modal } from './Modal.vue'
2
+ export type { ModalProps, ModalSize } from './Modal.vue'