@dillingerstaffing/strand-vue 0.4.0

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 (233) hide show
  1. package/dist/components/Alert/Alert.vue.d.ts +31 -0
  2. package/dist/components/Alert/Alert.vue.d.ts.map +1 -0
  3. package/dist/components/Alert/index.d.ts +3 -0
  4. package/dist/components/Alert/index.d.ts.map +1 -0
  5. package/dist/components/Avatar/Avatar.vue.d.ts +20 -0
  6. package/dist/components/Avatar/Avatar.vue.d.ts.map +1 -0
  7. package/dist/components/Avatar/index.d.ts +2 -0
  8. package/dist/components/Avatar/index.d.ts.map +1 -0
  9. package/dist/components/Badge/Badge.vue.d.ts +35 -0
  10. package/dist/components/Badge/Badge.vue.d.ts.map +1 -0
  11. package/dist/components/Badge/index.d.ts +2 -0
  12. package/dist/components/Badge/index.d.ts.map +1 -0
  13. package/dist/components/Breadcrumb/Breadcrumb.vue.d.ts +15 -0
  14. package/dist/components/Breadcrumb/Breadcrumb.vue.d.ts.map +1 -0
  15. package/dist/components/Breadcrumb/index.d.ts +3 -0
  16. package/dist/components/Breadcrumb/index.d.ts.map +1 -0
  17. package/dist/components/Button/Button.vue.d.ts +46 -0
  18. package/dist/components/Button/Button.vue.d.ts.map +1 -0
  19. package/dist/components/Button/index.d.ts +3 -0
  20. package/dist/components/Button/index.d.ts.map +1 -0
  21. package/dist/components/Card/Card.vue.d.ts +30 -0
  22. package/dist/components/Card/Card.vue.d.ts.map +1 -0
  23. package/dist/components/Card/index.d.ts +2 -0
  24. package/dist/components/Card/index.d.ts.map +1 -0
  25. package/dist/components/Checkbox/Checkbox.vue.d.ts +23 -0
  26. package/dist/components/Checkbox/Checkbox.vue.d.ts.map +1 -0
  27. package/dist/components/Checkbox/index.d.ts +3 -0
  28. package/dist/components/Checkbox/index.d.ts.map +1 -0
  29. package/dist/components/Container/Container.vue.d.ts +27 -0
  30. package/dist/components/Container/Container.vue.d.ts.map +1 -0
  31. package/dist/components/Container/index.d.ts +2 -0
  32. package/dist/components/Container/index.d.ts.map +1 -0
  33. package/dist/components/DataReadout/DataReadout.vue.d.ts +15 -0
  34. package/dist/components/DataReadout/DataReadout.vue.d.ts.map +1 -0
  35. package/dist/components/DataReadout/index.d.ts +2 -0
  36. package/dist/components/DataReadout/index.d.ts.map +1 -0
  37. package/dist/components/Dialog/Dialog.vue.d.ts +39 -0
  38. package/dist/components/Dialog/Dialog.vue.d.ts.map +1 -0
  39. package/dist/components/Dialog/index.d.ts +3 -0
  40. package/dist/components/Dialog/index.d.ts.map +1 -0
  41. package/dist/components/Divider/Divider.vue.d.ts +14 -0
  42. package/dist/components/Divider/Divider.vue.d.ts.map +1 -0
  43. package/dist/components/Divider/index.d.ts +2 -0
  44. package/dist/components/Divider/index.d.ts.map +1 -0
  45. package/dist/components/FormField/FormField.vue.d.ts +32 -0
  46. package/dist/components/FormField/FormField.vue.d.ts.map +1 -0
  47. package/dist/components/FormField/index.d.ts +3 -0
  48. package/dist/components/FormField/index.d.ts.map +1 -0
  49. package/dist/components/Grid/Grid.vue.d.ts +30 -0
  50. package/dist/components/Grid/Grid.vue.d.ts.map +1 -0
  51. package/dist/components/Grid/index.d.ts +2 -0
  52. package/dist/components/Grid/index.d.ts.map +1 -0
  53. package/dist/components/Input/Input.vue.d.ts +37 -0
  54. package/dist/components/Input/Input.vue.d.ts.map +1 -0
  55. package/dist/components/Input/index.d.ts +3 -0
  56. package/dist/components/Input/index.d.ts.map +1 -0
  57. package/dist/components/Link/Link.vue.d.ts +29 -0
  58. package/dist/components/Link/Link.vue.d.ts.map +1 -0
  59. package/dist/components/Link/index.d.ts +2 -0
  60. package/dist/components/Link/index.d.ts.map +1 -0
  61. package/dist/components/Nav/Nav.vue.d.ts +30 -0
  62. package/dist/components/Nav/Nav.vue.d.ts.map +1 -0
  63. package/dist/components/Nav/index.d.ts +3 -0
  64. package/dist/components/Nav/index.d.ts.map +1 -0
  65. package/dist/components/Progress/Progress.vue.d.ts +17 -0
  66. package/dist/components/Progress/Progress.vue.d.ts.map +1 -0
  67. package/dist/components/Progress/index.d.ts +2 -0
  68. package/dist/components/Progress/index.d.ts.map +1 -0
  69. package/dist/components/Radio/Radio.vue.d.ts +22 -0
  70. package/dist/components/Radio/Radio.vue.d.ts.map +1 -0
  71. package/dist/components/Radio/index.d.ts +3 -0
  72. package/dist/components/Radio/index.d.ts.map +1 -0
  73. package/dist/components/Section/Section.vue.d.ts +30 -0
  74. package/dist/components/Section/Section.vue.d.ts.map +1 -0
  75. package/dist/components/Section/index.d.ts +2 -0
  76. package/dist/components/Section/index.d.ts.map +1 -0
  77. package/dist/components/Select/Select.vue.d.ts +26 -0
  78. package/dist/components/Select/Select.vue.d.ts.map +1 -0
  79. package/dist/components/Select/index.d.ts +3 -0
  80. package/dist/components/Select/index.d.ts.map +1 -0
  81. package/dist/components/Skeleton/Skeleton.vue.d.ts +16 -0
  82. package/dist/components/Skeleton/Skeleton.vue.d.ts.map +1 -0
  83. package/dist/components/Skeleton/index.d.ts +2 -0
  84. package/dist/components/Skeleton/index.d.ts.map +1 -0
  85. package/dist/components/Slider/Slider.vue.d.ts +24 -0
  86. package/dist/components/Slider/Slider.vue.d.ts.map +1 -0
  87. package/dist/components/Slider/index.d.ts +3 -0
  88. package/dist/components/Slider/index.d.ts.map +1 -0
  89. package/dist/components/Spinner/Spinner.vue.d.ts +12 -0
  90. package/dist/components/Spinner/Spinner.vue.d.ts.map +1 -0
  91. package/dist/components/Spinner/index.d.ts +2 -0
  92. package/dist/components/Spinner/index.d.ts.map +1 -0
  93. package/dist/components/Stack/Stack.vue.d.ts +38 -0
  94. package/dist/components/Stack/Stack.vue.d.ts.map +1 -0
  95. package/dist/components/Stack/index.d.ts +2 -0
  96. package/dist/components/Stack/index.d.ts.map +1 -0
  97. package/dist/components/Switch/Switch.vue.d.ts +18 -0
  98. package/dist/components/Switch/Switch.vue.d.ts.map +1 -0
  99. package/dist/components/Switch/index.d.ts +3 -0
  100. package/dist/components/Switch/index.d.ts.map +1 -0
  101. package/dist/components/Table/Table.vue.d.ts +23 -0
  102. package/dist/components/Table/Table.vue.d.ts.map +1 -0
  103. package/dist/components/Table/index.d.ts +3 -0
  104. package/dist/components/Table/index.d.ts.map +1 -0
  105. package/dist/components/Tabs/Tabs.vue.d.ts +34 -0
  106. package/dist/components/Tabs/Tabs.vue.d.ts.map +1 -0
  107. package/dist/components/Tabs/index.d.ts +3 -0
  108. package/dist/components/Tabs/index.d.ts.map +1 -0
  109. package/dist/components/Tag/Tag.vue.d.ts +37 -0
  110. package/dist/components/Tag/Tag.vue.d.ts.map +1 -0
  111. package/dist/components/Tag/index.d.ts +2 -0
  112. package/dist/components/Tag/index.d.ts.map +1 -0
  113. package/dist/components/Textarea/Textarea.vue.d.ts +29 -0
  114. package/dist/components/Textarea/Textarea.vue.d.ts.map +1 -0
  115. package/dist/components/Textarea/index.d.ts +3 -0
  116. package/dist/components/Textarea/index.d.ts.map +1 -0
  117. package/dist/components/Toast/Toast.vue.d.ts +16 -0
  118. package/dist/components/Toast/Toast.vue.d.ts.map +1 -0
  119. package/dist/components/Toast/ToastProvider.vue.d.ts +18 -0
  120. package/dist/components/Toast/ToastProvider.vue.d.ts.map +1 -0
  121. package/dist/components/Toast/index.d.ts +6 -0
  122. package/dist/components/Toast/index.d.ts.map +1 -0
  123. package/dist/components/Toast/useToast.d.ts +13 -0
  124. package/dist/components/Toast/useToast.d.ts.map +1 -0
  125. package/dist/components/Tooltip/Tooltip.vue.d.ts +29 -0
  126. package/dist/components/Tooltip/Tooltip.vue.d.ts.map +1 -0
  127. package/dist/components/Tooltip/index.d.ts +3 -0
  128. package/dist/components/Tooltip/index.d.ts.map +1 -0
  129. package/dist/css/strand-ui.css +2534 -0
  130. package/dist/index.d.ts +35 -0
  131. package/dist/index.d.ts.map +1 -0
  132. package/dist/index.js +1413 -0
  133. package/dist/index.js.map +1 -0
  134. package/dist/test-setup.d.ts +1 -0
  135. package/dist/test-setup.d.ts.map +1 -0
  136. package/package.json +51 -0
  137. package/src/components/Alert/Alert.test.ts +100 -0
  138. package/src/components/Alert/Alert.vue +54 -0
  139. package/src/components/Alert/index.ts +2 -0
  140. package/src/components/Avatar/Avatar.test.ts +105 -0
  141. package/src/components/Avatar/Avatar.vue +56 -0
  142. package/src/components/Avatar/index.ts +1 -0
  143. package/src/components/Badge/Badge.test.ts +114 -0
  144. package/src/components/Badge/Badge.vue +66 -0
  145. package/src/components/Badge/index.ts +1 -0
  146. package/src/components/Breadcrumb/Breadcrumb.test.ts +119 -0
  147. package/src/components/Breadcrumb/Breadcrumb.vue +58 -0
  148. package/src/components/Breadcrumb/index.ts +2 -0
  149. package/src/components/Button/Button.test.ts +148 -0
  150. package/src/components/Button/Button.vue +75 -0
  151. package/src/components/Button/index.ts +2 -0
  152. package/src/components/Card/Card.test.ts +93 -0
  153. package/src/components/Card/Card.vue +36 -0
  154. package/src/components/Card/index.ts +1 -0
  155. package/src/components/Checkbox/Checkbox.test.ts +118 -0
  156. package/src/components/Checkbox/Checkbox.vue +117 -0
  157. package/src/components/Checkbox/index.ts +2 -0
  158. package/src/components/Container/Container.test.ts +70 -0
  159. package/src/components/Container/Container.vue +32 -0
  160. package/src/components/Container/index.ts +1 -0
  161. package/src/components/DataReadout/DataReadout.test.ts +99 -0
  162. package/src/components/DataReadout/DataReadout.vue +36 -0
  163. package/src/components/DataReadout/index.ts +1 -0
  164. package/src/components/Dialog/Dialog.test.ts +224 -0
  165. package/src/components/Dialog/Dialog.vue +146 -0
  166. package/src/components/Dialog/index.ts +2 -0
  167. package/src/components/Divider/Divider.test.ts +95 -0
  168. package/src/components/Divider/Divider.vue +63 -0
  169. package/src/components/Divider/index.ts +1 -0
  170. package/src/components/FormField/FormField.test.ts +98 -0
  171. package/src/components/FormField/FormField.vue +59 -0
  172. package/src/components/FormField/index.ts +2 -0
  173. package/src/components/Grid/Grid.test.ts +73 -0
  174. package/src/components/Grid/Grid.vue +34 -0
  175. package/src/components/Grid/index.ts +1 -0
  176. package/src/components/Input/Input.test.ts +102 -0
  177. package/src/components/Input/Input.vue +63 -0
  178. package/src/components/Input/index.ts +2 -0
  179. package/src/components/Link/Link.test.ts +92 -0
  180. package/src/components/Link/Link.vue +35 -0
  181. package/src/components/Link/index.ts +1 -0
  182. package/src/components/Nav/Nav.test.ts +171 -0
  183. package/src/components/Nav/Nav.vue +81 -0
  184. package/src/components/Nav/index.ts +2 -0
  185. package/src/components/Progress/Progress.test.ts +103 -0
  186. package/src/components/Progress/Progress.vue +96 -0
  187. package/src/components/Progress/index.ts +1 -0
  188. package/src/components/Radio/Radio.test.ts +92 -0
  189. package/src/components/Radio/Radio.vue +60 -0
  190. package/src/components/Radio/index.ts +2 -0
  191. package/src/components/Section/Section.test.ts +77 -0
  192. package/src/components/Section/Section.vue +36 -0
  193. package/src/components/Section/index.ts +1 -0
  194. package/src/components/Select/Select.test.ts +102 -0
  195. package/src/components/Select/Select.vue +70 -0
  196. package/src/components/Select/index.ts +2 -0
  197. package/src/components/Skeleton/Skeleton.test.ts +77 -0
  198. package/src/components/Skeleton/Skeleton.vue +48 -0
  199. package/src/components/Skeleton/index.ts +1 -0
  200. package/src/components/Slider/Slider.test.ts +73 -0
  201. package/src/components/Slider/Slider.vue +60 -0
  202. package/src/components/Slider/index.ts +2 -0
  203. package/src/components/Spinner/Spinner.test.ts +66 -0
  204. package/src/components/Spinner/Spinner.vue +33 -0
  205. package/src/components/Spinner/index.ts +1 -0
  206. package/src/components/Stack/Stack.test.ts +140 -0
  207. package/src/components/Stack/Stack.vue +50 -0
  208. package/src/components/Stack/index.ts +1 -0
  209. package/src/components/Switch/Switch.test.ts +116 -0
  210. package/src/components/Switch/Switch.vue +62 -0
  211. package/src/components/Switch/index.ts +2 -0
  212. package/src/components/Table/Table.test.ts +152 -0
  213. package/src/components/Table/Table.vue +98 -0
  214. package/src/components/Table/index.ts +2 -0
  215. package/src/components/Tabs/Tabs.test.ts +138 -0
  216. package/src/components/Tabs/Tabs.vue +96 -0
  217. package/src/components/Tabs/index.ts +2 -0
  218. package/src/components/Tag/Tag.test.ts +128 -0
  219. package/src/components/Tag/Tag.vue +65 -0
  220. package/src/components/Tag/index.ts +1 -0
  221. package/src/components/Textarea/Textarea.test.ts +107 -0
  222. package/src/components/Textarea/Textarea.vue +90 -0
  223. package/src/components/Textarea/index.ts +2 -0
  224. package/src/components/Toast/Toast.test.ts +204 -0
  225. package/src/components/Toast/Toast.vue +48 -0
  226. package/src/components/Toast/ToastProvider.vue +80 -0
  227. package/src/components/Toast/index.ts +5 -0
  228. package/src/components/Toast/useToast.ts +26 -0
  229. package/src/components/Tooltip/Tooltip.test.ts +145 -0
  230. package/src/components/Tooltip/Tooltip.vue +79 -0
  231. package/src/components/Tooltip/index.ts +2 -0
  232. package/src/index.ts +44 -0
  233. package/src/test-setup.ts +7 -0
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Badge from './Badge.vue'
4
+
5
+ describe('Badge', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders a span element', () => {
9
+ const { container } = render(Badge, { props: { count: 5 } })
10
+ expect(container.firstElementChild?.tagName).toBe('SPAN')
11
+ })
12
+
13
+ // ── Count variant ──
14
+
15
+ it('displays count number', () => {
16
+ const { getByRole } = render(Badge, { props: { count: 7 } })
17
+ expect(getByRole('status')).toHaveTextContent('7')
18
+ })
19
+
20
+ it('truncates count at maxCount', () => {
21
+ const { getByRole } = render(Badge, { props: { count: 150, maxCount: 99 } })
22
+ expect(getByRole('status')).toHaveTextContent('99+')
23
+ })
24
+
25
+ it('does not truncate count at maxCount boundary', () => {
26
+ const { getByRole } = render(Badge, { props: { count: 99, maxCount: 99 } })
27
+ expect(getByRole('status')).toHaveTextContent('99')
28
+ })
29
+
30
+ it('supports custom maxCount', () => {
31
+ const { getByRole } = render(Badge, { props: { count: 15, maxCount: 9 } })
32
+ expect(getByRole('status')).toHaveTextContent('9+')
33
+ })
34
+
35
+ // ── Dot variant ──
36
+
37
+ it('renders dot variant as small circle', () => {
38
+ const { container } = render(Badge, { props: { variant: 'dot' } })
39
+ const indicator = container.querySelector('.strand-badge__indicator')
40
+ expect(indicator?.className).toContain('strand-badge--dot')
41
+ })
42
+
43
+ // ── Status colors ──
44
+
45
+ it('applies default status class by default', () => {
46
+ const { container } = render(Badge, { props: { count: 1 } })
47
+ const indicator = container.querySelector('.strand-badge__indicator')
48
+ expect(indicator?.className).toContain('strand-badge--default')
49
+ })
50
+
51
+ it('applies teal status class', () => {
52
+ const { container } = render(Badge, { props: { count: 1, status: 'teal' } })
53
+ const indicator = container.querySelector('.strand-badge__indicator')
54
+ expect(indicator?.className).toContain('strand-badge--teal')
55
+ })
56
+
57
+ it('applies red status class', () => {
58
+ const { container } = render(Badge, { props: { count: 1, status: 'red' } })
59
+ const indicator = container.querySelector('.strand-badge__indicator')
60
+ expect(indicator?.className).toContain('strand-badge--red')
61
+ })
62
+
63
+ it('applies amber status class', () => {
64
+ const { container } = render(Badge, { props: { count: 1, status: 'amber' } })
65
+ const indicator = container.querySelector('.strand-badge__indicator')
66
+ expect(indicator?.className).toContain('strand-badge--amber')
67
+ })
68
+
69
+ it('applies blue status class', () => {
70
+ const { container } = render(Badge, { props: { count: 1, status: 'blue' } })
71
+ const indicator = container.querySelector('.strand-badge__indicator')
72
+ expect(indicator?.className).toContain('strand-badge--blue')
73
+ })
74
+
75
+ // ── Children wrapping ──
76
+
77
+ it('wraps children and positions badge', () => {
78
+ const { getByText, container } = render(Badge, {
79
+ props: { count: 3 },
80
+ slots: { default: '<span>Inbox</span>' },
81
+ })
82
+ expect(getByText('Inbox')).toBeTruthy()
83
+ const wrapper = container.firstElementChild
84
+ expect(wrapper?.className).toContain('strand-badge')
85
+ expect(wrapper?.className).not.toContain('strand-badge--inline')
86
+ })
87
+
88
+ it('renders inline without children', () => {
89
+ const { container } = render(Badge, { props: { count: 5 } })
90
+ const wrapper = container.firstElementChild
91
+ expect(wrapper?.className).toContain('strand-badge--inline')
92
+ })
93
+
94
+ // ── Custom className ──
95
+
96
+ it('merges custom className', () => {
97
+ const { container } = render(Badge, { props: { count: 1, className: 'custom' } })
98
+ const el = container.firstElementChild
99
+ expect(el?.className).toContain('strand-badge')
100
+ expect(el?.className).toContain('custom')
101
+ })
102
+
103
+ // ── Accessibility ──
104
+
105
+ it('has aria-label for count variant', () => {
106
+ const { getByRole } = render(Badge, { props: { count: 5 } })
107
+ expect(getByRole('status')).toHaveAttribute('aria-label', '5 notifications')
108
+ })
109
+
110
+ it('has aria-label for dot variant', () => {
111
+ const { getByRole } = render(Badge, { props: { variant: 'dot' } })
112
+ expect(getByRole('status')).toHaveAttribute('aria-label', 'Status indicator')
113
+ })
114
+ })
@@ -0,0 +1,66 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed, useSlots } from 'vue'
4
+
5
+ interface Props {
6
+ /** Badge display mode */
7
+ variant?: 'dot' | 'count'
8
+ /** Color status */
9
+ status?: 'default' | 'teal' | 'blue' | 'amber' | 'red'
10
+ /** Number to display (count variant only) */
11
+ count?: number
12
+ /** Maximum count before showing "N+" */
13
+ maxCount?: number
14
+ /** Additional CSS class */
15
+ className?: string
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ variant: 'count',
20
+ status: 'default',
21
+ maxCount: 99,
22
+ className: '',
23
+ })
24
+
25
+ const slots = useSlots()
26
+
27
+ const hasChildren = computed(() => !!slots.default)
28
+
29
+ const displayValue = computed(() => {
30
+ if (props.variant === 'count') {
31
+ return props.count != null && props.count > props.maxCount
32
+ ? `${props.maxCount}+`
33
+ : props.count
34
+ }
35
+ return null
36
+ })
37
+
38
+ const ariaLabel = computed(() => {
39
+ if (props.variant === 'dot') return 'Status indicator'
40
+ if (props.count != null) return `${props.count} notifications`
41
+ return undefined
42
+ })
43
+
44
+ const badgeClasses = computed(() =>
45
+ [
46
+ 'strand-badge__indicator',
47
+ `strand-badge--${props.variant}`,
48
+ `strand-badge--${props.status}`,
49
+ ]
50
+ .filter(Boolean)
51
+ .join(' '),
52
+ )
53
+
54
+ const wrapperClasses = computed(() =>
55
+ hasChildren.value
56
+ ? ['strand-badge', props.className].filter(Boolean).join(' ')
57
+ : ['strand-badge', 'strand-badge--inline', props.className].filter(Boolean).join(' '),
58
+ )
59
+ </script>
60
+
61
+ <template>
62
+ <span :class="wrapperClasses" v-bind="$attrs">
63
+ <slot />
64
+ <span :class="badgeClasses" :aria-label="ariaLabel" role="status">{{ displayValue }}</span>
65
+ </span>
66
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Badge } from './Badge.vue'
@@ -0,0 +1,119 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render } from '@testing-library/vue'
5
+ import Breadcrumb from './Breadcrumb.vue'
6
+
7
+ const defaultItems = [
8
+ { label: 'Home', href: '/' },
9
+ { label: 'Products', href: '/products' },
10
+ { label: 'Widget' },
11
+ ]
12
+
13
+ describe('Breadcrumb', () => {
14
+ it('renders nav with aria-label', () => {
15
+ const { container } = render(Breadcrumb, {
16
+ props: { items: defaultItems },
17
+ })
18
+ const nav = container.querySelector('nav')
19
+ expect(nav).toBeInTheDocument()
20
+ expect(nav).toHaveAttribute('aria-label', 'Breadcrumb')
21
+ expect(nav).toHaveClass('strand-breadcrumb')
22
+ })
23
+
24
+ it('renders ordered list', () => {
25
+ const { container } = render(Breadcrumb, {
26
+ props: { items: defaultItems },
27
+ })
28
+ const ol = container.querySelector('.strand-breadcrumb__list')
29
+ expect(ol).toBeInTheDocument()
30
+ expect(ol?.tagName).toBe('OL')
31
+ })
32
+
33
+ it('renders correct number of items', () => {
34
+ const { container } = render(Breadcrumb, {
35
+ props: { items: defaultItems },
36
+ })
37
+ const items = container.querySelectorAll('.strand-breadcrumb__item')
38
+ expect(items).toHaveLength(3)
39
+ })
40
+
41
+ it('renders links for non-last items', () => {
42
+ const { container } = render(Breadcrumb, {
43
+ props: { items: defaultItems },
44
+ })
45
+ const links = container.querySelectorAll('.strand-breadcrumb__link')
46
+ expect(links).toHaveLength(2)
47
+ expect(links[0]).toHaveAttribute('href', '/')
48
+ expect(links[0]).toHaveTextContent('Home')
49
+ expect(links[1]).toHaveAttribute('href', '/products')
50
+ expect(links[1]).toHaveTextContent('Products')
51
+ })
52
+
53
+ it('renders last item as current page with aria-current', () => {
54
+ const { container } = render(Breadcrumb, {
55
+ props: { items: defaultItems },
56
+ })
57
+ const current = container.querySelector('.strand-breadcrumb__current')
58
+ expect(current).toBeInTheDocument()
59
+ expect(current).toHaveAttribute('aria-current', 'page')
60
+ expect(current).toHaveTextContent('Widget')
61
+ })
62
+
63
+ it('renders separators between items with aria-hidden', () => {
64
+ const { container } = render(Breadcrumb, {
65
+ props: { items: defaultItems },
66
+ })
67
+ const separators = container.querySelectorAll('.strand-breadcrumb__separator')
68
+ expect(separators).toHaveLength(2)
69
+ expect(separators[0]).toHaveAttribute('aria-hidden', 'true')
70
+ expect(separators[0]).toHaveTextContent('/')
71
+ expect(separators[1]).toHaveAttribute('aria-hidden', 'true')
72
+ })
73
+
74
+ it('does not render separator before first item', () => {
75
+ const { container } = render(Breadcrumb, {
76
+ props: { items: defaultItems },
77
+ })
78
+ const firstItem = container.querySelector('.strand-breadcrumb__item')
79
+ expect(firstItem?.querySelector('.strand-breadcrumb__separator')).not.toBeInTheDocument()
80
+ })
81
+
82
+ it('uses custom separator', () => {
83
+ const { container } = render(Breadcrumb, {
84
+ props: { items: defaultItems, separator: '>' },
85
+ })
86
+ const separators = container.querySelectorAll('.strand-breadcrumb__separator')
87
+ expect(separators[0]).toHaveTextContent('>')
88
+ })
89
+
90
+ it('handles single item', () => {
91
+ const { container } = render(Breadcrumb, {
92
+ props: { items: [{ label: 'Home' }] },
93
+ })
94
+ const items = container.querySelectorAll('.strand-breadcrumb__item')
95
+ expect(items).toHaveLength(1)
96
+ const current = container.querySelector('.strand-breadcrumb__current')
97
+ expect(current).toHaveAttribute('aria-current', 'page')
98
+ expect(container.querySelector('.strand-breadcrumb__separator')).not.toBeInTheDocument()
99
+ expect(container.querySelector('.strand-breadcrumb__link')).not.toBeInTheDocument()
100
+ })
101
+
102
+ it('handles two items', () => {
103
+ const { container } = render(Breadcrumb, {
104
+ props: {
105
+ items: [
106
+ { label: 'Home', href: '/' },
107
+ { label: 'About' },
108
+ ],
109
+ },
110
+ })
111
+ const links = container.querySelectorAll('.strand-breadcrumb__link')
112
+ expect(links).toHaveLength(1)
113
+ expect(links[0]).toHaveTextContent('Home')
114
+ const current = container.querySelector('.strand-breadcrumb__current')
115
+ expect(current).toHaveTextContent('About')
116
+ const separators = container.querySelectorAll('.strand-breadcrumb__separator')
117
+ expect(separators).toHaveLength(1)
118
+ })
119
+ })
@@ -0,0 +1,58 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ export interface BreadcrumbItem {
6
+ label: string
7
+ href?: string
8
+ }
9
+
10
+ export interface BreadcrumbProps {
11
+ /** Breadcrumb path items; last item is treated as current page */
12
+ items: BreadcrumbItem[]
13
+ /** Separator character between items */
14
+ separator?: string
15
+ }
16
+
17
+ const props = withDefaults(defineProps<BreadcrumbProps>(), {
18
+ separator: '/',
19
+ })
20
+
21
+ const classes = computed(() =>
22
+ ['strand-breadcrumb'].filter(Boolean).join(' '),
23
+ )
24
+ </script>
25
+
26
+ <template>
27
+ <nav aria-label="Breadcrumb" :class="classes">
28
+ <ol class="strand-breadcrumb__list">
29
+ <li
30
+ v-for="(item, index) in items"
31
+ :key="`${item.label}-${index}`"
32
+ class="strand-breadcrumb__item"
33
+ >
34
+ <span
35
+ v-if="index > 0"
36
+ class="strand-breadcrumb__separator"
37
+ aria-hidden="true"
38
+ >
39
+ {{ separator }}
40
+ </span>
41
+ <span
42
+ v-if="index === items.length - 1"
43
+ class="strand-breadcrumb__current"
44
+ aria-current="page"
45
+ >
46
+ {{ item.label }}
47
+ </span>
48
+ <a
49
+ v-else
50
+ :href="item.href"
51
+ class="strand-breadcrumb__link"
52
+ >
53
+ {{ item.label }}
54
+ </a>
55
+ </li>
56
+ </ol>
57
+ </nav>
58
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Breadcrumb } from './Breadcrumb.vue'
@@ -0,0 +1,148 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render, fireEvent } from '@testing-library/vue'
5
+ import Button from './Button.vue'
6
+
7
+ describe('Button', () => {
8
+ it('renders with default props', () => {
9
+ const { getByRole } = render(Button, {
10
+ slots: { default: 'Click me' },
11
+ })
12
+ const btn = getByRole('button')
13
+ expect(btn).toBeInTheDocument()
14
+ expect(btn).toHaveClass('strand-btn', 'strand-btn--primary', 'strand-btn--md')
15
+ expect(btn).toHaveAttribute('type', 'button')
16
+ expect(btn).not.toBeDisabled()
17
+ expect(btn).toHaveTextContent('Click me')
18
+ })
19
+
20
+ it('applies variant classes', () => {
21
+ const variants = ['primary', 'secondary', 'ghost', 'danger'] as const
22
+ for (const variant of variants) {
23
+ const { container, unmount } = render(Button, {
24
+ props: { variant },
25
+ slots: { default: 'Btn' },
26
+ })
27
+ expect(container.querySelector('.strand-btn')).toHaveClass(`strand-btn--${variant}`)
28
+ unmount()
29
+ }
30
+ })
31
+
32
+ it('applies size classes', () => {
33
+ const sizes = ['sm', 'md', 'lg'] as const
34
+ for (const size of sizes) {
35
+ const { container, unmount } = render(Button, {
36
+ props: { size },
37
+ slots: { default: 'Btn' },
38
+ })
39
+ expect(container.querySelector('.strand-btn')).toHaveClass(`strand-btn--${size}`)
40
+ unmount()
41
+ }
42
+ })
43
+
44
+ it('emits click event when clicked', async () => {
45
+ const { getByRole, emitted } = render(Button, {
46
+ slots: { default: 'Click me' },
47
+ })
48
+ await fireEvent.click(getByRole('button'))
49
+ expect(emitted().click).toHaveLength(1)
50
+ })
51
+
52
+ it('does not emit click when disabled', async () => {
53
+ const { getByRole, emitted } = render(Button, {
54
+ props: { disabled: true },
55
+ slots: { default: 'Click me' },
56
+ })
57
+ await fireEvent.click(getByRole('button'))
58
+ expect(emitted().click).toBeUndefined()
59
+ })
60
+
61
+ it('sets disabled and aria-disabled when disabled', () => {
62
+ const { getByRole } = render(Button, {
63
+ props: { disabled: true },
64
+ slots: { default: 'Btn' },
65
+ })
66
+ const btn = getByRole('button')
67
+ expect(btn).toBeDisabled()
68
+ expect(btn).toHaveAttribute('aria-disabled', 'true')
69
+ })
70
+
71
+ it('shows loading state with spinner and aria-busy', () => {
72
+ const { getByRole, container } = render(Button, {
73
+ props: { loading: true },
74
+ slots: { default: 'Loading' },
75
+ })
76
+ const btn = getByRole('button')
77
+ expect(btn).toHaveClass('strand-btn--loading')
78
+ expect(btn).toHaveAttribute('aria-busy', 'true')
79
+ expect(btn).toBeDisabled()
80
+ expect(btn).toHaveAttribute('aria-disabled', 'true')
81
+ const spinner = container.querySelector('.strand-btn__spinner')
82
+ expect(spinner).toBeInTheDocument()
83
+ expect(spinner).toHaveAttribute('aria-hidden', 'true')
84
+ })
85
+
86
+ it('hides content visibility when loading', () => {
87
+ const { container } = render(Button, {
88
+ props: { loading: true },
89
+ slots: { default: 'Loading' },
90
+ })
91
+ const content = container.querySelector('.strand-btn__content')
92
+ expect(content).toHaveStyle({ visibility: 'hidden' })
93
+ })
94
+
95
+ it('does not emit click when loading', async () => {
96
+ const { getByRole, emitted } = render(Button, {
97
+ props: { loading: true },
98
+ slots: { default: 'Click me' },
99
+ })
100
+ await fireEvent.click(getByRole('button'))
101
+ expect(emitted().click).toBeUndefined()
102
+ })
103
+
104
+ it('applies iconOnly class', () => {
105
+ const { getByRole } = render(Button, {
106
+ props: { iconOnly: true },
107
+ slots: { default: 'X' },
108
+ })
109
+ expect(getByRole('button')).toHaveClass('strand-btn--icon-only')
110
+ })
111
+
112
+ it('applies fullWidth class', () => {
113
+ const { getByRole } = render(Button, {
114
+ props: { fullWidth: true },
115
+ slots: { default: 'Full' },
116
+ })
117
+ expect(getByRole('button')).toHaveClass('strand-btn--full-width')
118
+ })
119
+
120
+ it('passes type attribute', () => {
121
+ const { getByRole } = render(Button, {
122
+ props: { type: 'submit' },
123
+ slots: { default: 'Submit' },
124
+ })
125
+ expect(getByRole('button')).toHaveAttribute('type', 'submit')
126
+ })
127
+
128
+ it('does not set aria-disabled when not disabled', () => {
129
+ const { getByRole } = render(Button, {
130
+ slots: { default: 'Btn' },
131
+ })
132
+ expect(getByRole('button')).not.toHaveAttribute('aria-disabled')
133
+ })
134
+
135
+ it('does not set aria-busy when not loading', () => {
136
+ const { getByRole } = render(Button, {
137
+ slots: { default: 'Btn' },
138
+ })
139
+ expect(getByRole('button')).not.toHaveAttribute('aria-busy')
140
+ })
141
+
142
+ it('does not show spinner when not loading', () => {
143
+ const { container } = render(Button, {
144
+ slots: { default: 'Btn' },
145
+ })
146
+ expect(container.querySelector('.strand-btn__spinner')).not.toBeInTheDocument()
147
+ })
148
+ })
@@ -0,0 +1,75 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ export interface ButtonProps {
6
+ /** Visual style variant */
7
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
8
+ /** Button size */
9
+ size?: 'sm' | 'md' | 'lg'
10
+ /** Show loading spinner and disable interaction */
11
+ loading?: boolean
12
+ /** Square button for icon-only use */
13
+ iconOnly?: boolean
14
+ /** HTML button type */
15
+ type?: 'button' | 'submit' | 'reset'
16
+ /** Disabled state */
17
+ disabled?: boolean
18
+ /** Stretch to full container width */
19
+ fullWidth?: boolean
20
+ }
21
+
22
+ const props = withDefaults(defineProps<ButtonProps>(), {
23
+ variant: 'primary',
24
+ size: 'md',
25
+ loading: false,
26
+ iconOnly: false,
27
+ type: 'button',
28
+ disabled: false,
29
+ fullWidth: false,
30
+ })
31
+
32
+ const emit = defineEmits<{
33
+ (e: 'click', event: MouseEvent): void
34
+ }>()
35
+
36
+ const isDisabled = computed(() => props.disabled || props.loading)
37
+
38
+ const classes = computed(() =>
39
+ [
40
+ 'strand-btn',
41
+ `strand-btn--${props.variant}`,
42
+ `strand-btn--${props.size}`,
43
+ props.iconOnly && 'strand-btn--icon-only',
44
+ props.fullWidth && 'strand-btn--full-width',
45
+ props.loading && 'strand-btn--loading',
46
+ ]
47
+ .filter(Boolean)
48
+ .join(' '),
49
+ )
50
+
51
+ function handleClick(event: MouseEvent) {
52
+ if (!isDisabled.value) {
53
+ emit('click', event)
54
+ }
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <button
60
+ :type="type"
61
+ :class="classes"
62
+ :disabled="isDisabled"
63
+ :aria-disabled="isDisabled ? 'true' : undefined"
64
+ :aria-busy="loading ? 'true' : undefined"
65
+ @click="handleClick"
66
+ >
67
+ <span v-if="loading" class="strand-btn__spinner" aria-hidden="true" />
68
+ <span
69
+ class="strand-btn__content"
70
+ :style="loading ? { visibility: 'hidden' } : undefined"
71
+ >
72
+ <slot />
73
+ </span>
74
+ </button>
75
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Button } from './Button.vue'
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Card from './Card.vue'
4
+
5
+ describe('Card', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders a div element', () => {
9
+ const { container } = render(Card, { slots: { default: 'Content' } })
10
+ expect(container.firstElementChild?.tagName).toBe('DIV')
11
+ })
12
+
13
+ it('renders slot content', () => {
14
+ const { getByText } = render(Card, { slots: { default: 'Hello world' } })
15
+ expect(getByText('Hello world')).toBeTruthy()
16
+ })
17
+
18
+ // ── Variants ──
19
+
20
+ it('applies elevated variant class by default', () => {
21
+ const { container } = render(Card, { slots: { default: 'Test' } })
22
+ expect(container.firstElementChild?.className).toContain('strand-card--elevated')
23
+ })
24
+
25
+ it('applies outlined variant class', () => {
26
+ const { container } = render(Card, {
27
+ props: { variant: 'outlined' },
28
+ slots: { default: 'Test' },
29
+ })
30
+ expect(container.firstElementChild?.className).toContain('strand-card--outlined')
31
+ })
32
+
33
+ it('applies interactive variant class', () => {
34
+ const { container } = render(Card, {
35
+ props: { variant: 'interactive' },
36
+ slots: { default: 'Test' },
37
+ })
38
+ expect(container.firstElementChild?.className).toContain('strand-card--interactive')
39
+ })
40
+
41
+ // ── Padding ──
42
+
43
+ it('applies md padding class by default', () => {
44
+ const { container } = render(Card, { slots: { default: 'Test' } })
45
+ expect(container.firstElementChild?.className).toContain('strand-card--pad-md')
46
+ })
47
+
48
+ it('applies none padding class', () => {
49
+ const { container } = render(Card, {
50
+ props: { padding: 'none' },
51
+ slots: { default: 'Test' },
52
+ })
53
+ expect(container.firstElementChild?.className).toContain('strand-card--pad-none')
54
+ })
55
+
56
+ it('applies sm padding class', () => {
57
+ const { container } = render(Card, {
58
+ props: { padding: 'sm' },
59
+ slots: { default: 'Test' },
60
+ })
61
+ expect(container.firstElementChild?.className).toContain('strand-card--pad-sm')
62
+ })
63
+
64
+ it('applies lg padding class', () => {
65
+ const { container } = render(Card, {
66
+ props: { padding: 'lg' },
67
+ slots: { default: 'Test' },
68
+ })
69
+ expect(container.firstElementChild?.className).toContain('strand-card--pad-lg')
70
+ })
71
+
72
+ // ── Custom className ──
73
+
74
+ it('merges custom className with component classes', () => {
75
+ const { container } = render(Card, {
76
+ props: { className: 'custom' },
77
+ slots: { default: 'Test' },
78
+ })
79
+ const el = container.firstElementChild
80
+ expect(el?.className).toContain('strand-card')
81
+ expect(el?.className).toContain('custom')
82
+ })
83
+
84
+ // ── Props forwarding ──
85
+
86
+ it('forwards additional attributes', () => {
87
+ const { container } = render(Card, {
88
+ attrs: { id: 'card-1', 'data-testid': 'my-card' },
89
+ slots: { default: 'Test' },
90
+ })
91
+ expect(container.firstElementChild?.getAttribute('id')).toBe('card-1')
92
+ })
93
+ })