@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,65 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ interface Props {
6
+ /** Visual style variant */
7
+ variant?: 'solid' | 'outlined'
8
+ /** Color status */
9
+ status?: 'default' | 'teal' | 'blue' | 'amber' | 'red'
10
+ /** Show remove button */
11
+ removable?: boolean
12
+ /** Additional CSS class */
13
+ className?: string
14
+ }
15
+
16
+ const props = withDefaults(defineProps<Props>(), {
17
+ variant: 'solid',
18
+ status: 'default',
19
+ removable: false,
20
+ className: '',
21
+ })
22
+
23
+ const emit = defineEmits<{
24
+ remove: []
25
+ }>()
26
+
27
+ const classes = computed(() =>
28
+ [
29
+ 'strand-tag',
30
+ `strand-tag--${props.variant}`,
31
+ `strand-tag--${props.status}`,
32
+ props.className,
33
+ ]
34
+ .filter(Boolean)
35
+ .join(' '),
36
+ )
37
+ </script>
38
+
39
+ <template>
40
+ <span :class="classes" v-bind="$attrs">
41
+ <span class="strand-tag__text"><slot /></span>
42
+ <button
43
+ v-if="removable"
44
+ type="button"
45
+ class="strand-tag__remove"
46
+ aria-label="Remove"
47
+ @click="emit('remove')"
48
+ >
49
+ <svg
50
+ width="12"
51
+ height="12"
52
+ viewBox="0 0 12 12"
53
+ fill="none"
54
+ aria-hidden="true"
55
+ >
56
+ <path
57
+ d="M3 3l6 6M9 3l-6 6"
58
+ stroke="currentColor"
59
+ stroke-width="1.5"
60
+ stroke-linecap="round"
61
+ />
62
+ </svg>
63
+ </button>
64
+ </span>
65
+ </template>
@@ -0,0 +1 @@
1
+ export { default as Tag } from './Tag.vue'
@@ -0,0 +1,107 @@
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 Textarea from './Textarea.vue'
6
+
7
+ describe('Textarea', () => {
8
+ it('renders with default props', () => {
9
+ const { container } = render(Textarea)
10
+ const wrapper = container.querySelector('.strand-textarea')
11
+ expect(wrapper).toBeInTheDocument()
12
+ const textarea = container.querySelector('.strand-textarea__field') as HTMLTextAreaElement
13
+ expect(textarea).toBeInTheDocument()
14
+ expect(textarea).not.toBeDisabled()
15
+ })
16
+
17
+ it('applies error class and aria-invalid', () => {
18
+ const { container } = render(Textarea, {
19
+ props: { error: true },
20
+ })
21
+ const wrapper = container.querySelector('.strand-textarea')
22
+ expect(wrapper).toHaveClass('strand-textarea--error')
23
+ const textarea = container.querySelector('.strand-textarea__field')
24
+ expect(textarea).toHaveAttribute('aria-invalid', 'true')
25
+ })
26
+
27
+ it('does not set aria-invalid when no error', () => {
28
+ const { container } = render(Textarea)
29
+ const textarea = container.querySelector('.strand-textarea__field')
30
+ expect(textarea).not.toHaveAttribute('aria-invalid')
31
+ })
32
+
33
+ it('applies disabled class and attribute', () => {
34
+ const { container } = render(Textarea, {
35
+ props: { disabled: true },
36
+ })
37
+ const wrapper = container.querySelector('.strand-textarea')
38
+ expect(wrapper).toHaveClass('strand-textarea--disabled')
39
+ const textarea = container.querySelector('.strand-textarea__field')
40
+ expect(textarea).toBeDisabled()
41
+ })
42
+
43
+ it('applies auto-resize class', () => {
44
+ const { container } = render(Textarea, {
45
+ props: { autoResize: true },
46
+ })
47
+ expect(container.querySelector('.strand-textarea')).toHaveClass('strand-textarea--auto-resize')
48
+ })
49
+
50
+ it('emits update:modelValue on input', async () => {
51
+ const { container, emitted } = render(Textarea, {
52
+ props: { modelValue: '' },
53
+ })
54
+ const textarea = container.querySelector('.strand-textarea__field') as HTMLTextAreaElement
55
+ await fireEvent.update(textarea, 'hello world')
56
+ expect(emitted()['update:modelValue']).toBeTruthy()
57
+ expect(emitted()['update:modelValue'][0]).toEqual(['hello world'])
58
+ })
59
+
60
+ it('shows character count when showCount and maxLength are set', () => {
61
+ const { container } = render(Textarea, {
62
+ props: { showCount: true, maxLength: 100, modelValue: 'hello' },
63
+ })
64
+ const count = container.querySelector('.strand-textarea__count')
65
+ expect(count).toBeInTheDocument()
66
+ expect(count).toHaveAttribute('aria-live', 'polite')
67
+ expect(count).toHaveTextContent('5/100')
68
+ })
69
+
70
+ it('does not show count without maxLength', () => {
71
+ const { container } = render(Textarea, {
72
+ props: { showCount: true },
73
+ })
74
+ expect(container.querySelector('.strand-textarea__count')).not.toBeInTheDocument()
75
+ })
76
+
77
+ it('does not show count without showCount', () => {
78
+ const { container } = render(Textarea, {
79
+ props: { maxLength: 100 },
80
+ })
81
+ expect(container.querySelector('.strand-textarea__count')).not.toBeInTheDocument()
82
+ })
83
+
84
+ it('shows 0 count for empty value', () => {
85
+ const { container } = render(Textarea, {
86
+ props: { showCount: true, maxLength: 50, modelValue: '' },
87
+ })
88
+ const count = container.querySelector('.strand-textarea__count')
89
+ expect(count).toHaveTextContent('0/50')
90
+ })
91
+
92
+ it('sets maxlength attribute on textarea', () => {
93
+ const { container } = render(Textarea, {
94
+ props: { maxLength: 200 },
95
+ })
96
+ const textarea = container.querySelector('.strand-textarea__field')
97
+ expect(textarea).toHaveAttribute('maxlength', '200')
98
+ })
99
+
100
+ it('passes modelValue to textarea value', () => {
101
+ const { container } = render(Textarea, {
102
+ props: { modelValue: 'test content' },
103
+ })
104
+ const textarea = container.querySelector('.strand-textarea__field') as HTMLTextAreaElement
105
+ expect(textarea.value).toBe('test content')
106
+ })
107
+ })
@@ -0,0 +1,90 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed, ref, onMounted, watch } from 'vue'
4
+
5
+ export interface TextareaProps {
6
+ /** Auto-resize to fit content */
7
+ autoResize?: boolean
8
+ /** Show character count (requires maxLength) */
9
+ showCount?: boolean
10
+ /** Show error styling */
11
+ error?: boolean
12
+ /** Maximum character count */
13
+ maxLength?: number
14
+ /** Disabled state */
15
+ disabled?: boolean
16
+ /** Controlled value */
17
+ modelValue?: string
18
+ }
19
+
20
+ const props = withDefaults(defineProps<TextareaProps>(), {
21
+ autoResize: false,
22
+ showCount: false,
23
+ error: false,
24
+ disabled: false,
25
+ modelValue: '',
26
+ })
27
+
28
+ const emit = defineEmits<{
29
+ (e: 'update:modelValue', value: string): void
30
+ }>()
31
+
32
+ const textareaRef = ref<HTMLTextAreaElement | null>(null)
33
+
34
+ const wrapperClasses = computed(() =>
35
+ [
36
+ 'strand-textarea',
37
+ props.error && 'strand-textarea--error',
38
+ props.disabled && 'strand-textarea--disabled',
39
+ props.autoResize && 'strand-textarea--auto-resize',
40
+ ]
41
+ .filter(Boolean)
42
+ .join(' '),
43
+ )
44
+
45
+ const currentLength = computed(() =>
46
+ typeof props.modelValue === 'string' ? props.modelValue.length : 0,
47
+ )
48
+
49
+ function resize() {
50
+ if (props.autoResize && textareaRef.value) {
51
+ textareaRef.value.style.height = 'auto'
52
+ textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`
53
+ }
54
+ }
55
+
56
+ function handleInput(event: Event) {
57
+ const target = event.target as HTMLTextAreaElement
58
+ emit('update:modelValue', target.value)
59
+ resize()
60
+ }
61
+
62
+ watch(() => props.modelValue, () => {
63
+ resize()
64
+ })
65
+
66
+ onMounted(() => {
67
+ resize()
68
+ })
69
+ </script>
70
+
71
+ <template>
72
+ <div :class="wrapperClasses">
73
+ <textarea
74
+ ref="textareaRef"
75
+ class="strand-textarea__field"
76
+ :disabled="disabled"
77
+ :aria-invalid="error ? 'true' : undefined"
78
+ :maxlength="maxLength"
79
+ :value="modelValue"
80
+ @input="handleInput"
81
+ />
82
+ <span
83
+ v-if="showCount && maxLength != null"
84
+ class="strand-textarea__count"
85
+ aria-live="polite"
86
+ >
87
+ {{ currentLength }}/{{ maxLength }}
88
+ </span>
89
+ </div>
90
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Textarea } from './Textarea.vue'
@@ -0,0 +1,204 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
4
+ import { render, fireEvent } from '@testing-library/vue'
5
+ import { defineComponent, h } from 'vue'
6
+ import Toast from './Toast.vue'
7
+ import ToastProvider from './ToastProvider.vue'
8
+ import { useToast } from './useToast'
9
+ import type { ToastStatus } from './useToast'
10
+
11
+ /** Helper component that triggers a toast via the composable */
12
+ const TestTrigger = defineComponent({
13
+ props: {
14
+ message: { type: String, default: 'Test message' },
15
+ status: { type: String as () => ToastStatus | undefined, default: undefined },
16
+ duration: { type: Number, default: undefined },
17
+ },
18
+ setup(props) {
19
+ const { toast } = useToast()
20
+ return { toast, props }
21
+ },
22
+ render() {
23
+ return h(
24
+ 'button',
25
+ {
26
+ type: 'button',
27
+ onClick: () =>
28
+ this.toast({
29
+ message: this.props.message,
30
+ status: this.props.status,
31
+ duration: this.props.duration,
32
+ }),
33
+ },
34
+ 'Trigger',
35
+ )
36
+ },
37
+ })
38
+
39
+ describe('Toast', () => {
40
+ // -- Standalone Toast component --
41
+
42
+ it('renders message text', () => {
43
+ const { getByText } = render(Toast, {
44
+ props: { message: 'Hello' },
45
+ })
46
+ expect(getByText('Hello')).toBeTruthy()
47
+ })
48
+
49
+ it('applies status class', () => {
50
+ const { container } = render(Toast, {
51
+ props: { message: 'OK', status: 'success' },
52
+ })
53
+ expect(container.querySelector('.strand-toast--success')).toBeTruthy()
54
+ })
55
+
56
+ it('has role status', () => {
57
+ const { getByRole } = render(Toast, {
58
+ props: { message: 'Info' },
59
+ })
60
+ expect(getByRole('status')).toBeTruthy()
61
+ })
62
+
63
+ it('error toast has aria-live assertive', () => {
64
+ const { getByRole } = render(Toast, {
65
+ props: { message: 'Fail', status: 'error' },
66
+ })
67
+ expect(getByRole('status')).toHaveAttribute('aria-live', 'assertive')
68
+ })
69
+
70
+ it('info toast has aria-live polite', () => {
71
+ const { getByRole } = render(Toast, {
72
+ props: { message: 'Note', status: 'info' },
73
+ })
74
+ expect(getByRole('status')).toHaveAttribute('aria-live', 'polite')
75
+ })
76
+
77
+ it('warning toast has aria-live assertive', () => {
78
+ const { getByRole } = render(Toast, {
79
+ props: { message: 'Warn', status: 'warning' },
80
+ })
81
+ expect(getByRole('status')).toHaveAttribute('aria-live', 'assertive')
82
+ })
83
+
84
+ it('emits dismiss when dismiss button is clicked', async () => {
85
+ const { getByLabelText, emitted } = render(Toast, {
86
+ props: { message: 'Bye' },
87
+ })
88
+ await fireEvent.click(getByLabelText('Dismiss'))
89
+ expect(emitted().dismiss).toHaveLength(1)
90
+ })
91
+
92
+ it('defaults to info status', () => {
93
+ const { container } = render(Toast, {
94
+ props: { message: 'Default' },
95
+ })
96
+ expect(container.querySelector('.strand-toast--info')).toBeTruthy()
97
+ })
98
+ })
99
+
100
+ describe('ToastProvider + useToast', () => {
101
+ beforeEach(() => {
102
+ vi.useFakeTimers()
103
+ })
104
+
105
+ afterEach(() => {
106
+ vi.useRealTimers()
107
+ })
108
+
109
+ function renderWithProvider(triggerProps: Record<string, unknown> = {}) {
110
+ return render(ToastProvider, {
111
+ slots: {
112
+ default: () =>
113
+ h(TestTrigger, {
114
+ message: 'Test message',
115
+ ...triggerProps,
116
+ }),
117
+ },
118
+ })
119
+ }
120
+
121
+ it('renders children', () => {
122
+ const { getByText } = render(ToastProvider, {
123
+ slots: { default: '<p>App content</p>' },
124
+ })
125
+ expect(getByText('App content')).toBeTruthy()
126
+ })
127
+
128
+ it('useToast adds a toast that renders message', async () => {
129
+ const { getByText } = renderWithProvider({ message: 'Hello toast' })
130
+ await fireEvent.click(getByText('Trigger'))
131
+ expect(getByText('Hello toast')).toBeTruthy()
132
+ })
133
+
134
+ it('toast has correct status class', async () => {
135
+ const { getByText, container } = renderWithProvider({
136
+ message: 'Error occurred',
137
+ status: 'error',
138
+ })
139
+ await fireEvent.click(getByText('Trigger'))
140
+ expect(container.querySelector('.strand-toast--error')).toBeTruthy()
141
+ })
142
+
143
+ it('toast has role status', async () => {
144
+ const { getByText, getAllByRole } = renderWithProvider({
145
+ message: 'Status toast',
146
+ })
147
+ await fireEvent.click(getByText('Trigger'))
148
+ const statuses = getAllByRole('status')
149
+ expect(statuses.length).toBeGreaterThan(0)
150
+ })
151
+
152
+ it('error toast in provider has aria-live assertive', async () => {
153
+ const { getByText, container } = renderWithProvider({
154
+ message: 'Err',
155
+ status: 'error',
156
+ })
157
+ await fireEvent.click(getByText('Trigger'))
158
+ const toast = container.querySelector('.strand-toast--error')!
159
+ expect(toast.getAttribute('aria-live')).toBe('assertive')
160
+ })
161
+
162
+ it('toast auto-dismisses after duration', async () => {
163
+ const { getByText, queryByText } = renderWithProvider({
164
+ message: 'Vanishing',
165
+ duration: 3000,
166
+ })
167
+ await fireEvent.click(getByText('Trigger'))
168
+ expect(getByText('Vanishing')).toBeTruthy()
169
+
170
+ vi.advanceTimersByTime(3000)
171
+ await vi.dynamicImportSettled()
172
+
173
+ expect(queryByText('Vanishing')).toBeNull()
174
+ })
175
+
176
+ it('dismiss button removes toast', async () => {
177
+ const { getByText, getByLabelText, queryByText } = renderWithProvider({
178
+ message: 'Dismissable',
179
+ })
180
+ await fireEvent.click(getByText('Trigger'))
181
+ expect(getByText('Dismissable')).toBeTruthy()
182
+
183
+ await fireEvent.click(getByLabelText('Dismiss'))
184
+ expect(queryByText('Dismissable')).toBeNull()
185
+ })
186
+
187
+ it('defaults to info status when none provided', async () => {
188
+ const { getByText, container } = renderWithProvider({
189
+ message: 'Default info',
190
+ })
191
+ await fireEvent.click(getByText('Trigger'))
192
+ expect(container.querySelector('.strand-toast--info')).toBeTruthy()
193
+ })
194
+
195
+ it('multiple toasts stack', async () => {
196
+ const { getByText, container } = renderWithProvider({
197
+ message: 'First',
198
+ })
199
+ await fireEvent.click(getByText('Trigger'))
200
+ await fireEvent.click(getByText('Trigger'))
201
+ const toasts = container.querySelectorAll('.strand-toast')
202
+ expect(toasts.length).toBe(2)
203
+ })
204
+ })
@@ -0,0 +1,48 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+ import type { ToastStatus } from './useToast'
5
+
6
+ export interface ToastProps {
7
+ /** Visual status */
8
+ status?: ToastStatus
9
+ /** Toast message text */
10
+ message: string
11
+ }
12
+
13
+ const props = withDefaults(defineProps<ToastProps>(), {
14
+ status: 'info',
15
+ })
16
+
17
+ const emit = defineEmits<{
18
+ (e: 'dismiss'): void
19
+ }>()
20
+
21
+ const isUrgent = computed(
22
+ () => props.status === 'error' || props.status === 'warning',
23
+ )
24
+
25
+ const classes = computed(() =>
26
+ ['strand-toast', `strand-toast--${props.status}`]
27
+ .filter(Boolean)
28
+ .join(' '),
29
+ )
30
+ </script>
31
+
32
+ <template>
33
+ <div
34
+ :class="classes"
35
+ role="status"
36
+ :aria-live="isUrgent ? 'assertive' : 'polite'"
37
+ >
38
+ <span class="strand-toast__message">{{ message }}</span>
39
+ <button
40
+ type="button"
41
+ class="strand-toast__dismiss"
42
+ aria-label="Dismiss"
43
+ @click="emit('dismiss')"
44
+ >
45
+ &#215;
46
+ </button>
47
+ </div>
48
+ </template>
@@ -0,0 +1,80 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { ref, provide, onUnmounted } from 'vue'
4
+ import { ToastKey } from './useToast'
5
+ import type { ToastOptions, ToastStatus } from './useToast'
6
+
7
+ interface ToastEntry {
8
+ id: number
9
+ message: string
10
+ status: ToastStatus
11
+ duration: number
12
+ }
13
+
14
+ let toastIdCounter = 0
15
+
16
+ const toasts = ref<ToastEntry[]>([])
17
+ const timers = new Map<number, ReturnType<typeof setTimeout>>()
18
+
19
+ function removeToast(id: number) {
20
+ const timer = timers.get(id)
21
+ if (timer !== undefined) {
22
+ clearTimeout(timer)
23
+ timers.delete(id)
24
+ }
25
+ toasts.value = toasts.value.filter((t) => t.id !== id)
26
+ }
27
+
28
+ function addToast(options: ToastOptions) {
29
+ const entry: ToastEntry = {
30
+ id: ++toastIdCounter,
31
+ message: options.message,
32
+ status: options.status ?? 'info',
33
+ duration: options.duration ?? 5000,
34
+ }
35
+ toasts.value = [...toasts.value, entry]
36
+
37
+ if (entry.duration > 0) {
38
+ const timer = setTimeout(() => {
39
+ removeToast(entry.id)
40
+ }, entry.duration)
41
+ timers.set(entry.id, timer)
42
+ }
43
+ }
44
+
45
+ provide(ToastKey, { toast: addToast })
46
+
47
+ onUnmounted(() => {
48
+ for (const timer of timers.values()) {
49
+ clearTimeout(timer)
50
+ }
51
+ timers.clear()
52
+ })
53
+
54
+ function isUrgent(status: ToastStatus): boolean {
55
+ return status === 'error' || status === 'warning'
56
+ }
57
+ </script>
58
+
59
+ <template>
60
+ <slot />
61
+ <div v-if="toasts.length > 0" class="strand-toast__container">
62
+ <div
63
+ v-for="entry in toasts"
64
+ :key="entry.id"
65
+ :class="['strand-toast', `strand-toast--${entry.status}`].join(' ')"
66
+ role="status"
67
+ :aria-live="isUrgent(entry.status) ? 'assertive' : 'polite'"
68
+ >
69
+ <span class="strand-toast__message">{{ entry.message }}</span>
70
+ <button
71
+ type="button"
72
+ class="strand-toast__dismiss"
73
+ aria-label="Dismiss"
74
+ @click="removeToast(entry.id)"
75
+ >
76
+ &#215;
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </template>
@@ -0,0 +1,5 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Toast } from './Toast.vue'
3
+ export { default as ToastProvider } from './ToastProvider.vue'
4
+ export { useToast } from './useToast'
5
+ export type { ToastOptions, ToastStatus, ToastContextValue } from './useToast'
@@ -0,0 +1,26 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+
3
+ import { inject } from 'vue'
4
+ import type { InjectionKey } from 'vue'
5
+
6
+ export type ToastStatus = 'info' | 'success' | 'warning' | 'error'
7
+
8
+ export interface ToastOptions {
9
+ message: string
10
+ status?: ToastStatus
11
+ duration?: number
12
+ }
13
+
14
+ export interface ToastContextValue {
15
+ toast: (options: ToastOptions) => void
16
+ }
17
+
18
+ export const ToastKey: InjectionKey<ToastContextValue> = Symbol('StrandToast')
19
+
20
+ export function useToast(): ToastContextValue {
21
+ const ctx = inject(ToastKey)
22
+ if (!ctx) {
23
+ throw new Error('useToast must be used within a ToastProvider')
24
+ }
25
+ return ctx
26
+ }