@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,103 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Progress from './Progress.vue'
4
+
5
+ describe('Progress', () => {
6
+ // ── ARIA ──
7
+
8
+ it('renders with progressbar role', () => {
9
+ const { getByRole } = render(Progress, { props: { value: 50 } })
10
+ expect(getByRole('progressbar')).toBeTruthy()
11
+ })
12
+
13
+ it('has aria-valuemin of 0', () => {
14
+ const { getByRole } = render(Progress, { props: { value: 50 } })
15
+ expect(getByRole('progressbar')).toHaveAttribute('aria-valuemin', '0')
16
+ })
17
+
18
+ it('has aria-valuemax of 100', () => {
19
+ const { getByRole } = render(Progress, { props: { value: 50 } })
20
+ expect(getByRole('progressbar')).toHaveAttribute('aria-valuemax', '100')
21
+ })
22
+
23
+ it('has aria-valuenow when determinate', () => {
24
+ const { getByRole } = render(Progress, { props: { value: 75 } })
25
+ expect(getByRole('progressbar')).toHaveAttribute('aria-valuenow', '75')
26
+ })
27
+
28
+ it('has no aria-valuenow when indeterminate', () => {
29
+ const { getByRole } = render(Progress)
30
+ expect(getByRole('progressbar')).not.toHaveAttribute('aria-valuenow')
31
+ })
32
+
33
+ // ── Variants ──
34
+
35
+ it('applies bar variant class by default', () => {
36
+ const { getByRole } = render(Progress, { props: { value: 50 } })
37
+ expect(getByRole('progressbar').className).toContain('strand-progress--bar')
38
+ })
39
+
40
+ it('applies ring variant class', () => {
41
+ const { getByRole } = render(Progress, { props: { variant: 'ring', value: 50 } })
42
+ expect(getByRole('progressbar').className).toContain('strand-progress--ring')
43
+ })
44
+
45
+ it('renders SVG for ring variant', () => {
46
+ const { container } = render(Progress, { props: { variant: 'ring', value: 50 } })
47
+ expect(container.querySelector('svg')).toBeTruthy()
48
+ })
49
+
50
+ // ── Indeterminate ──
51
+
52
+ it('applies indeterminate class when no value', () => {
53
+ const { getByRole } = render(Progress)
54
+ expect(getByRole('progressbar').className).toContain('strand-progress--indeterminate')
55
+ })
56
+
57
+ it('does not apply indeterminate class when value is provided', () => {
58
+ const { getByRole } = render(Progress, { props: { value: 50 } })
59
+ expect(getByRole('progressbar').className).not.toContain('strand-progress--indeterminate')
60
+ })
61
+
62
+ // ── Sizes ──
63
+
64
+ it('applies md size class by default', () => {
65
+ const { getByRole } = render(Progress, { props: { value: 50 } })
66
+ expect(getByRole('progressbar').className).toContain('strand-progress--md')
67
+ })
68
+
69
+ it('applies sm size class', () => {
70
+ const { getByRole } = render(Progress, { props: { value: 50, size: 'sm' } })
71
+ expect(getByRole('progressbar').className).toContain('strand-progress--sm')
72
+ })
73
+
74
+ it('applies lg size class', () => {
75
+ const { getByRole } = render(Progress, { props: { value: 50, size: 'lg' } })
76
+ expect(getByRole('progressbar').className).toContain('strand-progress--lg')
77
+ })
78
+
79
+ // ── Fill width ──
80
+
81
+ it('sets fill width to match value for bar variant', () => {
82
+ const { container } = render(Progress, { props: { value: 60 } })
83
+ const fill = container.querySelector('.strand-progress__fill')
84
+ expect((fill as HTMLElement)?.style.width).toBe('60%')
85
+ })
86
+
87
+ // ── Ring SVG ──
88
+
89
+ it('renders track and fill circles for ring variant', () => {
90
+ const { container } = render(Progress, { props: { variant: 'ring', value: 50 } })
91
+ expect(container.querySelector('.strand-progress__track')).toBeTruthy()
92
+ expect(container.querySelector('.strand-progress__fill')).toBeTruthy()
93
+ })
94
+
95
+ // ── Custom className ──
96
+
97
+ it('merges custom className', () => {
98
+ const { getByRole } = render(Progress, { props: { value: 50, className: 'custom' } })
99
+ const el = getByRole('progressbar')
100
+ expect(el.className).toContain('strand-progress')
101
+ expect(el.className).toContain('custom')
102
+ })
103
+ })
@@ -0,0 +1,96 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** Visual variant */
7
+ variant?: 'bar' | 'ring'
8
+ /** Completion percentage (0-100). Omit for indeterminate. */
9
+ value?: number
10
+ /** Size of the progress indicator */
11
+ size?: 'sm' | 'md' | 'lg'
12
+ /** Additional CSS class */
13
+ className?: string
14
+ }
15
+
16
+ const props = withDefaults(defineProps<Props>(), {
17
+ variant: 'bar',
18
+ size: 'md',
19
+ className: '',
20
+ })
21
+
22
+ const RING_SIZES: Record<string, number> = { sm: 24, md: 40, lg: 56 }
23
+ const RING_STROKE = 3
24
+
25
+ const isDeterminate = computed(() => props.value != null)
26
+
27
+ const classes = computed(() =>
28
+ [
29
+ 'strand-progress',
30
+ `strand-progress--${props.variant}`,
31
+ `strand-progress--${props.size}`,
32
+ !isDeterminate.value && 'strand-progress--indeterminate',
33
+ props.className,
34
+ ]
35
+ .filter(Boolean)
36
+ .join(' '),
37
+ )
38
+
39
+ const dim = computed(() => RING_SIZES[props.size] ?? RING_SIZES.md)
40
+ const radius = computed(() => (dim.value - RING_STROKE) / 2)
41
+ const circumference = computed(() => 2 * Math.PI * radius.value)
42
+ const offset = computed(() =>
43
+ isDeterminate.value
44
+ ? circumference.value - (circumference.value * (props.value as number)) / 100
45
+ : 0,
46
+ )
47
+ </script>
48
+
49
+ <template>
50
+ <div
51
+ :class="classes"
52
+ role="progressbar"
53
+ :aria-valuemin="0"
54
+ :aria-valuemax="100"
55
+ :aria-valuenow="isDeterminate ? value : undefined"
56
+ v-bind="$attrs"
57
+ >
58
+ <!-- Ring variant -->
59
+ <template v-if="variant === 'ring'">
60
+ <svg
61
+ :width="dim"
62
+ :height="dim"
63
+ :viewBox="`0 0 ${dim} ${dim}`"
64
+ class="strand-progress__ring"
65
+ >
66
+ <circle
67
+ :cx="dim / 2"
68
+ :cy="dim / 2"
69
+ :r="radius"
70
+ fill="none"
71
+ :stroke-width="RING_STROKE"
72
+ class="strand-progress__track"
73
+ />
74
+ <circle
75
+ :cx="dim / 2"
76
+ :cy="dim / 2"
77
+ :r="radius"
78
+ fill="none"
79
+ :stroke-width="RING_STROKE"
80
+ :stroke-dasharray="circumference"
81
+ :stroke-dashoffset="isDeterminate ? offset : undefined"
82
+ stroke-linecap="round"
83
+ class="strand-progress__fill"
84
+ :transform="`rotate(-90 ${dim / 2} ${dim / 2})`"
85
+ />
86
+ </svg>
87
+ </template>
88
+ <!-- Bar variant -->
89
+ <template v-else>
90
+ <div
91
+ class="strand-progress__fill"
92
+ :style="isDeterminate ? { width: `${value}%` } : undefined"
93
+ />
94
+ </template>
95
+ </div>
96
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Progress } from './Progress.vue'
@@ -0,0 +1,92 @@
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 Radio from './Radio.vue'
6
+
7
+ describe('Radio', () => {
8
+ it('renders with default props', () => {
9
+ const { container } = render(Radio)
10
+ const label = container.querySelector('.strand-radio')
11
+ expect(label).toBeInTheDocument()
12
+ const input = container.querySelector('.strand-radio__native') as HTMLInputElement
13
+ expect(input).toBeInTheDocument()
14
+ expect(input).toHaveAttribute('type', 'radio')
15
+ expect(input).not.toBeChecked()
16
+ })
17
+
18
+ it('applies checked state and class', () => {
19
+ const { container } = render(Radio, {
20
+ props: { checked: true },
21
+ })
22
+ const wrapper = container.querySelector('.strand-radio')
23
+ expect(wrapper).toHaveClass('strand-radio--checked')
24
+ const input = container.querySelector('.strand-radio__native') as HTMLInputElement
25
+ expect(input).toBeChecked()
26
+ })
27
+
28
+ it('applies disabled state', () => {
29
+ const { container } = render(Radio, {
30
+ props: { disabled: true },
31
+ })
32
+ const wrapper = container.querySelector('.strand-radio')
33
+ expect(wrapper).toHaveClass('strand-radio--disabled')
34
+ const input = container.querySelector('.strand-radio__native')
35
+ expect(input).toBeDisabled()
36
+ })
37
+
38
+ it('renders label text', () => {
39
+ const { container } = render(Radio, {
40
+ props: { label: 'Option 1' },
41
+ })
42
+ const labelSpan = container.querySelector('.strand-radio__label')
43
+ expect(labelSpan).toBeInTheDocument()
44
+ expect(labelSpan).toHaveTextContent('Option 1')
45
+ })
46
+
47
+ it('does not render label span without label prop', () => {
48
+ const { container } = render(Radio)
49
+ expect(container.querySelector('.strand-radio__label')).not.toBeInTheDocument()
50
+ })
51
+
52
+ it('passes name attribute', () => {
53
+ const { container } = render(Radio, {
54
+ props: { name: 'color' },
55
+ })
56
+ const input = container.querySelector('.strand-radio__native')
57
+ expect(input).toHaveAttribute('name', 'color')
58
+ })
59
+
60
+ it('passes value attribute', () => {
61
+ const { container } = render(Radio, {
62
+ props: { value: 'red' },
63
+ })
64
+ const input = container.querySelector('.strand-radio__native')
65
+ expect(input).toHaveAttribute('value', 'red')
66
+ })
67
+
68
+ it('renders control with dot and aria-hidden', () => {
69
+ const { container } = render(Radio)
70
+ const control = container.querySelector('.strand-radio__control')
71
+ expect(control).toBeInTheDocument()
72
+ expect(control).toHaveAttribute('aria-hidden', 'true')
73
+ const dot = container.querySelector('.strand-radio__dot')
74
+ expect(dot).toBeInTheDocument()
75
+ })
76
+
77
+ it('emits change event when clicked', async () => {
78
+ const { container, emitted } = render(Radio)
79
+ const input = container.querySelector('.strand-radio__native') as HTMLInputElement
80
+ await fireEvent.click(input)
81
+ expect(emitted().change).toHaveLength(1)
82
+ })
83
+
84
+ it('does not emit change when disabled', async () => {
85
+ const { container, emitted } = render(Radio, {
86
+ props: { disabled: true },
87
+ })
88
+ const input = container.querySelector('.strand-radio__native') as HTMLInputElement
89
+ await fireEvent.click(input)
90
+ expect(emitted().change).toBeUndefined()
91
+ })
92
+ })
@@ -0,0 +1,60 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ export interface RadioProps {
6
+ /** Controlled checked state */
7
+ checked?: boolean
8
+ /** Disabled state */
9
+ disabled?: boolean
10
+ /** Label text */
11
+ label?: string
12
+ /** Radio group name */
13
+ name?: string
14
+ /** Radio value */
15
+ value?: string
16
+ }
17
+
18
+ const props = withDefaults(defineProps<RadioProps>(), {
19
+ checked: false,
20
+ disabled: false,
21
+ })
22
+
23
+ const emit = defineEmits<{
24
+ (e: 'change', event: Event): void
25
+ }>()
26
+
27
+ const classes = computed(() =>
28
+ [
29
+ 'strand-radio',
30
+ props.checked && 'strand-radio--checked',
31
+ props.disabled && 'strand-radio--disabled',
32
+ ]
33
+ .filter(Boolean)
34
+ .join(' '),
35
+ )
36
+
37
+ function handleChange(event: Event) {
38
+ if (!props.disabled) {
39
+ emit('change', event)
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <label :class="classes">
46
+ <input
47
+ type="radio"
48
+ class="strand-radio__native"
49
+ :checked="checked"
50
+ :disabled="disabled"
51
+ :name="name"
52
+ :value="value"
53
+ @change="handleChange"
54
+ />
55
+ <span class="strand-radio__control" aria-hidden="true">
56
+ <span class="strand-radio__dot" />
57
+ </span>
58
+ <span v-if="label" class="strand-radio__label">{{ label }}</span>
59
+ </label>
60
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Radio } from './Radio.vue'
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Section from './Section.vue'
4
+
5
+ describe('Section', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders a section element', () => {
9
+ const { container } = render(Section, { slots: { default: 'Content' } })
10
+ expect(container.firstElementChild?.tagName).toBe('SECTION')
11
+ })
12
+
13
+ it('renders slot content', () => {
14
+ const { getByText } = render(Section, { slots: { default: 'Hello' } })
15
+ expect(getByText('Hello')).toBeTruthy()
16
+ })
17
+
18
+ // ── Variants ──
19
+
20
+ it('applies standard variant class by default', () => {
21
+ const { container } = render(Section, { slots: { default: 'Test' } })
22
+ expect(container.firstElementChild?.className).toContain('strand-section--standard')
23
+ })
24
+
25
+ it('applies hero variant class', () => {
26
+ const { container } = render(Section, {
27
+ props: { variant: 'hero' },
28
+ slots: { default: 'Test' },
29
+ })
30
+ expect(container.firstElementChild?.className).toContain('strand-section--hero')
31
+ })
32
+
33
+ // ── Background ──
34
+
35
+ it('applies primary background class by default', () => {
36
+ const { container } = render(Section, { slots: { default: 'Test' } })
37
+ expect(container.firstElementChild?.className).toContain('strand-section--bg-primary')
38
+ })
39
+
40
+ it('applies elevated background class', () => {
41
+ const { container } = render(Section, {
42
+ props: { background: 'elevated' },
43
+ slots: { default: 'Test' },
44
+ })
45
+ expect(container.firstElementChild?.className).toContain('strand-section--bg-elevated')
46
+ })
47
+
48
+ it('applies recessed background class', () => {
49
+ const { container } = render(Section, {
50
+ props: { background: 'recessed' },
51
+ slots: { default: 'Test' },
52
+ })
53
+ expect(container.firstElementChild?.className).toContain('strand-section--bg-recessed')
54
+ })
55
+
56
+ // ── Custom className ──
57
+
58
+ it('merges custom className', () => {
59
+ const { container } = render(Section, {
60
+ props: { className: 'custom' },
61
+ slots: { default: 'Test' },
62
+ })
63
+ const el = container.firstElementChild
64
+ expect(el?.className).toContain('strand-section')
65
+ expect(el?.className).toContain('custom')
66
+ })
67
+
68
+ // ── Props forwarding ──
69
+
70
+ it('forwards additional attributes', () => {
71
+ const { container } = render(Section, {
72
+ attrs: { id: 'hero-section' },
73
+ slots: { default: 'Test' },
74
+ })
75
+ expect(container.firstElementChild?.getAttribute('id')).toBe('hero-section')
76
+ })
77
+ })
@@ -0,0 +1,36 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** Padding variant */
7
+ variant?: 'standard' | 'hero'
8
+ /** Surface background */
9
+ background?: 'primary' | 'elevated' | 'recessed'
10
+ /** Additional CSS class */
11
+ className?: string
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ variant: 'standard',
16
+ background: 'primary',
17
+ className: '',
18
+ })
19
+
20
+ const classes = computed(() =>
21
+ [
22
+ 'strand-section',
23
+ `strand-section--${props.variant}`,
24
+ `strand-section--bg-${props.background}`,
25
+ props.className,
26
+ ]
27
+ .filter(Boolean)
28
+ .join(' '),
29
+ )
30
+ </script>
31
+
32
+ <template>
33
+ <section :class="classes" v-bind="$attrs">
34
+ <slot />
35
+ </section>
36
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Section } from './Section.vue'
@@ -0,0 +1,102 @@
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 Select from './Select.vue'
6
+
7
+ const defaultOptions = [
8
+ { value: 'a', label: 'Option A' },
9
+ { value: 'b', label: 'Option B' },
10
+ { value: 'c', label: 'Option C' },
11
+ ]
12
+
13
+ describe('Select', () => {
14
+ it('renders with default props', () => {
15
+ const { container } = render(Select, {
16
+ props: { options: defaultOptions },
17
+ })
18
+ const wrapper = container.querySelector('.strand-select')
19
+ expect(wrapper).toBeInTheDocument()
20
+ const select = container.querySelector('.strand-select__field') as HTMLSelectElement
21
+ expect(select).toBeInTheDocument()
22
+ expect(select).not.toBeDisabled()
23
+ })
24
+
25
+ it('renders all options', () => {
26
+ const { container } = render(Select, {
27
+ props: { options: defaultOptions },
28
+ })
29
+ const options = container.querySelectorAll('option')
30
+ expect(options).toHaveLength(3)
31
+ expect(options[0]).toHaveTextContent('Option A')
32
+ expect(options[0]).toHaveAttribute('value', 'a')
33
+ expect(options[1]).toHaveTextContent('Option B')
34
+ expect(options[2]).toHaveTextContent('Option C')
35
+ })
36
+
37
+ it('renders placeholder as first disabled option', () => {
38
+ const { container } = render(Select, {
39
+ props: { options: defaultOptions, placeholder: 'Choose one' },
40
+ })
41
+ const options = container.querySelectorAll('option')
42
+ expect(options).toHaveLength(4)
43
+ expect(options[0]).toHaveTextContent('Choose one')
44
+ expect(options[0]).toHaveAttribute('value', '')
45
+ expect(options[0]).toBeDisabled()
46
+ })
47
+
48
+ it('applies error class and aria-invalid', () => {
49
+ const { container } = render(Select, {
50
+ props: { options: defaultOptions, error: true },
51
+ })
52
+ const wrapper = container.querySelector('.strand-select')
53
+ expect(wrapper).toHaveClass('strand-select--error')
54
+ const select = container.querySelector('.strand-select__field')
55
+ expect(select).toHaveAttribute('aria-invalid', 'true')
56
+ })
57
+
58
+ it('does not set aria-invalid when no error', () => {
59
+ const { container } = render(Select, {
60
+ props: { options: defaultOptions },
61
+ })
62
+ const select = container.querySelector('.strand-select__field')
63
+ expect(select).not.toHaveAttribute('aria-invalid')
64
+ })
65
+
66
+ it('applies disabled class and attribute', () => {
67
+ const { container } = render(Select, {
68
+ props: { options: defaultOptions, disabled: true },
69
+ })
70
+ const wrapper = container.querySelector('.strand-select')
71
+ expect(wrapper).toHaveClass('strand-select--disabled')
72
+ const select = container.querySelector('.strand-select__field')
73
+ expect(select).toBeDisabled()
74
+ })
75
+
76
+ it('renders arrow indicator with aria-hidden', () => {
77
+ const { container } = render(Select, {
78
+ props: { options: defaultOptions },
79
+ })
80
+ const arrow = container.querySelector('.strand-select__arrow')
81
+ expect(arrow).toBeInTheDocument()
82
+ expect(arrow).toHaveAttribute('aria-hidden', 'true')
83
+ })
84
+
85
+ it('emits update:modelValue on change', async () => {
86
+ const { container, emitted } = render(Select, {
87
+ props: { options: defaultOptions, modelValue: 'a' },
88
+ })
89
+ const select = container.querySelector('.strand-select__field') as HTMLSelectElement
90
+ await fireEvent.update(select, 'b')
91
+ expect(emitted()['update:modelValue']).toBeTruthy()
92
+ expect(emitted()['update:modelValue'][0]).toEqual(['b'])
93
+ })
94
+
95
+ it('sets selected value from modelValue', () => {
96
+ const { container } = render(Select, {
97
+ props: { options: defaultOptions, modelValue: 'b' },
98
+ })
99
+ const select = container.querySelector('.strand-select__field') as HTMLSelectElement
100
+ expect(select.value).toBe('b')
101
+ })
102
+ })
@@ -0,0 +1,70 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ export interface SelectOption {
6
+ value: string
7
+ label: string
8
+ }
9
+
10
+ export interface SelectProps {
11
+ /** Array of options to display */
12
+ options: SelectOption[]
13
+ /** Disabled state */
14
+ disabled?: boolean
15
+ /** Currently selected value */
16
+ modelValue?: string
17
+ /** Show error styling */
18
+ error?: boolean
19
+ /** Placeholder text shown as first disabled option */
20
+ placeholder?: string
21
+ }
22
+
23
+ const props = withDefaults(defineProps<SelectProps>(), {
24
+ disabled: false,
25
+ error: false,
26
+ })
27
+
28
+ const emit = defineEmits<{
29
+ (e: 'update:modelValue', value: string): void
30
+ }>()
31
+
32
+ const wrapperClasses = computed(() =>
33
+ [
34
+ 'strand-select',
35
+ props.error && 'strand-select--error',
36
+ props.disabled && 'strand-select--disabled',
37
+ ]
38
+ .filter(Boolean)
39
+ .join(' '),
40
+ )
41
+
42
+ function handleChange(event: Event) {
43
+ const target = event.target as HTMLSelectElement
44
+ emit('update:modelValue', target.value)
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <div :class="wrapperClasses">
50
+ <select
51
+ class="strand-select__field"
52
+ :value="modelValue"
53
+ :disabled="disabled"
54
+ :aria-invalid="error ? 'true' : undefined"
55
+ @change="handleChange"
56
+ >
57
+ <option v-if="placeholder" value="" disabled>
58
+ {{ placeholder }}
59
+ </option>
60
+ <option
61
+ v-for="opt in options"
62
+ :key="opt.value"
63
+ :value="opt.value"
64
+ >
65
+ {{ opt.label }}
66
+ </option>
67
+ </select>
68
+ <span class="strand-select__arrow" aria-hidden="true" />
69
+ </div>
70
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Select } from './Select.vue'