@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
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@sabrenski/spire-ui",
3
+ "version": "0.0.1",
4
+ "description": "A dependency-free Vue 3 component library built with pure CSS and OKLCH colors",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Sabri Stratos",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sabristratos/spire-ui.git"
11
+ },
12
+ "homepage": "https://github.com/sabristratos/spire-ui#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/sabristratos/spire-ui/issues"
15
+ },
16
+ "keywords": [
17
+ "vue",
18
+ "vue3",
19
+ "components",
20
+ "ui",
21
+ "component-library",
22
+ "design-system",
23
+ "typescript",
24
+ "oklch",
25
+ "css"
26
+ ],
27
+ "files": [
28
+ "dist",
29
+ "src"
30
+ ],
31
+ "main": "./src/index.ts",
32
+ "module": "./src/index.ts",
33
+ "types": "./src/index.ts",
34
+ "exports": {
35
+ ".": {
36
+ "import": "./src/index.ts",
37
+ "types": "./src/index.ts"
38
+ },
39
+ "./style.css": "./src/styles/main.css",
40
+ "./src/*": "./src/*"
41
+ },
42
+ "publishConfig": {
43
+ "main": "./dist/spire-ui.umd.js",
44
+ "module": "./dist/spire-ui.es.js",
45
+ "types": "./dist/index.d.ts",
46
+ "exports": {
47
+ ".": {
48
+ "import": "./dist/spire-ui.es.js",
49
+ "require": "./dist/spire-ui.umd.js",
50
+ "types": "./dist/index.d.ts"
51
+ },
52
+ "./style.css": "./dist/spire-ui.css"
53
+ }
54
+ },
55
+ "scripts": {
56
+ "build": "vite build",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest",
59
+ "typecheck": "vue-tsc --noEmit",
60
+ "docs:tokens": "npx tsx scripts/generate-token-docs.ts"
61
+ },
62
+ "peerDependencies": {
63
+ "chart.js": "^4.4.0",
64
+ "vue": "^3.3.0"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "chart.js": {
68
+ "optional": true
69
+ }
70
+ },
71
+ "devDependencies": {
72
+ "@vitejs/plugin-vue": "^5.2.1",
73
+ "@vue/test-utils": "^2.4.6",
74
+ "chart.js": "^4.5.1",
75
+ "happy-dom": "^20.3.0",
76
+ "typescript": "^5.7.2",
77
+ "vite": "^6.0.7",
78
+ "vite-plugin-dts": "^4.4.0",
79
+ "vitest": "^4.0.17",
80
+ "vue": "^3.5.13",
81
+ "vue-tsc": "^2.2.0"
82
+ }
83
+ }
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { h, ref, defineComponent } from 'vue'
4
+ import AccordionRoot from './AccordionRoot.vue'
5
+ import AccordionItem from './AccordionItem.vue'
6
+ import AccordionTrigger from './AccordionTrigger.vue'
7
+ import AccordionContent from './AccordionContent.vue'
8
+
9
+ function createAccordion(props: Record<string, unknown> = {}) {
10
+ return mount(AccordionRoot, {
11
+ props,
12
+ slots: {
13
+ default: () => [
14
+ h(AccordionItem, { value: 'item-1' }, {
15
+ default: () => [
16
+ h(AccordionTrigger, null, { default: () => 'Item 1' }),
17
+ h(AccordionContent, null, { default: () => 'Content 1' })
18
+ ]
19
+ }),
20
+ h(AccordionItem, { value: 'item-2' }, {
21
+ default: () => [
22
+ h(AccordionTrigger, null, { default: () => 'Item 2' }),
23
+ h(AccordionContent, null, { default: () => 'Content 2' })
24
+ ]
25
+ }),
26
+ h(AccordionItem, { value: 'item-3', disabled: true }, {
27
+ default: () => [
28
+ h(AccordionTrigger, null, { default: () => 'Item 3' }),
29
+ h(AccordionContent, null, { default: () => 'Content 3' })
30
+ ]
31
+ })
32
+ ]
33
+ }
34
+ })
35
+ }
36
+
37
+ describe('Accordion', () => {
38
+ describe('Rendering', () => {
39
+ it('renders all accordion items', () => {
40
+ const wrapper = createAccordion()
41
+ expect(wrapper.findAll('.ui-accordion__item')).toHaveLength(3)
42
+ })
43
+
44
+ it('renders triggers with correct text', () => {
45
+ const wrapper = createAccordion()
46
+ const triggers = wrapper.findAll('.ui-accordion__trigger')
47
+ expect(triggers[0].text()).toContain('Item 1')
48
+ expect(triggers[1].text()).toContain('Item 2')
49
+ expect(triggers[2].text()).toContain('Item 3')
50
+ })
51
+
52
+ it('renders chevron by default', () => {
53
+ const wrapper = createAccordion()
54
+ expect(wrapper.find('.ui-accordion__chevron').exists()).toBe(true)
55
+ })
56
+ })
57
+
58
+ describe('Single mode (default)', () => {
59
+ it('opens item when clicked', async () => {
60
+ const wrapper = createAccordion()
61
+ const trigger = wrapper.find('.ui-accordion__trigger')
62
+
63
+ await trigger.trigger('click')
64
+
65
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['item-1'])
66
+ })
67
+
68
+ it('closes open item when clicked again (collapsible)', async () => {
69
+ const wrapper = createAccordion({
70
+ modelValue: 'item-1',
71
+ collapsible: true
72
+ })
73
+
74
+ const trigger = wrapper.find('.ui-accordion__trigger')
75
+ await trigger.trigger('click')
76
+
77
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([undefined])
78
+ })
79
+
80
+ it('does not close last item when not collapsible', async () => {
81
+ const wrapper = createAccordion({
82
+ modelValue: 'item-1',
83
+ collapsible: false
84
+ })
85
+
86
+ const trigger = wrapper.find('.ui-accordion__trigger')
87
+ await trigger.trigger('click')
88
+
89
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
90
+ })
91
+
92
+ it('closes other items when opening a new one', async () => {
93
+ const wrapper = createAccordion({
94
+ modelValue: 'item-1'
95
+ })
96
+
97
+ const triggers = wrapper.findAll('.ui-accordion__trigger')
98
+ await triggers[1].trigger('click')
99
+
100
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['item-2'])
101
+ })
102
+ })
103
+
104
+ describe('Multiple mode', () => {
105
+ it('can open multiple items', async () => {
106
+ const wrapper = createAccordion({
107
+ modelValue: ['item-1'],
108
+ multiple: true
109
+ })
110
+
111
+ const triggers = wrapper.findAll('.ui-accordion__trigger')
112
+ await triggers[1].trigger('click')
113
+
114
+ const emitted = wrapper.emitted('update:modelValue')?.[0]?.[0] as string[]
115
+ expect(emitted).toContain('item-1')
116
+ expect(emitted).toContain('item-2')
117
+ })
118
+
119
+ it('can close one item while others remain open', async () => {
120
+ const wrapper = createAccordion({
121
+ modelValue: ['item-1', 'item-2'],
122
+ multiple: true
123
+ })
124
+
125
+ const triggers = wrapper.findAll('.ui-accordion__trigger')
126
+ await triggers[0].trigger('click')
127
+
128
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['item-2']])
129
+ })
130
+ })
131
+
132
+ describe('Disabled state', () => {
133
+ it('applies disabled class to item', () => {
134
+ const wrapper = createAccordion()
135
+ const items = wrapper.findAll('.ui-accordion__item')
136
+ expect(items[2].classes()).toContain('ui-accordion__item--disabled')
137
+ })
138
+
139
+ it('does not toggle when disabled item is clicked', async () => {
140
+ const wrapper = createAccordion()
141
+ const triggers = wrapper.findAll('.ui-accordion__trigger')
142
+
143
+ await triggers[2].trigger('click')
144
+
145
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
146
+ })
147
+ })
148
+
149
+ describe('Accessibility', () => {
150
+ it('trigger has aria-expanded attribute', () => {
151
+ const wrapper = createAccordion({
152
+ modelValue: 'item-1'
153
+ })
154
+
155
+ const triggers = wrapper.findAll('.ui-accordion__trigger')
156
+ expect(triggers[0].attributes('aria-expanded')).toBe('true')
157
+ expect(triggers[1].attributes('aria-expanded')).toBe('false')
158
+ })
159
+
160
+ it('trigger has aria-controls pointing to content', () => {
161
+ const wrapper = createAccordion()
162
+ const trigger = wrapper.find('.ui-accordion__trigger')
163
+ const content = wrapper.find('.ui-accordion__content')
164
+
165
+ expect(trigger.attributes('aria-controls')).toBe(content.attributes('id'))
166
+ })
167
+
168
+ it('content has role="region"', () => {
169
+ const wrapper = createAccordion()
170
+ const content = wrapper.find('.ui-accordion__content')
171
+ expect(content.attributes('role')).toBe('region')
172
+ })
173
+
174
+ it('content has aria-labelledby pointing to trigger', () => {
175
+ const wrapper = createAccordion()
176
+ const trigger = wrapper.find('.ui-accordion__trigger')
177
+ const content = wrapper.find('.ui-accordion__content')
178
+
179
+ expect(content.attributes('aria-labelledby')).toBe(trigger.attributes('id'))
180
+ })
181
+
182
+ it('trigger is a button element', () => {
183
+ const wrapper = createAccordion()
184
+ const trigger = wrapper.find('.ui-accordion__trigger')
185
+ expect(trigger.element.tagName).toBe('BUTTON')
186
+ })
187
+
188
+ it('trigger has type="button"', () => {
189
+ const wrapper = createAccordion()
190
+ const trigger = wrapper.find('.ui-accordion__trigger')
191
+ expect(trigger.attributes('type')).toBe('button')
192
+ })
193
+ })
194
+
195
+ describe('Data attributes', () => {
196
+ it('item has data-state attribute', () => {
197
+ const wrapper = createAccordion({
198
+ modelValue: 'item-1'
199
+ })
200
+
201
+ const items = wrapper.findAll('.ui-accordion__item')
202
+ expect(items[0].attributes('data-state')).toBe('open')
203
+ expect(items[1].attributes('data-state')).toBe('closed')
204
+ })
205
+ })
206
+
207
+ describe('Chevron', () => {
208
+ it('rotates chevron when open', () => {
209
+ const wrapper = createAccordion({
210
+ modelValue: 'item-1'
211
+ })
212
+
213
+ const chevrons = wrapper.findAll('.ui-accordion__chevron')
214
+ expect(chevrons[0].classes()).toContain('ui-accordion__chevron--open')
215
+ expect(chevrons[1].classes()).not.toContain('ui-accordion__chevron--open')
216
+ })
217
+ })
218
+ })
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ import { inject } from 'vue'
3
+ import { AccordionItemKey } from './keys'
4
+
5
+ export interface AccordionContentProps {
6
+ /** Lazy render - use v-if instead of v-show. Better performance but breaks Ctrl+F search. */
7
+ lazy?: boolean
8
+ }
9
+
10
+ const props = withDefaults(defineProps<AccordionContentProps>(), {
11
+ lazy: false
12
+ })
13
+
14
+ const item = inject(AccordionItemKey)
15
+
16
+ if (!item) {
17
+ throw new Error('AccordionContent must be used within AccordionItem')
18
+ }
19
+
20
+ function onEnter(el: Element) {
21
+ const element = el as HTMLElement
22
+ element.style.height = '0'
23
+ element.style.overflow = 'hidden'
24
+ element.offsetHeight
25
+ element.style.height = `${element.scrollHeight}px`
26
+ }
27
+
28
+ function onAfterEnter(el: Element) {
29
+ const element = el as HTMLElement
30
+ element.style.height = 'auto'
31
+ element.style.overflow = ''
32
+ }
33
+
34
+ function onLeave(el: Element) {
35
+ const element = el as HTMLElement
36
+ element.style.height = `${element.scrollHeight}px`
37
+ element.style.overflow = 'hidden'
38
+ element.offsetHeight
39
+ element.style.height = '0'
40
+ }
41
+
42
+ function onAfterLeave(el: Element) {
43
+ const element = el as HTMLElement
44
+ element.style.height = ''
45
+ element.style.overflow = ''
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <!-- Lazy mode: v-if for performance (destroys DOM when closed) -->
51
+ <Transition
52
+ v-if="lazy"
53
+ name="ui-accordion-content"
54
+ @enter="onEnter"
55
+ @after-enter="onAfterEnter"
56
+ @leave="onLeave"
57
+ @after-leave="onAfterLeave"
58
+ >
59
+ <div
60
+ v-if="item.isOpen.value"
61
+ :id="item.contentId"
62
+ class="ui-accordion__content"
63
+ role="region"
64
+ :aria-labelledby="item.triggerId"
65
+ >
66
+ <div class="ui-accordion__content-inner">
67
+ <slot />
68
+ </div>
69
+ </div>
70
+ </Transition>
71
+
72
+ <!-- Default mode: v-show for searchability (keeps DOM alive) -->
73
+ <Transition
74
+ v-else
75
+ name="ui-accordion-content"
76
+ @enter="onEnter"
77
+ @after-enter="onAfterEnter"
78
+ @leave="onLeave"
79
+ @after-leave="onAfterLeave"
80
+ >
81
+ <div
82
+ v-show="item.isOpen.value"
83
+ :id="item.contentId"
84
+ class="ui-accordion__content"
85
+ role="region"
86
+ :aria-labelledby="item.triggerId"
87
+ >
88
+ <div class="ui-accordion__content-inner">
89
+ <slot />
90
+ </div>
91
+ </div>
92
+ </Transition>
93
+ </template>
94
+
95
+ <style scoped>
96
+ .ui-accordion__content {
97
+ transition: height var(--duration-normal) cubic-bezier(0.16, 1, 0.3, 1);
98
+ }
99
+
100
+ .ui-accordion__content-inner {
101
+ padding-bottom: var(--space-4);
102
+ color: var(--accordion-content, var(--text-secondary));
103
+ font-size: var(--text-sm);
104
+ line-height: var(--leading-relaxed);
105
+ }
106
+
107
+ .ui-accordion-content-enter-active,
108
+ .ui-accordion-content-leave-active {
109
+ transition: height var(--duration-normal) cubic-bezier(0.16, 1, 0.3, 1);
110
+ overflow: hidden;
111
+ }
112
+ </style>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import { computed, provide, inject } from 'vue'
3
+ import { useId } from '../../composables'
4
+ import { AccordionKey, AccordionItemKey } from './keys'
5
+
6
+ export interface AccordionItemProps {
7
+ /** Unique identifier for this item */
8
+ value: string
9
+ /** Disable this item */
10
+ disabled?: boolean
11
+ }
12
+
13
+ const props = withDefaults(defineProps<AccordionItemProps>(), {
14
+ disabled: false
15
+ })
16
+
17
+ const accordion = inject(AccordionKey)
18
+
19
+ if (!accordion) {
20
+ throw new Error('AccordionItem must be used within AccordionRoot')
21
+ }
22
+
23
+ const triggerId = useId('accordion-trigger')
24
+ const contentId = useId('accordion-content')
25
+
26
+ const isOpen = computed(() => accordion.openItems.value.has(props.value))
27
+
28
+ function toggle() {
29
+ if (props.disabled) return
30
+ accordion!.toggle(props.value)
31
+ }
32
+
33
+ provide(AccordionItemKey, {
34
+ value: props.value,
35
+ triggerId,
36
+ contentId,
37
+ isOpen,
38
+ disabled: computed(() => props.disabled),
39
+ toggle
40
+ })
41
+
42
+ const itemClasses = computed(() => [
43
+ 'ui-accordion__item',
44
+ `ui-accordion__item--${accordion.variant.value}`,
45
+ {
46
+ 'ui-accordion__item--disabled': props.disabled,
47
+ 'ui-accordion__item--open': isOpen.value
48
+ }
49
+ ])
50
+ </script>
51
+
52
+ <template>
53
+ <div
54
+ :class="itemClasses"
55
+ :data-state="isOpen ? 'open' : 'closed'"
56
+ >
57
+ <slot />
58
+ </div>
59
+ </template>
60
+
61
+ <style scoped>
62
+ .ui-accordion__item {
63
+ overflow: hidden;
64
+ }
65
+
66
+ .ui-accordion__item--disabled {
67
+ opacity: 0.5;
68
+ }
69
+
70
+ /* Contained variant: dividers between items */
71
+ .ui-accordion__item--contained {
72
+ border-bottom: 1px solid var(--accordion-border, var(--border-default));
73
+ padding: 0 var(--space-4);
74
+ }
75
+
76
+ .ui-accordion__item--contained:last-child {
77
+ border-bottom: none;
78
+ }
79
+
80
+ /* Split variant: separate cards */
81
+ .ui-accordion__item--split {
82
+ border: 1px solid var(--accordion-border, var(--border-default));
83
+ border-radius: var(--radius-lg);
84
+ padding: 0 var(--space-4);
85
+ background: var(--accordion-item-bg, var(--surface-default));
86
+ }
87
+ </style>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import { computed, provide, toRef, ref, watch } from 'vue'
3
+ import { AccordionKey } from './keys'
4
+ import type { AccordionVariant } from './keys'
5
+
6
+ export interface AccordionRootProps {
7
+ /** Currently open item(s) - single value or array for multiple mode */
8
+ modelValue?: string | string[]
9
+ /** Allow all items to be collapsed */
10
+ collapsible?: boolean
11
+ /** Allow multiple items open simultaneously */
12
+ multiple?: boolean
13
+ /** Visual variant */
14
+ variant?: AccordionVariant
15
+ }
16
+
17
+ const props = withDefaults(defineProps<AccordionRootProps>(), {
18
+ collapsible: true,
19
+ multiple: false,
20
+ variant: 'contained'
21
+ })
22
+
23
+ const emit = defineEmits<{
24
+ 'update:modelValue': [value: string | string[] | undefined]
25
+ }>()
26
+
27
+ const internalValue = ref<string | string[] | undefined>(props.modelValue)
28
+
29
+ watch(() => props.modelValue, (newValue) => {
30
+ internalValue.value = newValue
31
+ })
32
+
33
+ const isControlled = computed(() => props.modelValue !== undefined)
34
+
35
+ const openItems = computed(() => {
36
+ const value = isControlled.value ? props.modelValue : internalValue.value
37
+ if (value === undefined) {
38
+ return new Set<string>()
39
+ }
40
+ if (Array.isArray(value)) {
41
+ return new Set(value)
42
+ }
43
+ return new Set([value])
44
+ })
45
+
46
+ function toggle(value: string) {
47
+ const isOpen = openItems.value.has(value)
48
+ let newValue: string | string[] | undefined
49
+
50
+ if (isOpen) {
51
+ if (!props.collapsible && openItems.value.size === 1) {
52
+ return
53
+ }
54
+
55
+ if (props.multiple) {
56
+ const newSet = new Set(openItems.value)
57
+ newSet.delete(value)
58
+ newValue = Array.from(newSet)
59
+ } else {
60
+ newValue = undefined
61
+ }
62
+ } else {
63
+ if (props.multiple) {
64
+ const newSet = new Set(openItems.value)
65
+ newSet.add(value)
66
+ newValue = Array.from(newSet)
67
+ } else {
68
+ newValue = value
69
+ }
70
+ }
71
+
72
+ internalValue.value = newValue
73
+ emit('update:modelValue', newValue)
74
+ }
75
+
76
+ provide(AccordionKey, {
77
+ openItems,
78
+ toggle,
79
+ collapsible: toRef(props, 'collapsible'),
80
+ multiple: toRef(props, 'multiple'),
81
+ variant: toRef(props, 'variant')
82
+ })
83
+ </script>
84
+
85
+ <template>
86
+ <div
87
+ class="ui-accordion"
88
+ :class="[`ui-accordion--${variant}`]"
89
+ >
90
+ <slot />
91
+ </div>
92
+ </template>
93
+
94
+ <style scoped>
95
+ .ui-accordion {
96
+ display: flex;
97
+ flex-direction: column;
98
+ width: 100%;
99
+ }
100
+
101
+ /* Contained variant: single border around group */
102
+ .ui-accordion--contained {
103
+ border: 1px solid var(--accordion-border, var(--border-default));
104
+ border-radius: var(--radius-lg);
105
+ }
106
+
107
+ /* Split variant: no outer border, items are separate cards */
108
+ .ui-accordion--split {
109
+ gap: var(--space-3);
110
+ }
111
+ </style>