@sabrenski/spire-ui 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/index.d.ts +167 -4
  2. package/dist/spire-ui.css +1 -1
  3. package/dist/spire-ui.es.js +7005 -6741
  4. package/dist/spire-ui.umd.js +10 -10
  5. package/package.json +83 -70
  6. package/src/components/Accordion/AccordionContent.vue +5 -2
  7. package/src/components/Accordion/AccordionItem.vue +4 -0
  8. package/src/components/Accordion/AccordionRoot.vue +4 -2
  9. package/src/components/Accordion/AccordionTrigger.vue +4 -1
  10. package/src/components/Avatar/Avatar.vue +4 -0
  11. package/src/components/Badge/Badge.vue +4 -0
  12. package/src/components/BadgeContainer/BadgeContainer.vue +4 -1
  13. package/src/components/Breadcrumb/BreadcrumbLink.vue +4 -1
  14. package/src/components/Breadcrumb/BreadcrumbRoot.vue +4 -1
  15. package/src/components/Button/Button.vue +5 -1
  16. package/src/components/Callout/Callout.vue +4 -0
  17. package/src/components/Card/Card.vue +5 -1
  18. package/src/components/Card/CardContent.vue +5 -1
  19. package/src/components/Card/CardFooter.vue +5 -1
  20. package/src/components/Card/CardHeader.vue +5 -1
  21. package/src/components/Card/CardImage.vue +4 -2
  22. package/src/components/Chart/BarChart.vue +4 -0
  23. package/src/components/Chart/BaseChart.vue +52 -47
  24. package/src/components/Chart/DonutChart.vue +4 -2
  25. package/src/components/Chart/LineChart.vue +4 -0
  26. package/src/components/Checkbox/Checkbox.test.ts +94 -0
  27. package/src/components/Checkbox/Checkbox.vue +170 -1
  28. package/src/components/ChoiceChip/ChoiceChip.vue +11 -5
  29. package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +4 -2
  30. package/src/components/ColorPicker/ColorArea.vue +4 -2
  31. package/src/components/ColorPicker/ColorPicker.vue +4 -2
  32. package/src/components/ColorPicker/ColorSlider.vue +5 -1
  33. package/src/components/Combobox/Combobox.vue +97 -91
  34. package/src/components/DataTable/DataTable.vue +5 -1
  35. package/src/components/DatePicker/DatePicker.vue +5 -1
  36. package/src/components/Drawer/Drawer.vue +4 -1
  37. package/src/components/Dropdown/Dropdown.vue +4 -2
  38. package/src/components/Dropdown/DropdownItem.vue +4 -0
  39. package/src/components/Dropdown/DropdownSubTrigger.vue +4 -2
  40. package/src/components/EmptyState/EmptyState.vue +5 -1
  41. package/src/components/FileUpload/FileUpload.vue +12 -6
  42. package/src/components/Heading/Heading.vue +4 -0
  43. package/src/components/Icon/Icon.vue +5 -2
  44. package/src/components/Input/Input.vue +5 -1
  45. package/src/components/Layout/Container.vue +4 -0
  46. package/src/components/Layout/Grid.vue +4 -1
  47. package/src/components/Layout/GridItem.vue +4 -1
  48. package/src/components/Layout/Stack.vue +4 -0
  49. package/src/components/Modal/Modal.test.ts +68 -13
  50. package/src/components/Modal/Modal.vue +94 -91
  51. package/src/components/Pagination/Pagination.vue +5 -1
  52. package/src/components/Popover/Popover.vue +4 -1
  53. package/src/components/Progress/Progress.vue +5 -0
  54. package/src/components/Radio/Radio.test.ts +88 -0
  55. package/src/components/Radio/Radio.vue +169 -1
  56. package/src/components/Rating/Rating.vue +5 -1
  57. package/src/components/SegmentedControl/SegmentedControl.vue +5 -1
  58. package/src/components/Select/Select.vue +61 -55
  59. package/src/components/Sidebar/SidebarGroup.vue +4 -0
  60. package/src/components/Sidebar/SidebarItem.vue +4 -0
  61. package/src/components/Sidebar/SidebarLayout.vue +5 -2
  62. package/src/components/Sidebar/SidebarRoot.vue +4 -2
  63. package/src/components/Skeleton/Skeleton.vue +5 -1
  64. package/src/components/Slider/Slider.vue +5 -1
  65. package/src/components/Spinner/Spinner.vue +4 -1
  66. package/src/components/SpireProvider/SpireProvider.vue +4 -1
  67. package/src/components/Stepper/StepperItem.vue +4 -0
  68. package/src/components/Stepper/StepperRoot.vue +4 -2
  69. package/src/components/Stepper/StepperTrigger.vue +6 -2
  70. package/src/components/Switch/Switch.vue +5 -1
  71. package/src/components/Tabs/Tabs.vue +4 -1
  72. package/src/components/Text/Text.vue +4 -0
  73. package/src/components/Textarea/Textarea.vue +13 -7
  74. package/src/components/TimePicker/TimePicker.vue +5 -1
  75. package/src/components/Timeline/Timeline.vue +4 -0
  76. package/src/components/Timeline/TimelineItem.vue +4 -0
  77. package/src/components/Toast/ToastItem.vue +5 -1
  78. package/src/components/Toast/ToastProvider.vue +5 -3
  79. package/src/components/ToggleButton/ToggleButton.vue +5 -1
  80. package/src/components/ToggleGroup/ToggleGroup.vue +5 -1
  81. package/src/components/Tooltip/Tooltip.vue +9 -1
  82. package/src/components/TreeView/TreeView.vue +4 -1
  83. package/src/components/TreeView/TreeViewItem.vue +4 -0
  84. package/src/index.ts +3 -0
  85. package/src/styles/main.css +21 -21
  86. package/src/types/common.ts +4 -0
@@ -2,6 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { mount } from '@vue/test-utils'
3
3
  import Modal from './Modal.vue'
4
4
 
5
+ const globalConfig = {
6
+ global: {
7
+ stubs: {
8
+ teleport: true
9
+ }
10
+ }
11
+ }
12
+
5
13
  // Track open state per dialog element using WeakMap (module-level to persist across mocks)
6
14
  const openStates = new WeakMap<HTMLDialogElement, boolean>()
7
15
 
@@ -40,18 +48,33 @@ afterEach(() => {
40
48
 
41
49
  describe('Modal', () => {
42
50
  describe('Rendering', () => {
43
- it('renders as a dialog element', () => {
44
- const wrapper = mount(Modal)
51
+ it('renders as a dialog element when open', () => {
52
+ const wrapper = mount(Modal, {
53
+ ...globalConfig,
54
+ props: { modelValue: true }
55
+ })
45
56
  expect(wrapper.find('dialog').exists()).toBe(true)
46
57
  })
47
58
 
59
+ it('does not render dialog when closed', () => {
60
+ const wrapper = mount(Modal, {
61
+ ...globalConfig,
62
+ props: { modelValue: false }
63
+ })
64
+ expect(wrapper.find('dialog').exists()).toBe(false)
65
+ })
66
+
48
67
  it('renders with ui-modal class', () => {
49
- const wrapper = mount(Modal)
68
+ const wrapper = mount(Modal, {
69
+ ...globalConfig,
70
+ props: { modelValue: true }
71
+ })
50
72
  expect(wrapper.find('.ui-modal').exists()).toBe(true)
51
73
  })
52
74
 
53
75
  it('renders slot content in body', () => {
54
76
  const wrapper = mount(Modal, {
77
+ ...globalConfig,
55
78
  props: { modelValue: true },
56
79
  slots: {
57
80
  default: '<p>Modal content</p>'
@@ -62,6 +85,7 @@ describe('Modal', () => {
62
85
 
63
86
  it('renders title when provided', () => {
64
87
  const wrapper = mount(Modal, {
88
+ ...globalConfig,
65
89
  props: { modelValue: true, title: 'My Modal' }
66
90
  })
67
91
  expect(wrapper.find('.ui-modal__title').text()).toBe('My Modal')
@@ -69,6 +93,7 @@ describe('Modal', () => {
69
93
 
70
94
  it('does not render header when no title or header slot', () => {
71
95
  const wrapper = mount(Modal, {
96
+ ...globalConfig,
72
97
  props: { modelValue: true }
73
98
  })
74
99
  expect(wrapper.find('.ui-modal__header').exists()).toBe(false)
@@ -76,6 +101,7 @@ describe('Modal', () => {
76
101
 
77
102
  it('renders header slot instead of title', () => {
78
103
  const wrapper = mount(Modal, {
104
+ ...globalConfig,
79
105
  props: { modelValue: true },
80
106
  slots: {
81
107
  header: '<span class="custom-header">Custom Header</span>'
@@ -86,6 +112,7 @@ describe('Modal', () => {
86
112
 
87
113
  it('renders footer slot when provided', () => {
88
114
  const wrapper = mount(Modal, {
115
+ ...globalConfig,
89
116
  props: { modelValue: true },
90
117
  slots: {
91
118
  footer: '<button>Save</button>'
@@ -97,6 +124,7 @@ describe('Modal', () => {
97
124
 
98
125
  it('does not render footer when no slot provided', () => {
99
126
  const wrapper = mount(Modal, {
127
+ ...globalConfig,
100
128
  props: { modelValue: true }
101
129
  })
102
130
  expect(wrapper.find('.ui-modal__footer').exists()).toBe(false)
@@ -106,33 +134,40 @@ describe('Modal', () => {
106
134
  describe('Sizes', () => {
107
135
  it('applies sm size class', () => {
108
136
  const wrapper = mount(Modal, {
109
- props: { size: 'sm' }
137
+ ...globalConfig,
138
+ props: { modelValue: true, size: 'sm' }
110
139
  })
111
140
  expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--sm')
112
141
  })
113
142
 
114
143
  it('applies md size class by default', () => {
115
- const wrapper = mount(Modal)
144
+ const wrapper = mount(Modal, {
145
+ ...globalConfig,
146
+ props: { modelValue: true }
147
+ })
116
148
  expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--md')
117
149
  })
118
150
 
119
151
  it('applies lg size class', () => {
120
152
  const wrapper = mount(Modal, {
121
- props: { size: 'lg' }
153
+ ...globalConfig,
154
+ props: { modelValue: true, size: 'lg' }
122
155
  })
123
156
  expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--lg')
124
157
  })
125
158
 
126
159
  it('applies xl size class', () => {
127
160
  const wrapper = mount(Modal, {
128
- props: { size: 'xl' }
161
+ ...globalConfig,
162
+ props: { modelValue: true, size: 'xl' }
129
163
  })
130
164
  expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--xl')
131
165
  })
132
166
 
133
167
  it('applies full size class', () => {
134
168
  const wrapper = mount(Modal, {
135
- props: { size: 'full' }
169
+ ...globalConfig,
170
+ props: { modelValue: true, size: 'full' }
136
171
  })
137
172
  expect(wrapper.find('.ui-modal').classes()).toContain('ui-modal--full')
138
173
  })
@@ -141,26 +176,30 @@ describe('Modal', () => {
141
176
  describe('Open/Close', () => {
142
177
  it('calls showModal when modelValue becomes true', async () => {
143
178
  const wrapper = mount(Modal, {
179
+ ...globalConfig,
144
180
  props: { modelValue: false }
145
181
  })
146
182
 
147
183
  await wrapper.setProps({ modelValue: true })
184
+ await wrapper.vm.$nextTick()
148
185
 
149
186
  expect(showModalMock).toHaveBeenCalled()
150
187
  })
151
188
 
152
- it('calls close when modelValue becomes false', async () => {
189
+ it('removes dialog from DOM when modelValue becomes false', async () => {
153
190
  const wrapper = mount(Modal, {
191
+ ...globalConfig,
154
192
  props: { modelValue: true }
155
193
  })
156
194
 
195
+ expect(wrapper.find('dialog').exists()).toBe(true)
157
196
  await wrapper.setProps({ modelValue: false })
158
-
159
- expect(closeMock).toHaveBeenCalled()
197
+ expect(wrapper.find('dialog').exists()).toBe(false)
160
198
  })
161
199
 
162
200
  it('locks body scroll when opened', async () => {
163
201
  const wrapper = mount(Modal, {
202
+ ...globalConfig,
164
203
  props: { modelValue: false }
165
204
  })
166
205
 
@@ -171,6 +210,7 @@ describe('Modal', () => {
171
210
 
172
211
  it('unlocks body scroll when closed', async () => {
173
212
  const wrapper = mount(Modal, {
213
+ ...globalConfig,
174
214
  props: { modelValue: true }
175
215
  })
176
216
 
@@ -183,6 +223,7 @@ describe('Modal', () => {
183
223
  describe('Close button', () => {
184
224
  it('renders close button when header is visible', () => {
185
225
  const wrapper = mount(Modal, {
226
+ ...globalConfig,
186
227
  props: { modelValue: true, title: 'Test' }
187
228
  })
188
229
  expect(wrapper.find('.ui-modal__close').exists()).toBe(true)
@@ -190,6 +231,7 @@ describe('Modal', () => {
190
231
 
191
232
  it('emits update:modelValue false when close button clicked', async () => {
192
233
  const wrapper = mount(Modal, {
234
+ ...globalConfig,
193
235
  props: { modelValue: true, title: 'Test' }
194
236
  })
195
237
 
@@ -200,6 +242,7 @@ describe('Modal', () => {
200
242
 
201
243
  it('has aria-label', () => {
202
244
  const wrapper = mount(Modal, {
245
+ ...globalConfig,
203
246
  props: { modelValue: true, title: 'Test' }
204
247
  })
205
248
  expect(wrapper.find('.ui-modal__close').attributes('aria-label')).toBe('Close modal')
@@ -209,6 +252,7 @@ describe('Modal', () => {
209
252
  describe('Backdrop click', () => {
210
253
  it('emits update:modelValue false when backdrop clicked', async () => {
211
254
  const wrapper = mount(Modal, {
255
+ ...globalConfig,
212
256
  props: { modelValue: true, title: 'Test' }
213
257
  })
214
258
 
@@ -221,6 +265,7 @@ describe('Modal', () => {
221
265
 
222
266
  it('does not close when content clicked', async () => {
223
267
  const wrapper = mount(Modal, {
268
+ ...globalConfig,
224
269
  props: { modelValue: true, title: 'Test' }
225
270
  })
226
271
 
@@ -231,6 +276,7 @@ describe('Modal', () => {
231
276
 
232
277
  it('does not close on backdrop click when persistent', async () => {
233
278
  const wrapper = mount(Modal, {
279
+ ...globalConfig,
234
280
  props: { modelValue: true, title: 'Test', persistent: true }
235
281
  })
236
282
 
@@ -244,6 +290,7 @@ describe('Modal', () => {
244
290
  describe('Escape key', () => {
245
291
  it('emits update:modelValue false on cancel event', async () => {
246
292
  const wrapper = mount(Modal, {
293
+ ...globalConfig,
247
294
  props: { modelValue: true, title: 'Test' }
248
295
  })
249
296
 
@@ -254,6 +301,7 @@ describe('Modal', () => {
254
301
 
255
302
  it('prevents default on cancel when persistent', async () => {
256
303
  const wrapper = mount(Modal, {
304
+ ...globalConfig,
257
305
  props: { modelValue: true, title: 'Test', persistent: true }
258
306
  })
259
307
 
@@ -269,6 +317,7 @@ describe('Modal', () => {
269
317
  describe('Close event', () => {
270
318
  it('emits close event on dialog close', async () => {
271
319
  const wrapper = mount(Modal, {
320
+ ...globalConfig,
272
321
  props: { modelValue: true, title: 'Test' }
273
322
  })
274
323
 
@@ -279,6 +328,7 @@ describe('Modal', () => {
279
328
 
280
329
  it('syncs modelValue on dialog close', async () => {
281
330
  const wrapper = mount(Modal, {
331
+ ...globalConfig,
282
332
  props: { modelValue: true, title: 'Test' }
283
333
  })
284
334
 
@@ -289,13 +339,17 @@ describe('Modal', () => {
289
339
  })
290
340
 
291
341
  describe('Accessibility', () => {
292
- it('uses native dialog element', () => {
293
- const wrapper = mount(Modal)
342
+ it('uses native dialog element when open', () => {
343
+ const wrapper = mount(Modal, {
344
+ ...globalConfig,
345
+ props: { modelValue: true }
346
+ })
294
347
  expect(wrapper.find('dialog').exists()).toBe(true)
295
348
  })
296
349
 
297
350
  it('close button has aria-label', () => {
298
351
  const wrapper = mount(Modal, {
352
+ ...globalConfig,
299
353
  props: { modelValue: true, title: 'Test' }
300
354
  })
301
355
  expect(wrapper.find('.ui-modal__close').attributes('aria-label')).toBe('Close modal')
@@ -303,6 +357,7 @@ describe('Modal', () => {
303
357
 
304
358
  it('close button icon is aria-hidden', () => {
305
359
  const wrapper = mount(Modal, {
360
+ ...globalConfig,
306
361
  props: { modelValue: true, title: 'Test' }
307
362
  })
308
363
  expect(wrapper.find('.ui-modal__close svg').attributes('aria-hidden')).toBe('true')
@@ -1,10 +1,13 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, watch, onMounted, useSlots } from 'vue'
2
+ import { ref, computed, watch, onMounted, nextTick, useSlots } from 'vue'
3
3
  import { useScrollLock } from '../../composables'
4
+ import type { ClassValue } from '../../types/common'
4
5
 
5
6
  export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
6
7
 
7
8
  export interface ModalProps {
9
+ /** Additional CSS classes */
10
+ class?: ClassValue
8
11
  /** Controls visibility (v-model) */
9
12
  modelValue?: boolean
10
13
  /** Optional header title */
@@ -57,27 +60,23 @@ function handleClose() {
57
60
  emit('close')
58
61
  }
59
62
 
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
- }
63
+ async function openDialog() {
64
+ await nextTick()
65
+ dialogRef.value?.showModal()
69
66
  }
70
67
 
71
68
  onMounted(() => {
72
69
  if (props.modelValue) {
73
- syncDialogState(true)
70
+ openDialog()
74
71
  }
75
72
  })
76
73
 
77
74
  watch(
78
75
  () => props.modelValue,
79
76
  (open) => {
80
- syncDialogState(open)
77
+ if (open) {
78
+ openDialog()
79
+ }
81
80
  }
82
81
  )
83
82
 
@@ -85,48 +84,50 @@ const hasHeader = () => props.title || slots.header
85
84
  </script>
86
85
 
87
86
  <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"
87
+ <Teleport to="body">
88
+ <dialog
89
+ v-if="modelValue"
90
+ ref="dialogRef"
91
+ :class="[props.class, 'ui-modal', `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"
115
106
  >
116
- <path d="M6 18L18 6M6 6l12 12" />
117
- </svg>
118
- </button>
119
- </header>
120
-
121
- <div class="ui-modal__body">
122
- <slot />
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>
123
128
  </div>
124
-
125
- <footer v-if="$slots.footer" class="ui-modal__footer">
126
- <slot name="footer" />
127
- </footer>
128
- </div>
129
- </dialog>
129
+ </dialog>
130
+ </Teleport>
130
131
  </template>
131
132
 
132
133
  <style scoped>
@@ -283,54 +284,56 @@ const hasHeader = () => props.title || slots.header
283
284
  </style>
284
285
 
285
286
  <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
- }
287
+ @layer spire-components {
288
+ .ui-modal {
289
+ transition:
290
+ opacity var(--duration-slow) var(--ease-default),
291
+ transform var(--duration-slow) var(--ease-out-expo),
292
+ overlay var(--duration-slow) var(--ease-default) allow-discrete,
293
+ display var(--duration-slow) var(--ease-default) allow-discrete;
294
+ }
308
295
 
309
- @starting-style {
310
- .ui-modal[open]::backdrop {
296
+ .ui-modal::backdrop {
311
297
  background-color: transparent;
312
298
  backdrop-filter: blur(0px);
299
+ transition:
300
+ background-color var(--duration-slow) var(--ease-default),
301
+ backdrop-filter var(--duration-slow) var(--ease-default),
302
+ overlay var(--duration-slow) allow-discrete,
303
+ display var(--duration-slow) allow-discrete;
313
304
  }
314
- }
315
-
316
- .ui-modal .ui-modal__box {
317
- opacity: 0;
318
- transform: scale(0.95) translateY(10px);
319
305
 
320
- transition:
321
- opacity var(--duration-slow) var(--ease-default),
322
- transform var(--duration-slow) var(--ease-out-expo);
323
- }
306
+ .ui-modal[open]::backdrop {
307
+ background-color: var(--modal-backdrop);
308
+ backdrop-filter: blur(2px);
309
+ }
324
310
 
325
- .ui-modal[open] .ui-modal__box {
326
- opacity: 1;
327
- transform: scale(1) translateY(0);
328
- }
311
+ @starting-style {
312
+ .ui-modal[open]::backdrop {
313
+ background-color: transparent;
314
+ backdrop-filter: blur(0px);
315
+ }
316
+ }
329
317
 
330
- @starting-style {
331
- .ui-modal[open] .ui-modal__box {
318
+ .ui-modal .ui-modal__box {
332
319
  opacity: 0;
333
320
  transform: scale(0.95) translateY(10px);
321
+
322
+ transition:
323
+ opacity var(--duration-slow) var(--ease-default),
324
+ transform var(--duration-slow) var(--ease-out-expo);
325
+ }
326
+
327
+ .ui-modal[open] .ui-modal__box {
328
+ opacity: 1;
329
+ transform: scale(1) translateY(0);
330
+ }
331
+
332
+ @starting-style {
333
+ .ui-modal[open] .ui-modal__box {
334
+ opacity: 0;
335
+ transform: scale(0.95) translateY(10px);
336
+ }
334
337
  }
335
338
  }
336
339
  </style>
@@ -3,11 +3,14 @@ import { computed } from 'vue'
3
3
  import Button from '../Button/Button.vue'
4
4
  import { useInternalIcon } from '../../config/icons'
5
5
  import { generatePageRange, type PageItem } from './utils'
6
+ import type { ClassValue } from '../../types/common'
6
7
 
7
8
  const ChevronLeftIcon = useInternalIcon('chevronLeft')
8
9
  const ChevronRightIcon = useInternalIcon('chevronRight')
9
10
 
10
11
  export interface PaginationProps {
12
+ /** Additional CSS classes */
13
+ class?: ClassValue
11
14
  /** Current active page (v-model) */
12
15
  modelValue: number
13
16
  /** Total count of items */
@@ -86,8 +89,9 @@ const buttonSize = computed(() => {
86
89
 
87
90
  <template>
88
91
  <nav
89
- class="ui-pagination"
90
92
  :class="[
93
+ props.class,
94
+ 'ui-pagination',
91
95
  `ui-pagination--${size}`,
92
96
  { 'ui-pagination--disabled': disabled }
93
97
  ]"
@@ -8,6 +8,7 @@ import {
8
8
  onUnmounted
9
9
  } from 'vue'
10
10
  import { useId, useScrollLock } from '../../composables'
11
+ import type { ClassValue } from '../../types/common'
11
12
 
12
13
  export type PopoverPlacement =
13
14
  | 'top' | 'top-start' | 'top-end'
@@ -16,6 +17,8 @@ export type PopoverPlacement =
16
17
  | 'right' | 'right-start' | 'right-end'
17
18
 
18
19
  export interface PopoverProps {
20
+ /** Additional CSS classes */
21
+ class?: ClassValue
19
22
  /** Popover placement relative to trigger */
20
23
  placement?: PopoverPlacement
21
24
  /** Offset from trigger (px) */
@@ -305,7 +308,7 @@ defineExpose({
305
308
  </script>
306
309
 
307
310
  <template>
308
- <div class="ui-popover">
311
+ <div :class="[props.class, 'ui-popover']">
309
312
  <div
310
313
  ref="triggerRef"
311
314
  class="ui-popover__trigger"
@@ -1,11 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import type { ClassValue } from '../../types/common'
3
4
 
4
5
  export type ProgressVariant = 'linear' | 'circular'
5
6
  export type ProgressSize = 'sm' | 'md' | 'lg'
6
7
  export type ProgressColor = 'primary' | 'success' | 'warning' | 'error'
7
8
 
8
9
  export interface ProgressProps {
10
+ /** Additional CSS classes */
11
+ class?: ClassValue
9
12
  /** Current progress value (0-100) */
10
13
  value?: number
11
14
  /** Display variant */
@@ -42,6 +45,7 @@ const props = withDefaults(defineProps<ProgressProps>(), {
42
45
  const clampedValue = computed(() => Math.min(100, Math.max(0, props.value)))
43
46
 
44
47
  const linearClasses = computed(() => [
48
+ props.class,
45
49
  'ui-progress-linear',
46
50
  `ui-progress-linear--${props.size}`,
47
51
  `ui-progress-linear--${props.color}`,
@@ -53,6 +57,7 @@ const linearClasses = computed(() => [
53
57
  ])
54
58
 
55
59
  const circularClasses = computed(() => [
60
+ props.class,
56
61
  'ui-progress-circular',
57
62
  `ui-progress-circular--${props.size}`,
58
63
  `ui-progress-circular--${props.color}`,
@@ -213,4 +213,92 @@ describe('Radio', () => {
213
213
  expect(wrapper.find('.ui-radio__box').attributes('aria-hidden')).toBe('true')
214
214
  })
215
215
  })
216
+
217
+ describe('Pill variant', () => {
218
+ it('renders pill structure when variant is pill', () => {
219
+ const wrapper = mount(Radio, {
220
+ props: { value: 'a', variant: 'pill', label: 'Option A' }
221
+ })
222
+ expect(wrapper.find('.ui-radio-pill').exists()).toBe(true)
223
+ expect(wrapper.find('.ui-radio').exists()).toBe(false)
224
+ })
225
+
226
+ it('renders default structure when variant is default', () => {
227
+ const wrapper = mount(Radio, {
228
+ props: { value: 'a', variant: 'default' }
229
+ })
230
+ expect(wrapper.find('.ui-radio').exists()).toBe(true)
231
+ expect(wrapper.find('.ui-radio-pill').exists()).toBe(false)
232
+ })
233
+
234
+ it('defaults to default variant', () => {
235
+ const wrapper = mount(Radio, {
236
+ props: { value: 'a' }
237
+ })
238
+ expect(wrapper.find('.ui-radio').exists()).toBe(true)
239
+ expect(wrapper.find('.ui-radio-pill').exists()).toBe(false)
240
+ })
241
+
242
+ it('renders pill label', () => {
243
+ const wrapper = mount(Radio, {
244
+ props: { value: 'a', variant: 'pill', label: 'Option A' }
245
+ })
246
+ expect(wrapper.find('.ui-radio-pill__label').text()).toBe('Option A')
247
+ })
248
+
249
+ it('renders pill check indicator', () => {
250
+ const wrapper = mount(Radio, {
251
+ props: { value: 'a', variant: 'pill', label: 'Option A' }
252
+ })
253
+ expect(wrapper.find('.ui-radio-pill__indicator').exists()).toBe(true)
254
+ expect(wrapper.find('.ui-radio-pill__check').exists()).toBe(true)
255
+ })
256
+
257
+ it('applies checked class when checked', () => {
258
+ const wrapper = mount(Radio, {
259
+ props: { value: 'a', modelValue: 'a', variant: 'pill', label: 'Option A' }
260
+ })
261
+ expect(wrapper.find('.ui-radio-pill').classes()).toContain('ui-radio-pill--checked')
262
+ })
263
+
264
+ it('does not apply checked class when unchecked', () => {
265
+ const wrapper = mount(Radio, {
266
+ props: { value: 'a', modelValue: 'b', variant: 'pill', label: 'Option A' }
267
+ })
268
+ expect(wrapper.find('.ui-radio-pill').classes()).not.toContain('ui-radio-pill--checked')
269
+ })
270
+
271
+ it('applies disabled class when disabled', () => {
272
+ const wrapper = mount(Radio, {
273
+ props: { value: 'a', variant: 'pill', label: 'Option A', disabled: true }
274
+ })
275
+ expect(wrapper.find('.ui-radio-pill').classes()).toContain('ui-radio-pill--disabled')
276
+ })
277
+
278
+ it('emits events on pill change', async () => {
279
+ const wrapper = mount(Radio, {
280
+ props: { value: 'a', modelValue: 'b', variant: 'pill', label: 'Option A' }
281
+ })
282
+ await wrapper.find('input').trigger('change')
283
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['a'])
284
+ expect(wrapper.emitted('change')?.[0]).toEqual(['a'])
285
+ })
286
+
287
+ const sizes = ['sm', 'md', 'lg'] as const
288
+ sizes.forEach(size => {
289
+ it(`applies ${size} size class to pill`, () => {
290
+ const wrapper = mount(Radio, {
291
+ props: { value: 'a', variant: 'pill', label: 'Option A', size }
292
+ })
293
+ expect(wrapper.find('.ui-radio-pill').classes()).toContain(`ui-radio-pill--${size}`)
294
+ })
295
+ })
296
+
297
+ it('pill indicator has aria-hidden', () => {
298
+ const wrapper = mount(Radio, {
299
+ props: { value: 'a', variant: 'pill', label: 'Option A' }
300
+ })
301
+ expect(wrapper.find('.ui-radio-pill__indicator').attributes('aria-hidden')).toBe('true')
302
+ })
303
+ })
216
304
  })