@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,224 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect, afterEach } from 'vitest'
4
+ import { render, fireEvent } from '@testing-library/vue'
5
+ import Dialog from './Dialog.vue'
6
+
7
+ describe('Dialog', () => {
8
+ afterEach(() => {
9
+ document.body.style.overflow = ''
10
+ })
11
+
12
+ // -- Rendering --
13
+
14
+ it('renders nothing visible when closed', () => {
15
+ const { container } = render(Dialog, {
16
+ props: { open: false },
17
+ })
18
+ expect(container.querySelector('.strand-dialog__backdrop')).toBeNull()
19
+ expect(container.querySelector('[role="dialog"]')).toBeNull()
20
+ })
21
+
22
+ it('renders dialog when open', () => {
23
+ const { getByRole } = render(Dialog, {
24
+ props: { open: true },
25
+ slots: { default: 'Content' },
26
+ })
27
+ expect(getByRole('dialog')).toBeTruthy()
28
+ })
29
+
30
+ it('renders children inside the dialog', () => {
31
+ const { getByRole } = render(Dialog, {
32
+ props: { open: true },
33
+ slots: { default: '<p>Dialog content</p>' },
34
+ })
35
+ expect(getByRole('dialog')).toHaveTextContent('Dialog content')
36
+ })
37
+
38
+ // -- ARIA --
39
+
40
+ it('has role dialog', () => {
41
+ const { getByRole } = render(Dialog, {
42
+ props: { open: true },
43
+ slots: { default: 'Content' },
44
+ })
45
+ expect(getByRole('dialog')).toBeTruthy()
46
+ })
47
+
48
+ it('has aria-modal true', () => {
49
+ const { getByRole } = render(Dialog, {
50
+ props: { open: true },
51
+ slots: { default: 'Content' },
52
+ })
53
+ expect(getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
54
+ })
55
+
56
+ it('renders title with aria-labelledby linkage', () => {
57
+ const { getByRole, getByText } = render(Dialog, {
58
+ props: { open: true, title: 'My Dialog' },
59
+ slots: { default: 'Content' },
60
+ })
61
+ const dialog = getByRole('dialog')
62
+ const titleEl = getByText('My Dialog')
63
+ const titleId = titleEl.getAttribute('id')
64
+ expect(dialog).toHaveAttribute('aria-labelledby', titleId)
65
+ })
66
+
67
+ it('does not set aria-labelledby when no title', () => {
68
+ const { getByRole } = render(Dialog, {
69
+ props: { open: true },
70
+ slots: { default: 'Content' },
71
+ })
72
+ expect(getByRole('dialog').hasAttribute('aria-labelledby')).toBe(false)
73
+ })
74
+
75
+ // -- Title --
76
+
77
+ it('renders the title text', () => {
78
+ const { getByText } = render(Dialog, {
79
+ props: { open: true, title: 'Confirm Action' },
80
+ slots: { default: 'Content' },
81
+ })
82
+ expect(getByText('Confirm Action')).toBeTruthy()
83
+ })
84
+
85
+ // -- Close button --
86
+
87
+ it('close button emits close', async () => {
88
+ const { getByLabelText, emitted } = render(Dialog, {
89
+ props: { open: true },
90
+ slots: { default: 'Content' },
91
+ })
92
+ await fireEvent.click(getByLabelText('Close'))
93
+ expect(emitted().close).toHaveLength(1)
94
+ })
95
+
96
+ // -- Escape key --
97
+
98
+ it('Escape key emits close', async () => {
99
+ const { getByRole, emitted } = render(Dialog, {
100
+ props: { open: true },
101
+ slots: { default: 'Content' },
102
+ })
103
+ await fireEvent.keyDown(getByRole('dialog').parentElement!, {
104
+ key: 'Escape',
105
+ })
106
+ expect(emitted().close).toHaveLength(1)
107
+ })
108
+
109
+ it('Escape key does not emit close when closeOnEscape is false', async () => {
110
+ const { getByRole, emitted } = render(Dialog, {
111
+ props: { open: true, closeOnEscape: false },
112
+ slots: { default: 'Content' },
113
+ })
114
+ await fireEvent.keyDown(getByRole('dialog').parentElement!, {
115
+ key: 'Escape',
116
+ })
117
+ expect(emitted().close).toBeUndefined()
118
+ })
119
+
120
+ // -- Outside click --
121
+
122
+ it('clicking backdrop emits close', async () => {
123
+ const { container, emitted } = render(Dialog, {
124
+ props: { open: true },
125
+ slots: { default: 'Content' },
126
+ })
127
+ const backdrop = container.querySelector('.strand-dialog__backdrop')!
128
+ await fireEvent.click(backdrop)
129
+ expect(emitted().close).toHaveLength(1)
130
+ })
131
+
132
+ it('clicking inside dialog does not emit close', async () => {
133
+ const { getByRole, emitted } = render(Dialog, {
134
+ props: { open: true },
135
+ slots: { default: 'Content' },
136
+ })
137
+ await fireEvent.click(getByRole('dialog'))
138
+ expect(emitted().close).toBeUndefined()
139
+ })
140
+
141
+ it('backdrop click disabled when closeOnOutsideClick is false', async () => {
142
+ const { container, emitted } = render(Dialog, {
143
+ props: { open: true, closeOnOutsideClick: false },
144
+ slots: { default: 'Content' },
145
+ })
146
+ const backdrop = container.querySelector('.strand-dialog__backdrop')!
147
+ await fireEvent.click(backdrop)
148
+ expect(emitted().close).toBeUndefined()
149
+ })
150
+
151
+ // -- Scroll lock --
152
+
153
+ it('sets body overflow hidden when open', () => {
154
+ render(Dialog, {
155
+ props: { open: true },
156
+ slots: { default: 'Content' },
157
+ })
158
+ expect(document.body.style.overflow).toBe('hidden')
159
+ })
160
+
161
+ it('restores body overflow when closed', async () => {
162
+ const { rerender } = render(Dialog, {
163
+ props: { open: true },
164
+ slots: { default: 'Content' },
165
+ })
166
+ expect(document.body.style.overflow).toBe('hidden')
167
+ await rerender({ open: false })
168
+ expect(document.body.style.overflow).toBe('')
169
+ })
170
+
171
+ // -- Focus trap --
172
+
173
+ it('traps focus with Tab cycling within the panel', async () => {
174
+ const { container } = render(Dialog, {
175
+ props: { open: true },
176
+ slots: {
177
+ default: '<button id="first">First</button><button id="last">Last</button>',
178
+ },
179
+ })
180
+ const backdrop = container.querySelector('.strand-dialog__backdrop')!
181
+ const lastBtn = container.querySelector('#last') as HTMLElement
182
+ const closeBtn = container.querySelector('.strand-dialog__close') as HTMLElement
183
+
184
+ // Focus the last button
185
+ lastBtn.focus()
186
+ expect(document.activeElement).toBe(lastBtn)
187
+
188
+ // Tab from last should wrap to first focusable (close button)
189
+ await fireEvent.keyDown(backdrop, { key: 'Tab' })
190
+ expect(document.activeElement).toBe(closeBtn)
191
+ })
192
+
193
+ it('traps focus with Shift+Tab cycling within the panel', async () => {
194
+ const { container, getByRole } = render(Dialog, {
195
+ props: { open: true },
196
+ slots: {
197
+ default: '<button id="first">First</button><button id="last">Last</button>',
198
+ },
199
+ })
200
+ const panel = getByRole('dialog')
201
+ const focusable = Array.from(
202
+ panel.querySelectorAll<HTMLElement>(
203
+ 'a[href], button:not(:disabled), textarea:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])',
204
+ ),
205
+ )
206
+ const first = focusable[0]
207
+ const last = focusable[focusable.length - 1]
208
+
209
+ // Focus the first focusable element in the panel
210
+ first.focus()
211
+ expect(document.activeElement).toBe(first)
212
+
213
+ // Simulate Shift+Tab by creating and dispatching a KeyboardEvent on the panel
214
+ // which will bubble to the backdrop's keydown handler
215
+ const event = new KeyboardEvent('keydown', {
216
+ key: 'Tab',
217
+ shiftKey: true,
218
+ bubbles: true,
219
+ cancelable: true,
220
+ })
221
+ first.dispatchEvent(event)
222
+ expect(document.activeElement).toBe(last)
223
+ })
224
+ })
@@ -0,0 +1,146 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed, ref, watch, onUnmounted, nextTick } from 'vue'
4
+
5
+ export interface DialogProps {
6
+ /** Whether the dialog is open */
7
+ open: boolean
8
+ /** Optional title rendered in the dialog header */
9
+ title?: string
10
+ /** Close when clicking the backdrop */
11
+ closeOnOutsideClick?: boolean
12
+ /** Close when pressing Escape */
13
+ closeOnEscape?: boolean
14
+ }
15
+
16
+ const props = withDefaults(defineProps<DialogProps>(), {
17
+ closeOnOutsideClick: true,
18
+ closeOnEscape: true,
19
+ })
20
+
21
+ const emit = defineEmits<{
22
+ (e: 'close'): void
23
+ }>()
24
+
25
+ const FOCUSABLE_SELECTOR =
26
+ 'a[href], button:not(:disabled), textarea:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])'
27
+
28
+ let dialogIdCounter = 0
29
+ const titleId = `strand-dialog-title-${++dialogIdCounter}`
30
+
31
+ const panelRef = ref<HTMLDivElement | null>(null)
32
+ let previousFocus: Element | null = null
33
+ let originalOverflow = ''
34
+
35
+ const panelClasses = computed(() =>
36
+ ['strand-dialog__panel'].filter(Boolean).join(' '),
37
+ )
38
+
39
+ function handleKeyDown(event: KeyboardEvent) {
40
+ if (event.key === 'Escape' && props.closeOnEscape) {
41
+ event.stopPropagation()
42
+ emit('close')
43
+ return
44
+ }
45
+
46
+ if (event.key === 'Tab') {
47
+ const panel = panelRef.value
48
+ if (!panel) return
49
+
50
+ const focusable = Array.from(
51
+ panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
52
+ )
53
+ if (focusable.length === 0) return
54
+
55
+ const first = focusable[0]
56
+ const last = focusable[focusable.length - 1]
57
+
58
+ if (event.shiftKey) {
59
+ if (document.activeElement === first) {
60
+ event.preventDefault()
61
+ last.focus()
62
+ }
63
+ } else {
64
+ if (document.activeElement === last) {
65
+ event.preventDefault()
66
+ first.focus()
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ function handleBackdropClick(event: MouseEvent) {
73
+ if (props.closeOnOutsideClick && event.target === event.currentTarget) {
74
+ emit('close')
75
+ }
76
+ }
77
+
78
+ watch(
79
+ () => props.open,
80
+ async (isOpen) => {
81
+ if (isOpen) {
82
+ previousFocus = document.activeElement
83
+ originalOverflow = document.body.style.overflow
84
+ document.body.style.overflow = 'hidden'
85
+
86
+ await nextTick()
87
+ const panel = panelRef.value
88
+ if (panel) {
89
+ const focusable = panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
90
+ if (focusable.length > 0) {
91
+ focusable[0].focus()
92
+ } else {
93
+ panel.focus()
94
+ }
95
+ }
96
+ } else {
97
+ document.body.style.overflow = originalOverflow
98
+ if (previousFocus && previousFocus instanceof HTMLElement) {
99
+ previousFocus.focus()
100
+ }
101
+ }
102
+ },
103
+ { immediate: true },
104
+ )
105
+
106
+ onUnmounted(() => {
107
+ if (props.open) {
108
+ document.body.style.overflow = originalOverflow
109
+ }
110
+ })
111
+ </script>
112
+
113
+ <template>
114
+ <div
115
+ v-if="open"
116
+ class="strand-dialog__backdrop"
117
+ @click="handleBackdropClick"
118
+ @keydown="handleKeyDown"
119
+ >
120
+ <div
121
+ ref="panelRef"
122
+ :class="panelClasses"
123
+ role="dialog"
124
+ aria-modal="true"
125
+ :aria-labelledby="title ? titleId : undefined"
126
+ :tabindex="-1"
127
+ >
128
+ <div v-if="title" class="strand-dialog__header">
129
+ <h2 :id="titleId" class="strand-dialog__title">
130
+ {{ title }}
131
+ </h2>
132
+ </div>
133
+ <button
134
+ type="button"
135
+ class="strand-dialog__close"
136
+ aria-label="Close"
137
+ @click="emit('close')"
138
+ >
139
+ &#215;
140
+ </button>
141
+ <div class="strand-dialog__body">
142
+ <slot />
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Dialog } from './Dialog.vue'
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/vue'
3
+ import Divider from './Divider.vue'
4
+
5
+ describe('Divider', () => {
6
+ // ── Horizontal (default) ──
7
+
8
+ it('renders an hr element by default', () => {
9
+ const { container } = render(Divider)
10
+ expect(container.firstElementChild?.tagName).toBe('HR')
11
+ })
12
+
13
+ it('applies horizontal class by default', () => {
14
+ const { container } = render(Divider)
15
+ expect(container.firstElementChild?.className).toContain('strand-divider--horizontal')
16
+ })
17
+
18
+ it('has separator role', () => {
19
+ const { container } = render(Divider)
20
+ expect(container.firstElementChild?.getAttribute('role')).toBe('separator')
21
+ })
22
+
23
+ it('has horizontal aria-orientation by default', () => {
24
+ const { container } = render(Divider)
25
+ expect(container.firstElementChild?.getAttribute('aria-orientation')).toBe('horizontal')
26
+ })
27
+
28
+ // ── Vertical ──
29
+
30
+ it('renders a div for vertical direction', () => {
31
+ const { container } = render(Divider, { props: { direction: 'vertical' } })
32
+ expect(container.firstElementChild?.tagName).toBe('DIV')
33
+ })
34
+
35
+ it('applies vertical class', () => {
36
+ const { container } = render(Divider, { props: { direction: 'vertical' } })
37
+ expect(container.firstElementChild?.className).toContain('strand-divider--vertical')
38
+ })
39
+
40
+ it('has vertical aria-orientation', () => {
41
+ const { container } = render(Divider, { props: { direction: 'vertical' } })
42
+ expect(container.firstElementChild?.getAttribute('aria-orientation')).toBe('vertical')
43
+ })
44
+
45
+ // ── Labeled ──
46
+
47
+ it('renders a div with label spans for labeled variant', () => {
48
+ const { container } = render(Divider, { props: { label: 'OR' } })
49
+ expect(container.firstElementChild?.tagName).toBe('DIV')
50
+ })
51
+
52
+ it('applies labeled class when label is provided', () => {
53
+ const { container } = render(Divider, { props: { label: 'OR' } })
54
+ expect(container.firstElementChild?.className).toContain('strand-divider--labeled')
55
+ })
56
+
57
+ it('renders label text', () => {
58
+ const { container } = render(Divider, { props: { label: 'OR' } })
59
+ const label = container.querySelector('.strand-divider__label')
60
+ expect(label?.textContent).toBe('OR')
61
+ })
62
+
63
+ it('renders two line spans for labeled variant', () => {
64
+ const { container } = render(Divider, { props: { label: 'OR' } })
65
+ const lines = container.querySelectorAll('.strand-divider__line')
66
+ expect(lines.length).toBe(2)
67
+ })
68
+
69
+ // ── Custom className ──
70
+
71
+ it('merges custom className on horizontal', () => {
72
+ const { container } = render(Divider, { props: { className: 'custom' } })
73
+ const el = container.firstElementChild
74
+ expect(el?.className).toContain('strand-divider')
75
+ expect(el?.className).toContain('custom')
76
+ })
77
+
78
+ it('merges custom className on vertical', () => {
79
+ const { container } = render(Divider, {
80
+ props: { direction: 'vertical', className: 'custom' },
81
+ })
82
+ const el = container.firstElementChild
83
+ expect(el?.className).toContain('strand-divider')
84
+ expect(el?.className).toContain('custom')
85
+ })
86
+
87
+ it('merges custom className on labeled', () => {
88
+ const { container } = render(Divider, {
89
+ props: { label: 'OR', className: 'custom' },
90
+ })
91
+ const el = container.firstElementChild
92
+ expect(el?.className).toContain('strand-divider')
93
+ expect(el?.className).toContain('custom')
94
+ })
95
+ })
@@ -0,0 +1,63 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** Separator direction */
7
+ direction?: 'horizontal' | 'vertical'
8
+ /** Optional label text displayed in the middle of the line */
9
+ label?: string
10
+ /** Additional CSS class */
11
+ className?: string
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ direction: 'horizontal',
16
+ className: '',
17
+ })
18
+
19
+ const isVertical = computed(() => props.direction === 'vertical')
20
+ const isLabeled = computed(() => !isVertical.value && !!props.label)
21
+ const isPlainHorizontal = computed(() => !isVertical.value && !props.label)
22
+
23
+ const classes = computed(() => {
24
+ if (isVertical.value) {
25
+ return ['strand-divider', 'strand-divider--vertical', props.className]
26
+ .filter(Boolean)
27
+ .join(' ')
28
+ }
29
+ if (isLabeled.value) {
30
+ return ['strand-divider', 'strand-divider--horizontal', 'strand-divider--labeled', props.className]
31
+ .filter(Boolean)
32
+ .join(' ')
33
+ }
34
+ return ['strand-divider', 'strand-divider--horizontal', props.className]
35
+ .filter(Boolean)
36
+ .join(' ')
37
+ })
38
+ </script>
39
+
40
+ <template>
41
+ <div
42
+ v-if="isVertical"
43
+ :class="classes"
44
+ role="separator"
45
+ aria-orientation="vertical"
46
+ />
47
+ <div
48
+ v-else-if="isLabeled"
49
+ :class="classes"
50
+ role="separator"
51
+ aria-orientation="horizontal"
52
+ >
53
+ <span class="strand-divider__line" />
54
+ <span class="strand-divider__label">{{ label }}</span>
55
+ <span class="strand-divider__line" />
56
+ </div>
57
+ <hr
58
+ v-else
59
+ :class="classes"
60
+ role="separator"
61
+ aria-orientation="horizontal"
62
+ />
63
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Divider } from './Divider.vue'
@@ -0,0 +1,98 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render } from '@testing-library/vue'
5
+ import FormField from './FormField.vue'
6
+
7
+ describe('FormField', () => {
8
+ it('renders with label and slot content', () => {
9
+ const { container, getByText } = render(FormField, {
10
+ props: { label: 'Email', htmlFor: 'email' },
11
+ slots: { default: '<input id="email" />' },
12
+ })
13
+ const wrapper = container.querySelector('.strand-form-field')
14
+ expect(wrapper).toBeInTheDocument()
15
+ expect(getByText('Email')).toBeInTheDocument()
16
+ const label = container.querySelector('.strand-form-field__label')
17
+ expect(label).toHaveAttribute('for', 'email')
18
+ const control = container.querySelector('.strand-form-field__control')
19
+ expect(control).toBeInTheDocument()
20
+ expect(control?.querySelector('input')).toBeInTheDocument()
21
+ })
22
+
23
+ it('shows required indicator', () => {
24
+ const { container } = render(FormField, {
25
+ props: { label: 'Name', htmlFor: 'name', required: true },
26
+ slots: { default: '<input id="name" />' },
27
+ })
28
+ const required = container.querySelector('.strand-form-field__required')
29
+ expect(required).toBeInTheDocument()
30
+ expect(required).toHaveTextContent('*')
31
+ expect(required).toHaveAttribute('aria-hidden', 'true')
32
+ })
33
+
34
+ it('does not show required indicator by default', () => {
35
+ const { container } = render(FormField, {
36
+ props: { label: 'Name', htmlFor: 'name' },
37
+ slots: { default: '<input id="name" />' },
38
+ })
39
+ expect(container.querySelector('.strand-form-field__required')).not.toBeInTheDocument()
40
+ })
41
+
42
+ it('shows hint text with correct id', () => {
43
+ const { container, getByText } = render(FormField, {
44
+ props: { label: 'Email', htmlFor: 'email', hint: 'We will not share this' },
45
+ slots: { default: '<input id="email" />' },
46
+ })
47
+ const hint = container.querySelector('.strand-form-field__hint')
48
+ expect(hint).toBeInTheDocument()
49
+ expect(hint).toHaveAttribute('id', 'email-hint')
50
+ expect(getByText('We will not share this')).toBeInTheDocument()
51
+ })
52
+
53
+ it('shows error text with correct id and role', () => {
54
+ const { container, getByText } = render(FormField, {
55
+ props: { label: 'Email', htmlFor: 'email', error: 'Required field' },
56
+ slots: { default: '<input id="email" />' },
57
+ })
58
+ const error = container.querySelector('.strand-form-field__error')
59
+ expect(error).toBeInTheDocument()
60
+ expect(error).toHaveAttribute('id', 'email-error')
61
+ expect(error).toHaveAttribute('role', 'alert')
62
+ expect(getByText('Required field')).toBeInTheDocument()
63
+ })
64
+
65
+ it('applies error class when error is present', () => {
66
+ const { container } = render(FormField, {
67
+ props: { label: 'Email', htmlFor: 'email', error: 'Invalid' },
68
+ slots: { default: '<input id="email" />' },
69
+ })
70
+ expect(container.querySelector('.strand-form-field')).toHaveClass('strand-form-field--error')
71
+ })
72
+
73
+ it('error replaces hint when both provided', () => {
74
+ const { container } = render(FormField, {
75
+ props: { label: 'Email', htmlFor: 'email', hint: 'Hint text', error: 'Error text' },
76
+ slots: { default: '<input id="email" />' },
77
+ })
78
+ expect(container.querySelector('.strand-form-field__error')).toBeInTheDocument()
79
+ expect(container.querySelector('.strand-form-field__hint')).not.toBeInTheDocument()
80
+ })
81
+
82
+ it('does not show hint or error when neither provided', () => {
83
+ const { container } = render(FormField, {
84
+ props: { label: 'Email', htmlFor: 'email' },
85
+ slots: { default: '<input id="email" />' },
86
+ })
87
+ expect(container.querySelector('.strand-form-field__error')).not.toBeInTheDocument()
88
+ expect(container.querySelector('.strand-form-field__hint')).not.toBeInTheDocument()
89
+ })
90
+
91
+ it('does not apply error class without error', () => {
92
+ const { container } = render(FormField, {
93
+ props: { label: 'Email', htmlFor: 'email' },
94
+ slots: { default: '<input id="email" />' },
95
+ })
96
+ expect(container.querySelector('.strand-form-field')).not.toHaveClass('strand-form-field--error')
97
+ })
98
+ })
@@ -0,0 +1,59 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ export interface FormFieldProps {
6
+ /** Label text */
7
+ label: string
8
+ /** Associates the label with a form control */
9
+ htmlFor: string
10
+ /** Hint text displayed below the input */
11
+ hint?: string
12
+ /** Error text displayed below the input (replaces hint) */
13
+ error?: string
14
+ /** Show required indicator */
15
+ required?: boolean
16
+ }
17
+
18
+ const props = withDefaults(defineProps<FormFieldProps>(), {
19
+ required: false,
20
+ })
21
+
22
+ const classes = computed(() =>
23
+ [
24
+ 'strand-form-field',
25
+ props.error && 'strand-form-field--error',
26
+ ]
27
+ .filter(Boolean)
28
+ .join(' '),
29
+ )
30
+ </script>
31
+
32
+ <template>
33
+ <div :class="classes">
34
+ <label class="strand-form-field__label" :for="htmlFor">
35
+ {{ label }}
36
+ <span v-if="required" class="strand-form-field__required" aria-hidden="true">
37
+ *
38
+ </span>
39
+ </label>
40
+ <div class="strand-form-field__control">
41
+ <slot />
42
+ </div>
43
+ <p
44
+ v-if="error"
45
+ class="strand-form-field__error"
46
+ :id="`${htmlFor}-error`"
47
+ role="alert"
48
+ >
49
+ {{ error }}
50
+ </p>
51
+ <p
52
+ v-else-if="hint"
53
+ class="strand-form-field__hint"
54
+ :id="`${htmlFor}-hint`"
55
+ >
56
+ {{ hint }}
57
+ </p>
58
+ </div>
59
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as FormField } from './FormField.vue'