@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,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Skeleton from './Skeleton.vue'
4
+
5
+ describe('Skeleton', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders a div element', () => {
9
+ const { container } = render(Skeleton)
10
+ expect(container.firstElementChild?.tagName).toBe('DIV')
11
+ })
12
+
13
+ it('has aria-hidden attribute', () => {
14
+ const { container } = render(Skeleton)
15
+ expect(container.firstElementChild?.getAttribute('aria-hidden')).toBe('true')
16
+ })
17
+
18
+ // ── Variants ──
19
+
20
+ it('applies text variant class by default', () => {
21
+ const { container } = render(Skeleton)
22
+ expect(container.firstElementChild?.className).toContain('strand-skeleton--text')
23
+ })
24
+
25
+ it('applies rectangle variant class', () => {
26
+ const { container } = render(Skeleton, { props: { variant: 'rectangle' } })
27
+ expect(container.firstElementChild?.className).toContain('strand-skeleton--rectangle')
28
+ })
29
+
30
+ it('applies circle variant class', () => {
31
+ const { container } = render(Skeleton, { props: { variant: 'circle', width: '48px' } })
32
+ expect(container.firstElementChild?.className).toContain('strand-skeleton--circle')
33
+ })
34
+
35
+ // ── Shimmer ──
36
+
37
+ it('applies shimmer class', () => {
38
+ const { container } = render(Skeleton)
39
+ expect(container.firstElementChild?.className).toContain('strand-skeleton--shimmer')
40
+ })
41
+
42
+ // ── Dimensions ──
43
+
44
+ it('sets width to 100% for text variant by default', () => {
45
+ const { container } = render(Skeleton)
46
+ const el = container.firstElementChild as HTMLElement
47
+ expect(el.style.width).toBe('100%')
48
+ })
49
+
50
+ it('uses custom width when provided', () => {
51
+ const { container } = render(Skeleton, { props: { width: '200px' } })
52
+ const el = container.firstElementChild as HTMLElement
53
+ expect(el.style.width).toBe('200px')
54
+ })
55
+
56
+ it('uses custom height when provided', () => {
57
+ const { container } = render(Skeleton, { props: { height: '50px' } })
58
+ const el = container.firstElementChild as HTMLElement
59
+ expect(el.style.height).toBe('50px')
60
+ })
61
+
62
+ it('sets height equal to width for circle variant', () => {
63
+ const { container } = render(Skeleton, { props: { variant: 'circle', width: '48px' } })
64
+ const el = container.firstElementChild as HTMLElement
65
+ expect(el.style.width).toBe('48px')
66
+ expect(el.style.height).toBe('48px')
67
+ })
68
+
69
+ // ── Custom className ──
70
+
71
+ it('merges custom className', () => {
72
+ const { container } = render(Skeleton, { props: { className: 'custom' } })
73
+ const el = container.firstElementChild
74
+ expect(el?.className).toContain('strand-skeleton')
75
+ expect(el?.className).toContain('custom')
76
+ })
77
+ })
@@ -0,0 +1,48 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** Shape variant */
7
+ variant?: 'text' | 'rectangle' | 'circle'
8
+ /** CSS width value */
9
+ width?: string
10
+ /** CSS height value */
11
+ height?: string
12
+ /** Additional CSS class */
13
+ className?: string
14
+ }
15
+
16
+ const props = withDefaults(defineProps<Props>(), {
17
+ variant: 'text',
18
+ className: '',
19
+ })
20
+
21
+ const effectiveWidth = computed(() =>
22
+ props.width ?? (props.variant === 'text' ? '100%' : undefined),
23
+ )
24
+
25
+ const effectiveHeight = computed(() =>
26
+ props.variant === 'circle' ? effectiveWidth.value : props.height,
27
+ )
28
+
29
+ const classes = computed(() =>
30
+ [
31
+ 'strand-skeleton',
32
+ `strand-skeleton--${props.variant}`,
33
+ 'strand-skeleton--shimmer',
34
+ props.className,
35
+ ]
36
+ .filter(Boolean)
37
+ .join(' '),
38
+ )
39
+
40
+ const inlineStyle = computed(() => ({
41
+ width: effectiveWidth.value,
42
+ height: effectiveHeight.value,
43
+ }))
44
+ </script>
45
+
46
+ <template>
47
+ <div :class="classes" aria-hidden="true" :style="inlineStyle" v-bind="$attrs" />
48
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Skeleton } from './Skeleton.vue'
@@ -0,0 +1,73 @@
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 Slider from './Slider.vue'
6
+
7
+ describe('Slider', () => {
8
+ it('renders with default props', () => {
9
+ const { container } = render(Slider)
10
+ const wrapper = container.querySelector('.strand-slider')
11
+ expect(wrapper).toBeInTheDocument()
12
+ const input = container.querySelector('.strand-slider__field') as HTMLInputElement
13
+ expect(input).toBeInTheDocument()
14
+ expect(input).toHaveAttribute('type', 'range')
15
+ expect(input).toHaveAttribute('min', '0')
16
+ expect(input).toHaveAttribute('max', '100')
17
+ expect(input).toHaveAttribute('step', '1')
18
+ })
19
+
20
+ it('sets aria-valuemin, aria-valuemax, and aria-valuenow', () => {
21
+ const { container } = render(Slider, {
22
+ props: { min: 10, max: 200, modelValue: 50 },
23
+ })
24
+ const input = container.querySelector('.strand-slider__field')
25
+ expect(input).toHaveAttribute('aria-valuemin', '10')
26
+ expect(input).toHaveAttribute('aria-valuemax', '200')
27
+ expect(input).toHaveAttribute('aria-valuenow', '50')
28
+ })
29
+
30
+ it('applies custom min, max, step props', () => {
31
+ const { container } = render(Slider, {
32
+ props: { min: 5, max: 50, step: 5 },
33
+ })
34
+ const input = container.querySelector('.strand-slider__field')
35
+ expect(input).toHaveAttribute('min', '5')
36
+ expect(input).toHaveAttribute('max', '50')
37
+ expect(input).toHaveAttribute('step', '5')
38
+ })
39
+
40
+ it('applies disabled class and attribute', () => {
41
+ const { container } = render(Slider, {
42
+ props: { disabled: true },
43
+ })
44
+ const wrapper = container.querySelector('.strand-slider')
45
+ expect(wrapper).toHaveClass('strand-slider--disabled')
46
+ const input = container.querySelector('.strand-slider__field')
47
+ expect(input).toBeDisabled()
48
+ })
49
+
50
+ it('does not apply disabled class when not disabled', () => {
51
+ const { container } = render(Slider)
52
+ const wrapper = container.querySelector('.strand-slider')
53
+ expect(wrapper).not.toHaveClass('strand-slider--disabled')
54
+ })
55
+
56
+ it('emits update:modelValue on input', async () => {
57
+ const { container, emitted } = render(Slider, {
58
+ props: { modelValue: 50 },
59
+ })
60
+ const input = container.querySelector('.strand-slider__field') as HTMLInputElement
61
+ await fireEvent.update(input, '75')
62
+ expect(emitted()['update:modelValue']).toBeTruthy()
63
+ expect(emitted()['update:modelValue'][0]).toEqual([75])
64
+ })
65
+
66
+ it('sets value from modelValue prop', () => {
67
+ const { container } = render(Slider, {
68
+ props: { modelValue: 42 },
69
+ })
70
+ const input = container.querySelector('.strand-slider__field') as HTMLInputElement
71
+ expect(input.value).toBe('42')
72
+ })
73
+ })
@@ -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 SliderProps {
6
+ /** Minimum value */
7
+ min?: number
8
+ /** Maximum value */
9
+ max?: number
10
+ /** Step increment */
11
+ step?: number
12
+ /** Current value */
13
+ modelValue?: number
14
+ /** Disabled state */
15
+ disabled?: boolean
16
+ }
17
+
18
+ const props = withDefaults(defineProps<SliderProps>(), {
19
+ min: 0,
20
+ max: 100,
21
+ step: 1,
22
+ disabled: false,
23
+ })
24
+
25
+ const emit = defineEmits<{
26
+ (e: 'update:modelValue', value: number): void
27
+ }>()
28
+
29
+ const wrapperClasses = computed(() =>
30
+ [
31
+ 'strand-slider',
32
+ props.disabled && 'strand-slider--disabled',
33
+ ]
34
+ .filter(Boolean)
35
+ .join(' '),
36
+ )
37
+
38
+ function handleInput(event: Event) {
39
+ const target = event.target as HTMLInputElement
40
+ emit('update:modelValue', Number(target.value))
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <div :class="wrapperClasses">
46
+ <input
47
+ type="range"
48
+ class="strand-slider__field"
49
+ :min="min"
50
+ :max="max"
51
+ :step="step"
52
+ :value="modelValue"
53
+ :disabled="disabled"
54
+ :aria-valuemin="min"
55
+ :aria-valuemax="max"
56
+ :aria-valuenow="modelValue"
57
+ @input="handleInput"
58
+ />
59
+ </div>
60
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Slider } from './Slider.vue'
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Spinner from './Spinner.vue'
4
+
5
+ describe('Spinner', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders a span element', () => {
9
+ const { container } = render(Spinner)
10
+ expect(container.firstElementChild?.tagName).toBe('SPAN')
11
+ })
12
+
13
+ it('has status role', () => {
14
+ const { getByRole } = render(Spinner)
15
+ expect(getByRole('status')).toBeTruthy()
16
+ })
17
+
18
+ // ── Inner elements ──
19
+
20
+ it('renders a ring span with aria-hidden', () => {
21
+ const { container } = render(Spinner)
22
+ const ring = container.querySelector('.strand-spinner__ring')
23
+ expect(ring).toBeTruthy()
24
+ expect(ring?.getAttribute('aria-hidden')).toBe('true')
25
+ })
26
+
27
+ it('renders sr-only loading text', () => {
28
+ const { container } = render(Spinner)
29
+ const srOnly = container.querySelector('.strand-spinner__sr-only')
30
+ expect(srOnly).toBeTruthy()
31
+ expect(srOnly?.textContent).toBe('Loading')
32
+ })
33
+
34
+ // ── Sizes ──
35
+
36
+ it('applies md size class by default', () => {
37
+ const { container } = render(Spinner)
38
+ expect(container.firstElementChild?.className).toContain('strand-spinner--md')
39
+ })
40
+
41
+ it('applies sm size class', () => {
42
+ const { container } = render(Spinner, { props: { size: 'sm' } })
43
+ expect(container.firstElementChild?.className).toContain('strand-spinner--sm')
44
+ })
45
+
46
+ it('applies lg size class', () => {
47
+ const { container } = render(Spinner, { props: { size: 'lg' } })
48
+ expect(container.firstElementChild?.className).toContain('strand-spinner--lg')
49
+ })
50
+
51
+ // ── Custom className ──
52
+
53
+ it('merges custom className', () => {
54
+ const { container } = render(Spinner, { props: { className: 'custom' } })
55
+ const el = container.firstElementChild
56
+ expect(el?.className).toContain('strand-spinner')
57
+ expect(el?.className).toContain('custom')
58
+ })
59
+
60
+ // ── Props forwarding ──
61
+
62
+ it('forwards additional attributes', () => {
63
+ const { container } = render(Spinner, { attrs: { id: 'my-spinner' } })
64
+ expect(container.firstElementChild?.getAttribute('id')).toBe('my-spinner')
65
+ })
66
+ })
@@ -0,0 +1,33 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** Size of the spinner */
7
+ size?: 'sm' | 'md' | 'lg'
8
+ /** Additional CSS class */
9
+ className?: string
10
+ }
11
+
12
+ const props = withDefaults(defineProps<Props>(), {
13
+ size: 'md',
14
+ className: '',
15
+ })
16
+
17
+ const classes = computed(() =>
18
+ [
19
+ 'strand-spinner',
20
+ `strand-spinner--${props.size}`,
21
+ props.className,
22
+ ]
23
+ .filter(Boolean)
24
+ .join(' '),
25
+ )
26
+ </script>
27
+
28
+ <template>
29
+ <span :class="classes" role="status" v-bind="$attrs">
30
+ <span class="strand-spinner__ring" aria-hidden="true" />
31
+ <span class="strand-spinner__sr-only">Loading</span>
32
+ </span>
33
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Spinner } from './Spinner.vue'
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Stack from './Stack.vue'
4
+
5
+ describe('Stack', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders a div element', () => {
9
+ const { container } = render(Stack, { slots: { default: '<div>Item</div>' } })
10
+ expect(container.firstElementChild?.tagName).toBe('DIV')
11
+ })
12
+
13
+ it('applies strand-stack base class', () => {
14
+ const { container } = render(Stack, { slots: { default: '<div>Item</div>' } })
15
+ expect(container.firstElementChild?.className).toContain('strand-stack')
16
+ })
17
+
18
+ // ── Direction ──
19
+
20
+ it('applies vertical direction class by default', () => {
21
+ const { container } = render(Stack, { slots: { default: '<div>Item</div>' } })
22
+ expect(container.firstElementChild?.className).toContain('strand-stack--vertical')
23
+ })
24
+
25
+ it('applies horizontal direction class', () => {
26
+ const { container } = render(Stack, {
27
+ props: { direction: 'horizontal' },
28
+ slots: { default: '<div>Item</div>' },
29
+ })
30
+ expect(container.firstElementChild?.className).toContain('strand-stack--horizontal')
31
+ })
32
+
33
+ // ── Gap ──
34
+
35
+ it('sets gap with default space-4', () => {
36
+ const { container } = render(Stack, { slots: { default: '<div>Item</div>' } })
37
+ const el = container.firstElementChild as HTMLElement
38
+ expect(el.style.gap).toBe('var(--strand-space-4)')
39
+ })
40
+
41
+ it('sets gap to match gap prop', () => {
42
+ const { container } = render(Stack, {
43
+ props: { gap: 8 },
44
+ slots: { default: '<div>Item</div>' },
45
+ })
46
+ const el = container.firstElementChild as HTMLElement
47
+ expect(el.style.gap).toBe('var(--strand-space-8)')
48
+ })
49
+
50
+ // ── Align ──
51
+
52
+ it('does not apply align class when stretch (default)', () => {
53
+ const { container } = render(Stack, { slots: { default: '<div>Item</div>' } })
54
+ expect(container.firstElementChild?.className).not.toContain('strand-stack--align-')
55
+ })
56
+
57
+ it('applies align-center class', () => {
58
+ const { container } = render(Stack, {
59
+ props: { align: 'center' },
60
+ slots: { default: '<div>Item</div>' },
61
+ })
62
+ expect(container.firstElementChild?.className).toContain('strand-stack--align-center')
63
+ })
64
+
65
+ it('applies align-start class', () => {
66
+ const { container } = render(Stack, {
67
+ props: { align: 'start' },
68
+ slots: { default: '<div>Item</div>' },
69
+ })
70
+ expect(container.firstElementChild?.className).toContain('strand-stack--align-start')
71
+ })
72
+
73
+ it('applies align-end class', () => {
74
+ const { container } = render(Stack, {
75
+ props: { align: 'end' },
76
+ slots: { default: '<div>Item</div>' },
77
+ })
78
+ expect(container.firstElementChild?.className).toContain('strand-stack--align-end')
79
+ })
80
+
81
+ // ── Justify ──
82
+
83
+ it('does not apply justify class by default', () => {
84
+ const { container } = render(Stack, { slots: { default: '<div>Item</div>' } })
85
+ expect(container.firstElementChild?.className).not.toContain('strand-stack--justify-')
86
+ })
87
+
88
+ it('applies justify-center class', () => {
89
+ const { container } = render(Stack, {
90
+ props: { justify: 'center' },
91
+ slots: { default: '<div>Item</div>' },
92
+ })
93
+ expect(container.firstElementChild?.className).toContain('strand-stack--justify-center')
94
+ })
95
+
96
+ it('applies justify-between class', () => {
97
+ const { container } = render(Stack, {
98
+ props: { justify: 'between' },
99
+ slots: { default: '<div>Item</div>' },
100
+ })
101
+ expect(container.firstElementChild?.className).toContain('strand-stack--justify-between')
102
+ })
103
+
104
+ // ── Wrap ──
105
+
106
+ it('does not apply wrap class by default', () => {
107
+ const { container } = render(Stack, { slots: { default: '<div>Item</div>' } })
108
+ expect(container.firstElementChild?.className).not.toContain('strand-stack--wrap')
109
+ })
110
+
111
+ it('applies wrap class when enabled', () => {
112
+ const { container } = render(Stack, {
113
+ props: { wrap: true },
114
+ slots: { default: '<div>Item</div>' },
115
+ })
116
+ expect(container.firstElementChild?.className).toContain('strand-stack--wrap')
117
+ })
118
+
119
+ // ── Custom className ──
120
+
121
+ it('merges custom className', () => {
122
+ const { container } = render(Stack, {
123
+ props: { className: 'custom' },
124
+ slots: { default: '<div>Item</div>' },
125
+ })
126
+ const el = container.firstElementChild
127
+ expect(el?.className).toContain('strand-stack')
128
+ expect(el?.className).toContain('custom')
129
+ })
130
+
131
+ // ── Props forwarding ──
132
+
133
+ it('forwards additional attributes', () => {
134
+ const { container } = render(Stack, {
135
+ attrs: { id: 'my-stack' },
136
+ slots: { default: '<div>Item</div>' },
137
+ })
138
+ expect(container.firstElementChild?.getAttribute('id')).toBe('my-stack')
139
+ })
140
+ })
@@ -0,0 +1,50 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** Flex direction */
7
+ direction?: 'vertical' | 'horizontal'
8
+ /** Gap between items, maps to --strand-space-{n} */
9
+ gap?: number
10
+ /** Cross-axis alignment */
11
+ align?: 'start' | 'center' | 'end' | 'stretch'
12
+ /** Main-axis alignment */
13
+ justify?: 'start' | 'center' | 'end' | 'between' | 'around'
14
+ /** Enable flex-wrap */
15
+ wrap?: boolean
16
+ /** Additional CSS class */
17
+ className?: string
18
+ }
19
+
20
+ const props = withDefaults(defineProps<Props>(), {
21
+ direction: 'vertical',
22
+ gap: 4,
23
+ align: 'stretch',
24
+ wrap: false,
25
+ className: '',
26
+ })
27
+
28
+ const classes = computed(() =>
29
+ [
30
+ 'strand-stack',
31
+ `strand-stack--${props.direction}`,
32
+ props.align !== 'stretch' && `strand-stack--align-${props.align}`,
33
+ props.justify && `strand-stack--justify-${props.justify}`,
34
+ props.wrap && 'strand-stack--wrap',
35
+ props.className,
36
+ ]
37
+ .filter(Boolean)
38
+ .join(' '),
39
+ )
40
+
41
+ const inlineStyle = computed(() => ({
42
+ gap: `var(--strand-space-${props.gap})`,
43
+ }))
44
+ </script>
45
+
46
+ <template>
47
+ <div :class="classes" :style="inlineStyle" v-bind="$attrs">
48
+ <slot />
49
+ </div>
50
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Stack } from './Stack.vue'
@@ -0,0 +1,116 @@
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 Switch from './Switch.vue'
6
+
7
+ describe('Switch', () => {
8
+ // -- Rendering --
9
+
10
+ it('renders as switch role', () => {
11
+ const { getByRole } = render(Switch)
12
+ expect(getByRole('switch')).toBeTruthy()
13
+ })
14
+
15
+ // -- Toggle on click --
16
+
17
+ it('emits change with true when unchecked switch is clicked', async () => {
18
+ const { getByRole, emitted } = render(Switch)
19
+ await fireEvent.click(getByRole('switch'))
20
+ expect(emitted().change[0]).toEqual([true])
21
+ })
22
+
23
+ it('emits change with false when checked switch is clicked', async () => {
24
+ const { getByRole, emitted } = render(Switch, {
25
+ props: { checked: true },
26
+ })
27
+ await fireEvent.click(getByRole('switch'))
28
+ expect(emitted().change[0]).toEqual([false])
29
+ })
30
+
31
+ // -- Toggle on Space key --
32
+
33
+ it('toggles on Space key', async () => {
34
+ const { getByRole, emitted } = render(Switch)
35
+ await fireEvent.keyDown(getByRole('switch'), { key: ' ' })
36
+ expect(emitted().change[0]).toEqual([true])
37
+ })
38
+
39
+ // -- Toggle on Enter key --
40
+
41
+ it('toggles on Enter key', async () => {
42
+ const { getByRole, emitted } = render(Switch)
43
+ await fireEvent.keyDown(getByRole('switch'), { key: 'Enter' })
44
+ expect(emitted().change[0]).toEqual([true])
45
+ })
46
+
47
+ // -- Checked state --
48
+
49
+ it('sets aria-checked true when checked', () => {
50
+ const { getByRole } = render(Switch, {
51
+ props: { checked: true },
52
+ })
53
+ expect(getByRole('switch')).toHaveAttribute('aria-checked', 'true')
54
+ })
55
+
56
+ it('sets aria-checked false when unchecked', () => {
57
+ const { getByRole } = render(Switch, {
58
+ props: { checked: false },
59
+ })
60
+ expect(getByRole('switch')).toHaveAttribute('aria-checked', 'false')
61
+ })
62
+
63
+ it('applies checked class', () => {
64
+ const { container } = render(Switch, {
65
+ props: { checked: true },
66
+ })
67
+ expect(container.querySelector('.strand-switch--checked')).toBeTruthy()
68
+ })
69
+
70
+ // -- Disabled state --
71
+
72
+ it('disables the switch when disabled prop is set', () => {
73
+ const { getByRole } = render(Switch, {
74
+ props: { disabled: true },
75
+ })
76
+ expect(getByRole('switch')).toBeDisabled()
77
+ })
78
+
79
+ it('does not emit change when disabled', async () => {
80
+ const { getByRole, emitted } = render(Switch, {
81
+ props: { disabled: true },
82
+ })
83
+ await fireEvent.click(getByRole('switch'))
84
+ expect(emitted().change).toBeUndefined()
85
+ })
86
+
87
+ it('applies disabled class', () => {
88
+ const { container } = render(Switch, {
89
+ props: { disabled: true },
90
+ })
91
+ expect(container.querySelector('.strand-switch--disabled')).toBeTruthy()
92
+ })
93
+
94
+ // -- Inline label --
95
+
96
+ it('renders inline label text', () => {
97
+ const { getByText } = render(Switch, {
98
+ props: { label: 'Dark mode' },
99
+ })
100
+ expect(getByText('Dark mode')).toBeTruthy()
101
+ })
102
+
103
+ it('does not render label span without label prop', () => {
104
+ const { container } = render(Switch)
105
+ expect(container.querySelector('.strand-switch__label')).not.toBeInTheDocument()
106
+ })
107
+
108
+ // -- Structure --
109
+
110
+ it('renders thumb with aria-hidden', () => {
111
+ const { container } = render(Switch)
112
+ const thumb = container.querySelector('.strand-switch__thumb')
113
+ expect(thumb).toBeInTheDocument()
114
+ expect(thumb).toHaveAttribute('aria-hidden', 'true')
115
+ })
116
+ })