@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,73 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Grid from './Grid.vue'
4
+
5
+ describe('Grid', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders a div element', () => {
9
+ const { container } = render(Grid, { slots: { default: '<div>Item</div>' } })
10
+ expect(container.firstElementChild?.tagName).toBe('DIV')
11
+ })
12
+
13
+ it('applies strand-grid base class', () => {
14
+ const { container } = render(Grid, { slots: { default: '<div>Item</div>' } })
15
+ expect(container.firstElementChild?.className).toContain('strand-grid')
16
+ })
17
+
18
+ // ── Columns ──
19
+
20
+ it('sets grid-template-columns with default 1 column', () => {
21
+ const { container } = render(Grid, { slots: { default: '<div>Item</div>' } })
22
+ const el = container.firstElementChild as HTMLElement
23
+ expect(el.style.gridTemplateColumns).toBe('repeat(1, 1fr)')
24
+ })
25
+
26
+ it('sets grid-template-columns to match columns prop', () => {
27
+ const { container } = render(Grid, {
28
+ props: { columns: 3 },
29
+ slots: { default: '<div>Item</div>' },
30
+ })
31
+ const el = container.firstElementChild as HTMLElement
32
+ expect(el.style.gridTemplateColumns).toBe('repeat(3, 1fr)')
33
+ })
34
+
35
+ // ── Gap ──
36
+
37
+ it('sets gap with default space-4', () => {
38
+ const { container } = render(Grid, { slots: { default: '<div>Item</div>' } })
39
+ const el = container.firstElementChild as HTMLElement
40
+ expect(el.style.gap).toBe('var(--strand-space-4)')
41
+ })
42
+
43
+ it('sets gap to match gap prop', () => {
44
+ const { container } = render(Grid, {
45
+ props: { gap: 8 },
46
+ slots: { default: '<div>Item</div>' },
47
+ })
48
+ const el = container.firstElementChild as HTMLElement
49
+ expect(el.style.gap).toBe('var(--strand-space-8)')
50
+ })
51
+
52
+ // ── Custom className ──
53
+
54
+ it('merges custom className', () => {
55
+ const { container } = render(Grid, {
56
+ props: { className: 'custom' },
57
+ slots: { default: '<div>Item</div>' },
58
+ })
59
+ const el = container.firstElementChild
60
+ expect(el?.className).toContain('strand-grid')
61
+ expect(el?.className).toContain('custom')
62
+ })
63
+
64
+ // ── Props forwarding ──
65
+
66
+ it('forwards additional attributes', () => {
67
+ const { container } = render(Grid, {
68
+ attrs: { id: 'my-grid' },
69
+ slots: { default: '<div>Item</div>' },
70
+ })
71
+ expect(container.firstElementChild?.getAttribute('id')).toBe('my-grid')
72
+ })
73
+ })
@@ -0,0 +1,34 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** Number of equal-width columns */
7
+ columns?: number
8
+ /** Gap between items, maps to --strand-space-{n} */
9
+ gap?: number
10
+ /** Additional CSS class */
11
+ className?: string
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ columns: 1,
16
+ gap: 4,
17
+ className: '',
18
+ })
19
+
20
+ const classes = computed(() =>
21
+ ['strand-grid', props.className].filter(Boolean).join(' '),
22
+ )
23
+
24
+ const inlineStyle = computed(() => ({
25
+ gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
26
+ gap: `var(--strand-space-${props.gap})`,
27
+ }))
28
+ </script>
29
+
30
+ <template>
31
+ <div :class="classes" :style="inlineStyle" v-bind="$attrs">
32
+ <slot />
33
+ </div>
34
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Grid } from './Grid.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 Input from './Input.vue'
6
+
7
+ describe('Input', () => {
8
+ it('renders with default props', () => {
9
+ const { container } = render(Input)
10
+ const wrapper = container.querySelector('.strand-input')
11
+ expect(wrapper).toBeInTheDocument()
12
+ const input = container.querySelector('.strand-input__field') as HTMLInputElement
13
+ expect(input).toBeInTheDocument()
14
+ expect(input).toHaveAttribute('type', 'text')
15
+ expect(input).not.toBeDisabled()
16
+ })
17
+
18
+ it('applies error class and aria-invalid', () => {
19
+ const { container } = render(Input, {
20
+ props: { error: true },
21
+ })
22
+ const wrapper = container.querySelector('.strand-input')
23
+ expect(wrapper).toHaveClass('strand-input--error')
24
+ const input = container.querySelector('.strand-input__field')
25
+ expect(input).toHaveAttribute('aria-invalid', 'true')
26
+ })
27
+
28
+ it('does not set aria-invalid when no error', () => {
29
+ const { container } = render(Input)
30
+ const input = container.querySelector('.strand-input__field')
31
+ expect(input).not.toHaveAttribute('aria-invalid')
32
+ })
33
+
34
+ it('applies disabled class and attribute', () => {
35
+ const { container } = render(Input, {
36
+ props: { disabled: true },
37
+ })
38
+ const wrapper = container.querySelector('.strand-input')
39
+ expect(wrapper).toHaveClass('strand-input--disabled')
40
+ const input = container.querySelector('.strand-input__field')
41
+ expect(input).toBeDisabled()
42
+ })
43
+
44
+ it('sets input type', () => {
45
+ const types = ['text', 'email', 'password', 'search', 'number'] as const
46
+ for (const type of types) {
47
+ const { container } = render(Input, {
48
+ props: { type },
49
+ })
50
+ expect(container.querySelector('.strand-input__field')).toHaveAttribute('type', type)
51
+ }
52
+ })
53
+
54
+ it('emits update:modelValue on input', async () => {
55
+ const { container, emitted } = render(Input, {
56
+ props: { modelValue: '' },
57
+ })
58
+ const input = container.querySelector('.strand-input__field') as HTMLInputElement
59
+ await fireEvent.update(input, 'hello')
60
+ expect(emitted()['update:modelValue']).toBeTruthy()
61
+ expect(emitted()['update:modelValue'][0]).toEqual(['hello'])
62
+ })
63
+
64
+ it('renders leading addon slot with has-leading class', () => {
65
+ const { container } = render(Input, {
66
+ slots: { leading: '<span>$</span>' },
67
+ })
68
+ const wrapper = container.querySelector('.strand-input')
69
+ expect(wrapper).toHaveClass('strand-input--has-leading')
70
+ const leading = container.querySelector('.strand-input__leading')
71
+ expect(leading).toBeInTheDocument()
72
+ expect(leading).toHaveAttribute('aria-hidden', 'true')
73
+ })
74
+
75
+ it('renders trailing addon slot with has-trailing class', () => {
76
+ const { container } = render(Input, {
77
+ slots: { trailing: '<span>%</span>' },
78
+ })
79
+ const wrapper = container.querySelector('.strand-input')
80
+ expect(wrapper).toHaveClass('strand-input--has-trailing')
81
+ const trailing = container.querySelector('.strand-input__trailing')
82
+ expect(trailing).toBeInTheDocument()
83
+ expect(trailing).toHaveAttribute('aria-hidden', 'true')
84
+ })
85
+
86
+ it('does not render addon containers when slots are empty', () => {
87
+ const { container } = render(Input)
88
+ expect(container.querySelector('.strand-input__leading')).not.toBeInTheDocument()
89
+ expect(container.querySelector('.strand-input__trailing')).not.toBeInTheDocument()
90
+ const wrapper = container.querySelector('.strand-input')
91
+ expect(wrapper).not.toHaveClass('strand-input--has-leading')
92
+ expect(wrapper).not.toHaveClass('strand-input--has-trailing')
93
+ })
94
+
95
+ it('passes modelValue to input value', () => {
96
+ const { container } = render(Input, {
97
+ props: { modelValue: 'test value' },
98
+ })
99
+ const input = container.querySelector('.strand-input__field') as HTMLInputElement
100
+ expect(input.value).toBe('test value')
101
+ })
102
+ })
@@ -0,0 +1,63 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed, useSlots } from 'vue'
4
+
5
+ export interface InputProps {
6
+ /** Input type */
7
+ type?: 'text' | 'email' | 'password' | 'search' | 'number'
8
+ /** Show error styling */
9
+ error?: boolean
10
+ /** Disabled state */
11
+ disabled?: boolean
12
+ /** Current value */
13
+ modelValue?: string
14
+ }
15
+
16
+ const props = withDefaults(defineProps<InputProps>(), {
17
+ type: 'text',
18
+ error: false,
19
+ disabled: false,
20
+ })
21
+
22
+ const emit = defineEmits<{
23
+ (e: 'update:modelValue', value: string): void
24
+ }>()
25
+
26
+ const slots = useSlots()
27
+
28
+ const wrapperClasses = computed(() =>
29
+ [
30
+ 'strand-input',
31
+ props.error && 'strand-input--error',
32
+ props.disabled && 'strand-input--disabled',
33
+ !!slots.leading && 'strand-input--has-leading',
34
+ !!slots.trailing && 'strand-input--has-trailing',
35
+ ]
36
+ .filter(Boolean)
37
+ .join(' '),
38
+ )
39
+
40
+ function handleInput(event: Event) {
41
+ const target = event.target as HTMLInputElement
42
+ emit('update:modelValue', target.value)
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <div :class="wrapperClasses">
48
+ <span v-if="$slots.leading" class="strand-input__leading" aria-hidden="true">
49
+ <slot name="leading" />
50
+ </span>
51
+ <input
52
+ :type="type"
53
+ class="strand-input__field"
54
+ :disabled="disabled"
55
+ :aria-invalid="error ? 'true' : undefined"
56
+ :value="modelValue"
57
+ @input="handleInput"
58
+ />
59
+ <span v-if="$slots.trailing" class="strand-input__trailing" aria-hidden="true">
60
+ <slot name="trailing" />
61
+ </span>
62
+ </div>
63
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Input } from './Input.vue'
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Link from './Link.vue'
4
+
5
+ describe('Link', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders an anchor element', () => {
9
+ const { container } = render(Link, {
10
+ props: { href: '/about' },
11
+ slots: { default: 'About' },
12
+ })
13
+ expect(container.firstElementChild?.tagName).toBe('A')
14
+ })
15
+
16
+ it('renders slot content', () => {
17
+ const { getByText } = render(Link, {
18
+ props: { href: '/about' },
19
+ slots: { default: 'About Us' },
20
+ })
21
+ expect(getByText('About Us')).toBeTruthy()
22
+ })
23
+
24
+ it('applies strand-link base class', () => {
25
+ const { container } = render(Link, {
26
+ props: { href: '/about' },
27
+ slots: { default: 'About' },
28
+ })
29
+ expect(container.firstElementChild?.className).toContain('strand-link')
30
+ })
31
+
32
+ // ── Href ──
33
+
34
+ it('sets href attribute', () => {
35
+ const { container } = render(Link, {
36
+ props: { href: '/contact' },
37
+ slots: { default: 'Contact' },
38
+ })
39
+ expect(container.firstElementChild?.getAttribute('href')).toBe('/contact')
40
+ })
41
+
42
+ // ── External ──
43
+
44
+ it('does not set target or rel by default', () => {
45
+ const { container } = render(Link, {
46
+ props: { href: '/about' },
47
+ slots: { default: 'About' },
48
+ })
49
+ const el = container.firstElementChild
50
+ expect(el?.getAttribute('target')).toBeNull()
51
+ expect(el?.getAttribute('rel')).toBeNull()
52
+ })
53
+
54
+ it('sets target _blank when external', () => {
55
+ const { container } = render(Link, {
56
+ props: { href: 'https://example.com', external: true },
57
+ slots: { default: 'External' },
58
+ })
59
+ expect(container.firstElementChild?.getAttribute('target')).toBe('_blank')
60
+ })
61
+
62
+ it('sets rel noopener noreferrer when external', () => {
63
+ const { container } = render(Link, {
64
+ props: { href: 'https://example.com', external: true },
65
+ slots: { default: 'External' },
66
+ })
67
+ expect(container.firstElementChild?.getAttribute('rel')).toBe('noopener noreferrer')
68
+ })
69
+
70
+ // ── Custom className ──
71
+
72
+ it('merges custom className', () => {
73
+ const { container } = render(Link, {
74
+ props: { href: '/about', className: 'custom' },
75
+ slots: { default: 'About' },
76
+ })
77
+ const el = container.firstElementChild
78
+ expect(el?.className).toContain('strand-link')
79
+ expect(el?.className).toContain('custom')
80
+ })
81
+
82
+ // ── Props forwarding ──
83
+
84
+ it('forwards additional attributes', () => {
85
+ const { container } = render(Link, {
86
+ props: { href: '/about' },
87
+ attrs: { id: 'my-link' },
88
+ slots: { default: 'About' },
89
+ })
90
+ expect(container.firstElementChild?.getAttribute('id')).toBe('my-link')
91
+ })
92
+ })
@@ -0,0 +1,35 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** URL destination */
7
+ href: string
8
+ /** Opens in new tab with rel="noopener noreferrer" */
9
+ external?: boolean
10
+ /** Additional CSS class */
11
+ className?: string
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ external: false,
16
+ className: '',
17
+ })
18
+
19
+ const classes = computed(() =>
20
+ ['strand-link', props.className].filter(Boolean).join(' '),
21
+ )
22
+ </script>
23
+
24
+ <template>
25
+ <a
26
+ :href="href"
27
+ :class="classes"
28
+ v-bind="{
29
+ ...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
30
+ ...$attrs,
31
+ }"
32
+ >
33
+ <slot />
34
+ </a>
35
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Link } from './Link.vue'
@@ -0,0 +1,171 @@
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 Nav from './Nav.vue'
6
+
7
+ const sampleItems = [
8
+ { label: 'Home', href: '/', active: true },
9
+ { label: 'About', href: '/about' },
10
+ { label: 'Contact', href: '/contact' },
11
+ ]
12
+
13
+ describe('Nav', () => {
14
+ // -- Rendering --
15
+
16
+ it('renders a nav element', () => {
17
+ const { container } = render(Nav, {
18
+ props: { items: sampleItems },
19
+ })
20
+ expect(container.querySelector('nav')).toBeTruthy()
21
+ })
22
+
23
+ it('has aria-label Main navigation', () => {
24
+ const { getByRole } = render(Nav, {
25
+ props: { items: sampleItems },
26
+ })
27
+ expect(getByRole('navigation')).toHaveAttribute('aria-label', 'Main navigation')
28
+ })
29
+
30
+ it('renders logo content', () => {
31
+ const { getByText } = render(Nav, {
32
+ props: { items: sampleItems },
33
+ slots: { logo: '<span>MyLogo</span>' },
34
+ })
35
+ expect(getByText('MyLogo')).toBeTruthy()
36
+ })
37
+
38
+ it('renders navigation items', () => {
39
+ const { getByText } = render(Nav, {
40
+ props: { items: sampleItems },
41
+ })
42
+ expect(getByText('Home')).toBeTruthy()
43
+ expect(getByText('About')).toBeTruthy()
44
+ expect(getByText('Contact')).toBeTruthy()
45
+ })
46
+
47
+ it('renders item hrefs', () => {
48
+ const { getByText } = render(Nav, {
49
+ props: { items: sampleItems },
50
+ })
51
+ expect(getByText('Home').closest('a')).toHaveAttribute('href', '/')
52
+ expect(getByText('About').closest('a')).toHaveAttribute('href', '/about')
53
+ })
54
+
55
+ // -- Active item --
56
+
57
+ it('active item has active class', () => {
58
+ const { getByText } = render(Nav, {
59
+ props: { items: sampleItems },
60
+ })
61
+ const homeLink = getByText('Home').closest('a')!
62
+ expect(homeLink.className).toContain('strand-nav__link--active')
63
+ })
64
+
65
+ it('active item has aria-current page', () => {
66
+ const { getByText } = render(Nav, {
67
+ props: { items: sampleItems },
68
+ })
69
+ const homeLink = getByText('Home').closest('a')!
70
+ expect(homeLink).toHaveAttribute('aria-current', 'page')
71
+ })
72
+
73
+ it('inactive item does not have active class', () => {
74
+ const { getByText } = render(Nav, {
75
+ props: { items: sampleItems },
76
+ })
77
+ const aboutLink = getByText('About').closest('a')!
78
+ expect(aboutLink.className).not.toContain('strand-nav__link--active')
79
+ })
80
+
81
+ // -- Actions --
82
+
83
+ it('renders actions content', () => {
84
+ const { getByText } = render(Nav, {
85
+ props: { items: sampleItems },
86
+ slots: { actions: '<button type="button">Sign In</button>' },
87
+ })
88
+ expect(getByText('Sign In')).toBeTruthy()
89
+ })
90
+
91
+ // -- Hamburger --
92
+
93
+ it('hamburger button has aria-expanded false initially', () => {
94
+ const { container } = render(Nav, {
95
+ props: { items: sampleItems },
96
+ })
97
+ const hamburger = container.querySelector('.strand-nav__hamburger')!
98
+ expect(hamburger).toHaveAttribute('aria-expanded', 'false')
99
+ })
100
+
101
+ it('hamburger button has aria-label Menu', () => {
102
+ const { container } = render(Nav, {
103
+ props: { items: sampleItems },
104
+ })
105
+ const hamburger = container.querySelector('.strand-nav__hamburger')!
106
+ expect(hamburger).toHaveAttribute('aria-label', 'Menu')
107
+ })
108
+
109
+ it('clicking hamburger toggles menu and updates aria attributes', async () => {
110
+ const { container } = render(Nav, {
111
+ props: { items: sampleItems },
112
+ })
113
+ const hamburger = container.querySelector('.strand-nav__hamburger') as HTMLButtonElement
114
+
115
+ // Initially closed
116
+ expect(hamburger).toHaveAttribute('aria-expanded', 'false')
117
+ expect(hamburger).toHaveAttribute('aria-label', 'Menu')
118
+ expect(container.querySelector('.strand-nav__mobile-menu')).toBeNull()
119
+
120
+ // Open
121
+ await fireEvent.click(hamburger)
122
+ expect(hamburger).toHaveAttribute('aria-expanded', 'true')
123
+ expect(hamburger).toHaveAttribute('aria-label', 'Close menu')
124
+ expect(container.querySelector('.strand-nav__mobile-menu')).toBeTruthy()
125
+
126
+ // Close
127
+ await fireEvent.click(hamburger)
128
+ expect(hamburger).toHaveAttribute('aria-expanded', 'false')
129
+ expect(hamburger).toHaveAttribute('aria-label', 'Menu')
130
+ })
131
+
132
+ it('mobile menu renders items when open', async () => {
133
+ const { container } = render(Nav, {
134
+ props: { items: sampleItems },
135
+ })
136
+ const hamburger = container.querySelector('.strand-nav__hamburger') as HTMLButtonElement
137
+ await fireEvent.click(hamburger)
138
+
139
+ const mobileMenu = container.querySelector('.strand-nav__mobile-menu')!
140
+ expect(mobileMenu).toBeTruthy()
141
+ const mobileLinks = mobileMenu.querySelectorAll('.strand-nav__mobile-link')
142
+ expect(mobileLinks.length).toBe(3)
143
+ })
144
+
145
+ it('mobile active item has active class', async () => {
146
+ const { container } = render(Nav, {
147
+ props: { items: sampleItems },
148
+ })
149
+ const hamburger = container.querySelector('.strand-nav__hamburger') as HTMLButtonElement
150
+ await fireEvent.click(hamburger)
151
+
152
+ const mobileMenu = container.querySelector('.strand-nav__mobile-menu')!
153
+ const activeLink = mobileMenu.querySelector('.strand-nav__mobile-link--active')
154
+ expect(activeLink).toBeTruthy()
155
+ expect(activeLink!.textContent?.trim()).toBe('Home')
156
+ })
157
+
158
+ // -- Empty states --
159
+
160
+ it('renders without items', () => {
161
+ const { getByRole } = render(Nav)
162
+ expect(getByRole('navigation')).toBeTruthy()
163
+ })
164
+
165
+ it('renders without logo', () => {
166
+ const { container } = render(Nav, {
167
+ props: { items: sampleItems },
168
+ })
169
+ expect(container.querySelector('.strand-nav__logo')).toBeNull()
170
+ })
171
+ })
@@ -0,0 +1,81 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed, ref } from 'vue'
4
+
5
+ export interface NavItem {
6
+ label: string
7
+ href: string
8
+ active?: boolean
9
+ }
10
+
11
+ export interface NavProps {
12
+ /** Navigation items */
13
+ items?: NavItem[]
14
+ }
15
+
16
+ const props = withDefaults(defineProps<NavProps>(), {
17
+ items: () => [],
18
+ })
19
+
20
+ const menuOpen = ref(false)
21
+
22
+ function toggleMenu() {
23
+ menuOpen.value = !menuOpen.value
24
+ }
25
+
26
+ const classes = computed(() => ['strand-nav'].filter(Boolean).join(' '))
27
+ </script>
28
+
29
+ <template>
30
+ <nav :class="classes" aria-label="Main navigation">
31
+ <div class="strand-nav__inner">
32
+ <div v-if="$slots.logo" class="strand-nav__logo">
33
+ <slot name="logo" />
34
+ </div>
35
+
36
+ <div class="strand-nav__items">
37
+ <a
38
+ v-for="item in items"
39
+ :key="item.href"
40
+ :href="item.href"
41
+ :class="[
42
+ 'strand-nav__link',
43
+ item.active && 'strand-nav__link--active',
44
+ ].filter(Boolean).join(' ')"
45
+ :aria-current="item.active ? 'page' : undefined"
46
+ >
47
+ {{ item.label }}
48
+ </a>
49
+ </div>
50
+
51
+ <div v-if="$slots.actions" class="strand-nav__actions">
52
+ <slot name="actions" />
53
+ </div>
54
+
55
+ <button
56
+ type="button"
57
+ class="strand-nav__hamburger"
58
+ :aria-expanded="menuOpen ? 'true' : 'false'"
59
+ :aria-label="menuOpen ? 'Close menu' : 'Menu'"
60
+ @click="toggleMenu"
61
+ >
62
+ <span class="strand-nav__hamburger-icon" aria-hidden="true" />
63
+ </button>
64
+ </div>
65
+
66
+ <div v-if="menuOpen" class="strand-nav__mobile-menu">
67
+ <a
68
+ v-for="item in items"
69
+ :key="item.href"
70
+ :href="item.href"
71
+ :class="[
72
+ 'strand-nav__mobile-link',
73
+ item.active && 'strand-nav__mobile-link--active',
74
+ ].filter(Boolean).join(' ')"
75
+ :aria-current="item.active ? 'page' : undefined"
76
+ >
77
+ {{ item.label }}
78
+ </a>
79
+ </div>
80
+ </nav>
81
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Nav } from './Nav.vue'