@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,154 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Text from './Text.vue'
4
+
5
+ describe('Text', () => {
6
+ describe('Rendering', () => {
7
+ it('renders as p by default', () => {
8
+ const wrapper = mount(Text, {
9
+ slots: { default: 'Hello' }
10
+ })
11
+ expect(wrapper.element.tagName.toLowerCase()).toBe('p')
12
+ })
13
+
14
+ it('renders slot content', () => {
15
+ const wrapper = mount(Text, {
16
+ slots: { default: 'Body text' }
17
+ })
18
+ expect(wrapper.text()).toBe('Body text')
19
+ })
20
+
21
+ it('renders as custom tag via "as" prop', () => {
22
+ const wrapper = mount(Text, {
23
+ props: { as: 'span' },
24
+ slots: { default: 'Inline text' }
25
+ })
26
+ expect(wrapper.element.tagName.toLowerCase()).toBe('span')
27
+ })
28
+
29
+ it('can render as label', () => {
30
+ const wrapper = mount(Text, {
31
+ props: { as: 'label' },
32
+ slots: { default: 'Field label' }
33
+ })
34
+ expect(wrapper.element.tagName.toLowerCase()).toBe('label')
35
+ })
36
+ })
37
+
38
+ describe('Sizes', () => {
39
+ const sizes = ['xs', 'sm', 'base', 'md', 'lg', 'xl'] as const
40
+
41
+ sizes.forEach(size => {
42
+ it(`applies ${size} size class`, () => {
43
+ const wrapper = mount(Text, {
44
+ props: { size },
45
+ slots: { default: 'Text' }
46
+ })
47
+ expect(wrapper.classes()).toContain(`ui-text--${size}`)
48
+ })
49
+ })
50
+
51
+ it('defaults to base size', () => {
52
+ const wrapper = mount(Text, {
53
+ slots: { default: 'Text' }
54
+ })
55
+ expect(wrapper.classes()).toContain('ui-text--base')
56
+ })
57
+ })
58
+
59
+ describe('Weights', () => {
60
+ const weights = ['regular', 'medium', 'semibold', 'bold'] as const
61
+
62
+ weights.forEach(weight => {
63
+ it(`applies ${weight} weight class`, () => {
64
+ const wrapper = mount(Text, {
65
+ props: { weight },
66
+ slots: { default: 'Text' }
67
+ })
68
+ expect(wrapper.classes()).toContain(`ui-text--${weight}`)
69
+ })
70
+ })
71
+
72
+ it('defaults to regular weight', () => {
73
+ const wrapper = mount(Text, {
74
+ slots: { default: 'Text' }
75
+ })
76
+ expect(wrapper.classes()).toContain('ui-text--regular')
77
+ })
78
+ })
79
+
80
+ describe('Alignment', () => {
81
+ const alignments = ['left', 'center', 'right', 'justify'] as const
82
+
83
+ alignments.forEach(align => {
84
+ it(`applies ${align} alignment class`, () => {
85
+ const wrapper = mount(Text, {
86
+ props: { align },
87
+ slots: { default: 'Text' }
88
+ })
89
+ expect(wrapper.classes()).toContain(`ui-text--${align}`)
90
+ })
91
+ })
92
+
93
+ it('defaults to left alignment', () => {
94
+ const wrapper = mount(Text, {
95
+ slots: { default: 'Text' }
96
+ })
97
+ expect(wrapper.classes()).toContain('ui-text--left')
98
+ })
99
+ })
100
+
101
+ describe('Muted variant', () => {
102
+ it('applies muted class when muted prop is true', () => {
103
+ const wrapper = mount(Text, {
104
+ props: { muted: true },
105
+ slots: { default: 'Secondary text' }
106
+ })
107
+ expect(wrapper.classes()).toContain('ui-text--muted')
108
+ })
109
+
110
+ it('does not apply muted class by default', () => {
111
+ const wrapper = mount(Text, {
112
+ slots: { default: 'Text' }
113
+ })
114
+ expect(wrapper.classes()).not.toContain('ui-text--muted')
115
+ })
116
+ })
117
+
118
+ describe('Truncation', () => {
119
+ it('applies truncate class when truncate prop is true', () => {
120
+ const wrapper = mount(Text, {
121
+ props: { truncate: true },
122
+ slots: { default: 'Very long text' }
123
+ })
124
+ expect(wrapper.classes()).toContain('ui-text--truncate')
125
+ })
126
+
127
+ it('does not apply truncate when clamp is set', () => {
128
+ const wrapper = mount(Text, {
129
+ props: { truncate: true, clamp: 2 },
130
+ slots: { default: 'Long text' }
131
+ })
132
+ expect(wrapper.classes()).not.toContain('ui-text--truncate')
133
+ expect(wrapper.classes()).toContain('ui-text--clamp')
134
+ })
135
+ })
136
+
137
+ describe('Line clamping', () => {
138
+ it('applies clamp class when clamp prop is set', () => {
139
+ const wrapper = mount(Text, {
140
+ props: { clamp: 3 },
141
+ slots: { default: 'Multi-line text' }
142
+ })
143
+ expect(wrapper.classes()).toContain('ui-text--clamp')
144
+ })
145
+
146
+ it('accepts different clamp values', () => {
147
+ const wrapper = mount(Text, {
148
+ props: { clamp: 5 },
149
+ slots: { default: 'Multi-line text' }
150
+ })
151
+ expect(wrapper.classes()).toContain('ui-text--clamp')
152
+ })
153
+ })
154
+ })
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ import { computed, type CSSProperties } from 'vue'
3
+
4
+ type TextTag = 'p' | 'span' | 'div' | 'label' | 'li'
5
+ type TextSize = 'xs' | 'sm' | 'base' | 'md' | 'lg' | 'xl'
6
+ type TextWeight = 'regular' | 'medium' | 'semibold' | 'bold'
7
+
8
+ export interface TextProps {
9
+ /** Semantic HTML tag */
10
+ as?: TextTag
11
+ /** Font size */
12
+ size?: TextSize
13
+ /** Font weight */
14
+ weight?: TextWeight
15
+ /** Text alignment */
16
+ align?: 'left' | 'center' | 'right' | 'justify'
17
+ /** Use secondary (muted) color */
18
+ muted?: boolean
19
+ /** Single-line truncation with ellipsis */
20
+ truncate?: boolean
21
+ /** Multi-line clamping (number of lines) */
22
+ clamp?: number
23
+ }
24
+
25
+ const props = withDefaults(defineProps<TextProps>(), {
26
+ as: 'p',
27
+ size: 'base',
28
+ weight: 'regular',
29
+ align: 'left'
30
+ })
31
+
32
+ const classes = computed(() => [
33
+ 'ui-text',
34
+ `ui-text--${props.size}`,
35
+ `ui-text--${props.weight}`,
36
+ `ui-text--${props.align}`,
37
+ {
38
+ 'ui-text--muted': props.muted,
39
+ 'ui-text--truncate': props.truncate && !props.clamp,
40
+ 'ui-text--clamp': props.clamp
41
+ }
42
+ ])
43
+
44
+ const clampStyle = computed(() =>
45
+ props.clamp ? { WebkitLineClamp: props.clamp } as CSSProperties : undefined
46
+ )
47
+ </script>
48
+
49
+ <template>
50
+ <component :is="as" :class="classes" :style="clampStyle">
51
+ <slot />
52
+ </component>
53
+ </template>
54
+
55
+ <style scoped>
56
+ .ui-text {
57
+ font-family: var(--font-sans);
58
+ color: var(--text-primary);
59
+ margin: 0;
60
+ }
61
+
62
+ /* Sizes */
63
+ .ui-text--xs { font-size: var(--text-xs); line-height: 1.4; }
64
+ .ui-text--sm { font-size: var(--text-sm); line-height: 1.4; }
65
+ .ui-text--base { font-size: var(--text-base); line-height: 1.5; }
66
+ .ui-text--md { font-size: var(--text-md); line-height: 1.5; }
67
+ .ui-text--lg { font-size: var(--text-lg); line-height: 1.5; }
68
+ .ui-text--xl { font-size: var(--text-xl); line-height: 1.4; }
69
+
70
+ /* Weights */
71
+ .ui-text--regular { font-weight: 400; }
72
+ .ui-text--medium { font-weight: 500; }
73
+ .ui-text--semibold { font-weight: 600; }
74
+ .ui-text--bold { font-weight: 700; }
75
+
76
+ /* Alignment */
77
+ .ui-text--left { text-align: left; }
78
+ .ui-text--center { text-align: center; }
79
+ .ui-text--right { text-align: right; }
80
+ .ui-text--justify { text-align: justify; }
81
+
82
+ /* Muted variant */
83
+ .ui-text--muted {
84
+ color: var(--text-secondary);
85
+ }
86
+
87
+ /* Single-line truncation */
88
+ .ui-text--truncate {
89
+ overflow: hidden;
90
+ text-overflow: ellipsis;
91
+ white-space: nowrap;
92
+ }
93
+
94
+ /* Multi-line clamping */
95
+ .ui-text--clamp {
96
+ display: -webkit-box;
97
+ -webkit-box-orient: vertical;
98
+ overflow: hidden;
99
+ }
100
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Text } from './Text.vue'
2
+ export type { TextProps } from './Text.vue'
@@ -0,0 +1,432 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import Textarea from './Textarea.vue'
5
+
6
+ describe('Textarea', () => {
7
+ describe('Rendering', () => {
8
+ it('renders a textarea element', () => {
9
+ const wrapper = mount(Textarea)
10
+ expect(wrapper.find('textarea').exists()).toBe(true)
11
+ })
12
+
13
+ it('renders label when provided', () => {
14
+ const wrapper = mount(Textarea, {
15
+ props: { label: 'Description' }
16
+ })
17
+ expect(wrapper.find('label').text()).toContain('Description')
18
+ })
19
+
20
+ it('renders required indicator when required', () => {
21
+ const wrapper = mount(Textarea, {
22
+ props: { label: 'Description', required: true }
23
+ })
24
+ expect(wrapper.find('.ui-textarea-field__required').exists()).toBe(true)
25
+ })
26
+
27
+ it('renders hint text', () => {
28
+ const wrapper = mount(Textarea, {
29
+ props: { hint: 'Enter a detailed description' }
30
+ })
31
+ expect(wrapper.find('.ui-textarea-field__message--hint').text()).toBe('Enter a detailed description')
32
+ })
33
+
34
+ it('renders error message', () => {
35
+ const wrapper = mount(Textarea, {
36
+ props: { error: 'This field is required' }
37
+ })
38
+ expect(wrapper.find('.ui-textarea-field__message--error').text()).toBe('This field is required')
39
+ })
40
+
41
+ it('renders placeholder', () => {
42
+ const wrapper = mount(Textarea, {
43
+ props: { placeholder: 'Enter text...' }
44
+ })
45
+ expect(wrapper.find('textarea').attributes('placeholder')).toBe('Enter text...')
46
+ })
47
+
48
+ it('sets rows attribute', () => {
49
+ const wrapper = mount(Textarea, {
50
+ props: { rows: 5 }
51
+ })
52
+ expect(wrapper.find('textarea').attributes('rows')).toBe('5')
53
+ })
54
+
55
+ it('defaults to 3 rows', () => {
56
+ const wrapper = mount(Textarea)
57
+ expect(wrapper.find('textarea').attributes('rows')).toBe('3')
58
+ })
59
+ })
60
+
61
+ describe('v-model', () => {
62
+ it('binds value correctly', () => {
63
+ const wrapper = mount(Textarea, {
64
+ props: { modelValue: 'Hello world' }
65
+ })
66
+ expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('Hello world')
67
+ })
68
+
69
+ it('emits update:modelValue on input', async () => {
70
+ const wrapper = mount(Textarea)
71
+ const textarea = wrapper.find('textarea')
72
+
73
+ await textarea.setValue('New content')
74
+
75
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
76
+ expect(wrapper.emitted('update:modelValue')![0][0]).toBe('New content')
77
+ })
78
+ })
79
+
80
+ describe('Focus events', () => {
81
+ it('emits focus event', async () => {
82
+ const wrapper = mount(Textarea)
83
+ await wrapper.find('textarea').trigger('focus')
84
+ expect(wrapper.emitted('focus')).toBeTruthy()
85
+ })
86
+
87
+ it('emits blur event', async () => {
88
+ const wrapper = mount(Textarea)
89
+ await wrapper.find('textarea').trigger('blur')
90
+ expect(wrapper.emitted('blur')).toBeTruthy()
91
+ })
92
+ })
93
+
94
+ describe('States', () => {
95
+ it('applies disabled state', () => {
96
+ const wrapper = mount(Textarea, {
97
+ props: { disabled: true }
98
+ })
99
+ expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
100
+ expect(wrapper.find('.ui-textarea-field--disabled').exists()).toBe(true)
101
+ expect(wrapper.find('.ui-textarea-wrapper--disabled').exists()).toBe(true)
102
+ })
103
+
104
+ it('applies readonly state', () => {
105
+ const wrapper = mount(Textarea, {
106
+ props: { readonly: true }
107
+ })
108
+ expect(wrapper.find('textarea').attributes('readonly')).toBeDefined()
109
+ expect(wrapper.find('.ui-textarea-field--readonly').exists()).toBe(true)
110
+ expect(wrapper.find('.ui-textarea-wrapper--readonly').exists()).toBe(true)
111
+ })
112
+
113
+ it('applies error state', () => {
114
+ const wrapper = mount(Textarea, {
115
+ props: { error: 'Error message' }
116
+ })
117
+ expect(wrapper.find('.ui-textarea-field--error').exists()).toBe(true)
118
+ expect(wrapper.find('.ui-textarea-wrapper--error').exists()).toBe(true)
119
+ })
120
+
121
+ it('applies block modifier', () => {
122
+ const wrapper = mount(Textarea, {
123
+ props: { block: true }
124
+ })
125
+ expect(wrapper.find('.ui-textarea-field--block').exists()).toBe(true)
126
+ })
127
+ })
128
+
129
+ describe('Accessibility', () => {
130
+ it('generates unique id for textarea', () => {
131
+ const wrapper = mount(Textarea)
132
+ const id = wrapper.find('textarea').attributes('id')
133
+ expect(id).toMatch(/^textarea-/)
134
+ })
135
+
136
+ it('uses provided id', () => {
137
+ const wrapper = mount(Textarea, {
138
+ props: { id: 'custom-textarea' }
139
+ })
140
+ expect(wrapper.find('textarea').attributes('id')).toBe('custom-textarea')
141
+ })
142
+
143
+ it('links label to textarea', () => {
144
+ const wrapper = mount(Textarea, {
145
+ props: { label: 'Bio', id: 'bio-field' }
146
+ })
147
+ expect(wrapper.find('label').attributes('for')).toBe('bio-field')
148
+ })
149
+
150
+ it('sets aria-invalid when error', () => {
151
+ const wrapper = mount(Textarea, {
152
+ props: { error: 'Required' }
153
+ })
154
+ expect(wrapper.find('textarea').attributes('aria-invalid')).toBe('true')
155
+ })
156
+
157
+ it('links aria-describedby to hint', () => {
158
+ const wrapper = mount(Textarea, {
159
+ props: { hint: 'Help text', id: 'test-id' }
160
+ })
161
+ expect(wrapper.find('textarea').attributes('aria-describedby')).toBe('test-id-hint')
162
+ })
163
+
164
+ it('links aria-describedby to error over hint', () => {
165
+ const wrapper = mount(Textarea, {
166
+ props: { hint: 'Help text', error: 'Error', id: 'test-id' }
167
+ })
168
+ expect(wrapper.find('textarea').attributes('aria-describedby')).toBe('test-id-error')
169
+ })
170
+
171
+ it('links aria-describedby to counter when showCount and maxLength', () => {
172
+ const wrapper = mount(Textarea, {
173
+ props: { showCount: true, maxLength: 100, id: 'test-id' }
174
+ })
175
+ expect(wrapper.find('textarea').attributes('aria-describedby')).toContain('test-id-counter')
176
+ })
177
+ })
178
+
179
+ describe('Character count', () => {
180
+ it('shows character counter when showCount is true', () => {
181
+ const wrapper = mount(Textarea, {
182
+ props: { showCount: true, modelValue: 'Hello' }
183
+ })
184
+ expect(wrapper.find('.ui-textarea-field__counter').exists()).toBe(true)
185
+ expect(wrapper.find('.ui-textarea-field__counter').text()).toBe('5')
186
+ })
187
+
188
+ it('shows count with max length', () => {
189
+ const wrapper = mount(Textarea, {
190
+ props: { showCount: true, maxLength: 100, modelValue: 'Hello' }
191
+ })
192
+ expect(wrapper.find('.ui-textarea-field__counter').text()).toBe('5 / 100')
193
+ })
194
+
195
+ it('applies warning state at 90%', () => {
196
+ const wrapper = mount(Textarea, {
197
+ props: { showCount: true, maxLength: 10, modelValue: '123456789' }
198
+ })
199
+ expect(wrapper.find('.ui-textarea-field__counter--warning').exists()).toBe(true)
200
+ })
201
+
202
+ it('applies error state over limit', () => {
203
+ const wrapper = mount(Textarea, {
204
+ props: { showCount: true, maxLength: 5, modelValue: '123456' }
205
+ })
206
+ expect(wrapper.find('.ui-textarea-field__counter--error').exists()).toBe(true)
207
+ })
208
+
209
+ it('applies error state to wrapper when over limit', () => {
210
+ const wrapper = mount(Textarea, {
211
+ props: { maxLength: 5, modelValue: '123456' }
212
+ })
213
+ expect(wrapper.find('.ui-textarea-field--error').exists()).toBe(true)
214
+ })
215
+
216
+ it('hides counter when showCount is false', () => {
217
+ const wrapper = mount(Textarea, {
218
+ props: { showCount: false, maxLength: 100 }
219
+ })
220
+ expect(wrapper.find('.ui-textarea-field__counter').exists()).toBe(false)
221
+ })
222
+
223
+ it('sets maxlength attribute on textarea', () => {
224
+ const wrapper = mount(Textarea, {
225
+ props: { maxLength: 140 }
226
+ })
227
+ expect(wrapper.find('textarea').attributes('maxlength')).toBe('140')
228
+ })
229
+ })
230
+
231
+ describe('Auto-grow', () => {
232
+ it('applies autosize class when enabled', () => {
233
+ const wrapper = mount(Textarea, {
234
+ props: { autosize: true }
235
+ })
236
+ expect(wrapper.find('.ui-textarea-wrapper--autosize').exists()).toBe(true)
237
+ expect(wrapper.find('.ui-textarea-wrapper__textarea--autosize').exists()).toBe(true)
238
+ })
239
+
240
+ it('does not apply autosize class when disabled', () => {
241
+ const wrapper = mount(Textarea, {
242
+ props: { autosize: false }
243
+ })
244
+ expect(wrapper.find('.ui-textarea-wrapper--autosize').exists()).toBe(false)
245
+ })
246
+
247
+ it('adjusts height on input when autosize is true', async () => {
248
+ const wrapper = mount(Textarea, {
249
+ props: { autosize: true },
250
+ attachTo: document.body
251
+ })
252
+
253
+ const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
254
+
255
+ // Mock scrollHeight
256
+ Object.defineProperty(textarea, 'scrollHeight', {
257
+ value: 100,
258
+ configurable: true
259
+ })
260
+
261
+ await wrapper.find('textarea').setValue('Line 1\nLine 2\nLine 3')
262
+ await nextTick()
263
+
264
+ // Height should be set to scrollHeight
265
+ expect(textarea.style.height).toBe('100px')
266
+
267
+ wrapper.unmount()
268
+ })
269
+
270
+ it('respects maxHeight constraint', async () => {
271
+ const wrapper = mount(Textarea, {
272
+ props: { autosize: true, maxHeight: 150 },
273
+ attachTo: document.body
274
+ })
275
+
276
+ const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
277
+
278
+ // Mock scrollHeight larger than maxHeight
279
+ Object.defineProperty(textarea, 'scrollHeight', {
280
+ value: 200,
281
+ configurable: true
282
+ })
283
+
284
+ await wrapper.find('textarea').setValue('Very long content...')
285
+ await nextTick()
286
+
287
+ // Height should be capped at maxHeight
288
+ expect(textarea.style.height).toBe('150px')
289
+ expect(textarea.style.overflowY).toBe('auto')
290
+
291
+ wrapper.unmount()
292
+ })
293
+ })
294
+
295
+ describe('Resize behavior', () => {
296
+ it('has vertical resize by default', () => {
297
+ const wrapper = mount(Textarea)
298
+ const textarea = wrapper.find('textarea')
299
+ expect(textarea.classes()).not.toContain('ui-textarea-wrapper__textarea--autosize')
300
+ })
301
+
302
+ it('disables resize when autosize is true', () => {
303
+ const wrapper = mount(Textarea, {
304
+ props: { autosize: true }
305
+ })
306
+ expect(wrapper.find('.ui-textarea-wrapper__textarea--autosize').exists()).toBe(true)
307
+ })
308
+
309
+ it('disables resize when disabled', async () => {
310
+ const wrapper = mount(Textarea, {
311
+ props: { disabled: true },
312
+ attachTo: document.body
313
+ })
314
+
315
+ const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
316
+ const styles = window.getComputedStyle(textarea)
317
+
318
+ // The CSS sets resize: none for disabled textareas
319
+ // In JSDOM, we can check the class is applied
320
+ expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
321
+
322
+ wrapper.unmount()
323
+ })
324
+ })
325
+
326
+ describe('Icons', () => {
327
+ it('renders left icon', () => {
328
+ const mockIcon = { template: '<svg class="mock-icon"></svg>' }
329
+ const wrapper = mount(Textarea, {
330
+ props: { iconLeft: mockIcon as any }
331
+ })
332
+ expect(wrapper.find('.ui-textarea-wrapper__addon--left').exists()).toBe(true)
333
+ })
334
+
335
+ it('renders left slot content', () => {
336
+ const wrapper = mount(Textarea, {
337
+ slots: {
338
+ left: '<span class="custom-icon">📝</span>'
339
+ }
340
+ })
341
+ expect(wrapper.find('.ui-textarea-wrapper__addon--left').exists()).toBe(true)
342
+ expect(wrapper.find('.custom-icon').exists()).toBe(true)
343
+ })
344
+
345
+ it('applies has-left class when icon present', () => {
346
+ const mockIcon = { template: '<svg class="mock-icon"></svg>' }
347
+ const wrapper = mount(Textarea, {
348
+ props: { iconLeft: mockIcon as any }
349
+ })
350
+ expect(wrapper.find('.ui-textarea-wrapper--has-left').exists()).toBe(true)
351
+ })
352
+ })
353
+
354
+ describe('Exposed methods', () => {
355
+ it('exposes focus method', async () => {
356
+ const wrapper = mount(Textarea, {
357
+ attachTo: document.body
358
+ })
359
+
360
+ const focusSpy = vi.spyOn(wrapper.find('textarea').element, 'focus')
361
+
362
+ ;(wrapper.vm as any).focus()
363
+
364
+ expect(focusSpy).toHaveBeenCalled()
365
+
366
+ wrapper.unmount()
367
+ })
368
+
369
+ it('exposes blur method', async () => {
370
+ const wrapper = mount(Textarea, {
371
+ attachTo: document.body
372
+ })
373
+
374
+ const blurSpy = vi.spyOn(wrapper.find('textarea').element, 'blur')
375
+
376
+ ;(wrapper.vm as any).blur()
377
+
378
+ expect(blurSpy).toHaveBeenCalled()
379
+
380
+ wrapper.unmount()
381
+ })
382
+
383
+ it('exposes select method', async () => {
384
+ const wrapper = mount(Textarea, {
385
+ props: { modelValue: 'Select me' },
386
+ attachTo: document.body
387
+ })
388
+
389
+ const selectSpy = vi.spyOn(wrapper.find('textarea').element, 'select')
390
+
391
+ ;(wrapper.vm as any).select()
392
+
393
+ expect(selectSpy).toHaveBeenCalled()
394
+
395
+ wrapper.unmount()
396
+ })
397
+ })
398
+
399
+ describe('Name attribute', () => {
400
+ it('sets name attribute', () => {
401
+ const wrapper = mount(Textarea, {
402
+ props: { name: 'bio' }
403
+ })
404
+ expect(wrapper.find('textarea').attributes('name')).toBe('bio')
405
+ })
406
+ })
407
+
408
+ describe('Visual parity with Input', () => {
409
+ it('uses same wrapper pattern class structure', () => {
410
+ const wrapper = mount(Textarea)
411
+ // Should have field and wrapper classes similar to Input
412
+ expect(wrapper.find('.ui-textarea-field').exists()).toBe(true)
413
+ expect(wrapper.find('.ui-textarea-wrapper').exists()).toBe(true)
414
+ })
415
+
416
+ it('applies error state classes consistently', () => {
417
+ const wrapper = mount(Textarea, {
418
+ props: { error: 'Error' }
419
+ })
420
+ expect(wrapper.find('.ui-textarea-field--error').exists()).toBe(true)
421
+ expect(wrapper.find('.ui-textarea-wrapper--error').exists()).toBe(true)
422
+ })
423
+
424
+ it('applies disabled state classes consistently', () => {
425
+ const wrapper = mount(Textarea, {
426
+ props: { disabled: true }
427
+ })
428
+ expect(wrapper.find('.ui-textarea-field--disabled').exists()).toBe(true)
429
+ expect(wrapper.find('.ui-textarea-wrapper--disabled').exists()).toBe(true)
430
+ })
431
+ })
432
+ })