@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,62 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed } from 'vue'
4
+
5
+ export interface SwitchProps {
6
+ /** Controlled checked state */
7
+ checked?: boolean
8
+ /** Disabled state */
9
+ disabled?: boolean
10
+ /** Inline label text */
11
+ label?: string
12
+ }
13
+
14
+ const props = withDefaults(defineProps<SwitchProps>(), {
15
+ checked: false,
16
+ disabled: false,
17
+ })
18
+
19
+ const emit = defineEmits<{
20
+ (e: 'change', checked: boolean): void
21
+ }>()
22
+
23
+ const classes = computed(() =>
24
+ [
25
+ 'strand-switch',
26
+ props.checked && 'strand-switch--checked',
27
+ props.disabled && 'strand-switch--disabled',
28
+ ]
29
+ .filter(Boolean)
30
+ .join(' '),
31
+ )
32
+
33
+ function handleClick() {
34
+ if (!props.disabled) {
35
+ emit('change', !props.checked)
36
+ }
37
+ }
38
+
39
+ function handleKeyDown(event: KeyboardEvent) {
40
+ if ((event.key === ' ' || event.key === 'Enter') && !props.disabled) {
41
+ event.preventDefault()
42
+ emit('change', !props.checked)
43
+ }
44
+ }
45
+ </script>
46
+
47
+ <template>
48
+ <label :class="classes">
49
+ <button
50
+ type="button"
51
+ role="switch"
52
+ class="strand-switch__track"
53
+ :aria-checked="checked ? 'true' : 'false'"
54
+ :disabled="disabled"
55
+ @click="handleClick"
56
+ @keydown="handleKeyDown"
57
+ >
58
+ <span class="strand-switch__thumb" aria-hidden="true" />
59
+ </button>
60
+ <span v-if="label" class="strand-switch__label">{{ label }}</span>
61
+ </label>
62
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Switch } from './Switch.vue'
@@ -0,0 +1,152 @@
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 Table from './Table.vue'
6
+
7
+ const columns = [
8
+ { key: 'name', header: 'Name', sortable: true },
9
+ { key: 'role', header: 'Role' },
10
+ { key: 'status', header: 'Status', sortable: true },
11
+ ]
12
+
13
+ const data = [
14
+ { name: 'Alice', role: 'Engineer', status: 'Active' },
15
+ { name: 'Bob', role: 'Designer', status: 'Away' },
16
+ ]
17
+
18
+ describe('Table', () => {
19
+ // -- Rendering --
20
+
21
+ it('renders a table element', () => {
22
+ const { container } = render(Table, {
23
+ props: { columns, data },
24
+ })
25
+ expect(container.querySelector('table')).toBeTruthy()
26
+ })
27
+
28
+ it('renders column headers', () => {
29
+ const { getByText } = render(Table, {
30
+ props: { columns, data },
31
+ })
32
+ expect(getByText('Name')).toBeTruthy()
33
+ expect(getByText('Role')).toBeTruthy()
34
+ expect(getByText('Status')).toBeTruthy()
35
+ })
36
+
37
+ it('renders data rows', () => {
38
+ const { getByText } = render(Table, {
39
+ props: { columns, data },
40
+ })
41
+ expect(getByText('Alice')).toBeTruthy()
42
+ expect(getByText('Bob')).toBeTruthy()
43
+ })
44
+
45
+ it('renders correct number of cells', () => {
46
+ const { container } = render(Table, {
47
+ props: { columns, data },
48
+ })
49
+ const cells = container.querySelectorAll('.strand-table__td')
50
+ // 2 rows x 3 columns = 6 cells
51
+ expect(cells.length).toBe(6)
52
+ })
53
+
54
+ it('renders correct number of header cells', () => {
55
+ const { container } = render(Table, {
56
+ props: { columns, data },
57
+ })
58
+ const headers = container.querySelectorAll('.strand-table__th')
59
+ expect(headers.length).toBe(3)
60
+ })
61
+
62
+ // -- Sorting --
63
+
64
+ it('renders sort button for sortable columns', () => {
65
+ const { container } = render(Table, {
66
+ props: { columns, data },
67
+ })
68
+ const sortButtons = container.querySelectorAll('.strand-table__sort-btn')
69
+ // "Name" and "Status" are sortable
70
+ expect(sortButtons.length).toBe(2)
71
+ })
72
+
73
+ it('does not render sort button for non-sortable columns', () => {
74
+ const nonSortable = [{ key: 'role', header: 'Role' }]
75
+ const { container } = render(Table, {
76
+ props: { columns: nonSortable, data },
77
+ })
78
+ const sortButtons = container.querySelectorAll('.strand-table__sort-btn')
79
+ expect(sortButtons.length).toBe(0)
80
+ })
81
+
82
+ it('emits sort with key and asc direction on first click', async () => {
83
+ const { container, emitted } = render(Table, {
84
+ props: { columns, data },
85
+ })
86
+ const sortButtons = container.querySelectorAll('.strand-table__sort-btn')
87
+ await fireEvent.click(sortButtons[0])
88
+ expect(emitted().sort[0]).toEqual(['name', 'asc'])
89
+ })
90
+
91
+ it('toggles sort direction on second click of same column', async () => {
92
+ const { container, emitted } = render(Table, {
93
+ props: { columns, data },
94
+ })
95
+ const sortButtons = container.querySelectorAll('.strand-table__sort-btn')
96
+ await fireEvent.click(sortButtons[0]) // asc
97
+ await fireEvent.click(sortButtons[0]) // desc
98
+ expect(emitted().sort[1]).toEqual(['name', 'desc'])
99
+ })
100
+
101
+ // -- Responsive --
102
+
103
+ it('wraps table in overflow-x scroll container', () => {
104
+ const { container } = render(Table, {
105
+ props: { columns, data },
106
+ })
107
+ const wrapper = container.querySelector('.strand-table-wrapper')
108
+ expect(wrapper).toBeTruthy()
109
+ })
110
+
111
+ // -- Empty state --
112
+
113
+ it('renders empty tbody when data is empty', () => {
114
+ const { container } = render(Table, {
115
+ props: { columns, data: [] },
116
+ })
117
+ const rows = container.querySelectorAll('.strand-table__row')
118
+ expect(rows.length).toBe(0)
119
+ })
120
+
121
+ // -- Column width --
122
+
123
+ it('applies width style to column headers', () => {
124
+ const cols = [{ key: 'name', header: 'Name', width: '200px' }]
125
+ const { container } = render(Table, {
126
+ props: { columns: cols, data: [] },
127
+ })
128
+ const th = container.querySelector('.strand-table__th') as HTMLElement
129
+ expect(th?.style.width).toBe('200px')
130
+ })
131
+
132
+ // -- Sort indicator --
133
+
134
+ it('shows sort indicator on sortable columns', () => {
135
+ const { container } = render(Table, {
136
+ props: { columns, data },
137
+ })
138
+ const indicators = container.querySelectorAll('.strand-table__sort-indicator')
139
+ expect(indicators.length).toBe(2)
140
+ })
141
+
142
+ // -- Sort button aria-label --
143
+
144
+ it('sort button has aria-label with column name', () => {
145
+ const { container } = render(Table, {
146
+ props: { columns, data },
147
+ })
148
+ const sortButtons = container.querySelectorAll('.strand-table__sort-btn')
149
+ expect(sortButtons[0]).toHaveAttribute('aria-label', 'Sort by Name')
150
+ expect(sortButtons[1]).toHaveAttribute('aria-label', 'Sort by Status')
151
+ })
152
+ })
@@ -0,0 +1,98 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed, ref } from 'vue'
4
+
5
+ export interface TableColumn {
6
+ /** Unique key matching the data field */
7
+ key: string
8
+ /** Display header text */
9
+ header: string
10
+ /** Whether the column is sortable */
11
+ sortable?: boolean
12
+ /** Optional fixed width */
13
+ width?: string
14
+ }
15
+
16
+ export interface TableProps {
17
+ /** Column definitions */
18
+ columns: TableColumn[]
19
+ /** Row data */
20
+ data: Array<Record<string, unknown>>
21
+ }
22
+
23
+ const props = defineProps<TableProps>()
24
+
25
+ const emit = defineEmits<{
26
+ (e: 'sort', key: string, direction: 'asc' | 'desc'): void
27
+ }>()
28
+
29
+ const sortKey = ref<string | null>(null)
30
+ const sortDirection = ref<'asc' | 'desc'>('asc')
31
+
32
+ const wrapperClasses = computed(() =>
33
+ ['strand-table-wrapper'].filter(Boolean).join(' '),
34
+ )
35
+
36
+ function handleSort(key: string) {
37
+ const nextDirection =
38
+ sortKey.value === key && sortDirection.value === 'asc' ? 'desc' : 'asc'
39
+ sortKey.value = key
40
+ sortDirection.value = nextDirection
41
+ emit('sort', key, nextDirection)
42
+ }
43
+
44
+ function sortIndicator(key: string): string {
45
+ if (sortKey.value === key) {
46
+ return sortDirection.value === 'asc' ? '\u2191' : '\u2193'
47
+ }
48
+ return '\u2195'
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <div :class="wrapperClasses">
54
+ <table class="strand-table">
55
+ <thead class="strand-table__head">
56
+ <tr>
57
+ <th
58
+ v-for="col in columns"
59
+ :key="col.key"
60
+ class="strand-table__th"
61
+ :style="col.width ? { width: col.width } : undefined"
62
+ >
63
+ <button
64
+ v-if="col.sortable"
65
+ type="button"
66
+ class="strand-table__sort-btn"
67
+ :aria-label="`Sort by ${col.header}`"
68
+ @click="handleSort(col.key)"
69
+ >
70
+ {{ col.header }}
71
+ <span class="strand-table__sort-indicator" aria-hidden="true">
72
+ {{ sortIndicator(col.key) }}
73
+ </span>
74
+ </button>
75
+ <template v-else>
76
+ {{ col.header }}
77
+ </template>
78
+ </th>
79
+ </tr>
80
+ </thead>
81
+ <tbody class="strand-table__body">
82
+ <tr
83
+ v-for="(row, rowIndex) in data"
84
+ :key="rowIndex"
85
+ class="strand-table__row"
86
+ >
87
+ <td
88
+ v-for="col in columns"
89
+ :key="col.key"
90
+ class="strand-table__td"
91
+ >
92
+ {{ row[col.key] }}
93
+ </td>
94
+ </tr>
95
+ </tbody>
96
+ </table>
97
+ </div>
98
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Table } from './Table.vue'
@@ -0,0 +1,138 @@
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 Tabs from './Tabs.vue'
6
+
7
+ const sampleTabs = [
8
+ { id: 'one', label: 'Tab One' },
9
+ { id: 'two', label: 'Tab Two' },
10
+ { id: 'three', label: 'Tab Three' },
11
+ ]
12
+
13
+ function renderTabs(props: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
14
+ return render(Tabs, {
15
+ props: {
16
+ tabs: sampleTabs,
17
+ activeTab: 'one',
18
+ ...props,
19
+ },
20
+ slots: {
21
+ 'panel-one': '<div>Content One</div>',
22
+ 'panel-two': '<div>Content Two</div>',
23
+ 'panel-three': '<div>Content Three</div>',
24
+ ...slots,
25
+ },
26
+ })
27
+ }
28
+
29
+ describe('Tabs', () => {
30
+ // -- Structure --
31
+
32
+ it('renders tablist role', () => {
33
+ const { getByRole } = renderTabs()
34
+ expect(getByRole('tablist')).toBeTruthy()
35
+ })
36
+
37
+ it('renders tab buttons for each tab', () => {
38
+ const { getAllByRole } = renderTabs()
39
+ expect(getAllByRole('tab')).toHaveLength(3)
40
+ })
41
+
42
+ // -- Active state --
43
+
44
+ it('active tab has aria-selected true', () => {
45
+ const { getAllByRole } = renderTabs()
46
+ const tabs = getAllByRole('tab')
47
+ expect(tabs[0]).toHaveAttribute('aria-selected', 'true')
48
+ })
49
+
50
+ it('inactive tabs have aria-selected false', () => {
51
+ const { getAllByRole } = renderTabs()
52
+ const tabs = getAllByRole('tab')
53
+ expect(tabs[1]).toHaveAttribute('aria-selected', 'false')
54
+ expect(tabs[2]).toHaveAttribute('aria-selected', 'false')
55
+ })
56
+
57
+ it('active tab has tabindex 0, inactive tabs have tabindex -1', () => {
58
+ const { getAllByRole } = renderTabs({ activeTab: 'two' })
59
+ const tabs = getAllByRole('tab')
60
+ expect(tabs[0]).toHaveAttribute('tabindex', '-1')
61
+ expect(tabs[1]).toHaveAttribute('tabindex', '0')
62
+ expect(tabs[2]).toHaveAttribute('tabindex', '-1')
63
+ })
64
+
65
+ // -- Interaction --
66
+
67
+ it('clicking tab emits change with the tab id', async () => {
68
+ const { getAllByRole, emitted } = renderTabs()
69
+ await fireEvent.click(getAllByRole('tab')[1])
70
+ expect(emitted().change[0]).toEqual(['two'])
71
+ })
72
+
73
+ // -- Panels --
74
+
75
+ it('active panel is visible', () => {
76
+ const { getByText } = renderTabs()
77
+ expect(getByText('Content One').closest('[role="tabpanel"]')).not.toHaveAttribute('hidden')
78
+ })
79
+
80
+ it('inactive panels are hidden', () => {
81
+ const { getByText } = renderTabs()
82
+ expect(getByText('Content Two').closest('[role="tabpanel"]')).toHaveAttribute('hidden')
83
+ expect(getByText('Content Three').closest('[role="tabpanel"]')).toHaveAttribute('hidden')
84
+ })
85
+
86
+ it('tabpanel has aria-labelledby pointing to its tab', () => {
87
+ const { getAllByRole } = renderTabs()
88
+ const panels = getAllByRole('tabpanel', { hidden: true })
89
+ expect(panels[0]).toHaveAttribute('aria-labelledby', 'tab-one')
90
+ })
91
+
92
+ // -- Keyboard navigation --
93
+
94
+ it('ArrowRight moves to next tab', async () => {
95
+ const { getAllByRole, emitted } = renderTabs()
96
+ await fireEvent.keyDown(getAllByRole('tab')[0], { key: 'ArrowRight' })
97
+ expect(emitted().change[0]).toEqual(['two'])
98
+ })
99
+
100
+ it('ArrowLeft moves to previous tab', async () => {
101
+ const { getAllByRole, emitted } = renderTabs({ activeTab: 'two' })
102
+ await fireEvent.keyDown(getAllByRole('tab')[1], { key: 'ArrowLeft' })
103
+ expect(emitted().change[0]).toEqual(['one'])
104
+ })
105
+
106
+ it('ArrowRight wraps to first tab from last', async () => {
107
+ const { getAllByRole, emitted } = renderTabs({ activeTab: 'three' })
108
+ await fireEvent.keyDown(getAllByRole('tab')[2], { key: 'ArrowRight' })
109
+ expect(emitted().change[0]).toEqual(['one'])
110
+ })
111
+
112
+ it('ArrowLeft wraps to last tab from first', async () => {
113
+ const { getAllByRole, emitted } = renderTabs({ activeTab: 'one' })
114
+ await fireEvent.keyDown(getAllByRole('tab')[0], { key: 'ArrowLeft' })
115
+ expect(emitted().change[0]).toEqual(['three'])
116
+ })
117
+
118
+ it('Home moves focus to first tab', async () => {
119
+ const { getAllByRole, emitted } = renderTabs({ activeTab: 'three' })
120
+ await fireEvent.keyDown(getAllByRole('tab')[2], { key: 'Home' })
121
+ expect(emitted().change[0]).toEqual(['one'])
122
+ })
123
+
124
+ it('End moves focus to last tab', async () => {
125
+ const { getAllByRole, emitted } = renderTabs({ activeTab: 'one' })
126
+ await fireEvent.keyDown(getAllByRole('tab')[0], { key: 'End' })
127
+ expect(emitted().change[0]).toEqual(['three'])
128
+ })
129
+
130
+ // -- Tab aria-controls --
131
+
132
+ it('tab has aria-controls pointing to panel', () => {
133
+ const { getAllByRole } = renderTabs()
134
+ const tabs = getAllByRole('tab')
135
+ expect(tabs[0]).toHaveAttribute('aria-controls', 'panel-one')
136
+ expect(tabs[1]).toHaveAttribute('aria-controls', 'panel-two')
137
+ })
138
+ })
@@ -0,0 +1,96 @@
1
+ <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
+ <script setup lang="ts">
3
+ import { computed, ref } from 'vue'
4
+
5
+ export interface TabItem {
6
+ id: string
7
+ label: string
8
+ }
9
+
10
+ export interface TabsProps {
11
+ /** Tab definitions (id + label only; content is provided via slots) */
12
+ tabs: TabItem[]
13
+ /** Currently active tab id (controlled) */
14
+ activeTab: string
15
+ }
16
+
17
+ const props = defineProps<TabsProps>()
18
+
19
+ const emit = defineEmits<{
20
+ (e: 'change', id: string): void
21
+ }>()
22
+
23
+ const tablistRef = ref<HTMLDivElement | null>(null)
24
+
25
+ const classes = computed(() => ['strand-tabs'].filter(Boolean).join(' '))
26
+
27
+ function focusAndSelect(index: number) {
28
+ const tab = props.tabs[index]
29
+ if (tab) {
30
+ emit('change', tab.id)
31
+ const buttons = tablistRef.value?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
32
+ buttons?.[index]?.focus()
33
+ }
34
+ }
35
+
36
+ function handleKeyDown(event: KeyboardEvent) {
37
+ const currentIndex = props.tabs.findIndex((t) => t.id === props.activeTab)
38
+ let nextIndex: number | null = null
39
+
40
+ switch (event.key) {
41
+ case 'ArrowRight':
42
+ nextIndex = (currentIndex + 1) % props.tabs.length
43
+ break
44
+ case 'ArrowLeft':
45
+ nextIndex = (currentIndex - 1 + props.tabs.length) % props.tabs.length
46
+ break
47
+ case 'Home':
48
+ nextIndex = 0
49
+ break
50
+ case 'End':
51
+ nextIndex = props.tabs.length - 1
52
+ break
53
+ default:
54
+ return
55
+ }
56
+
57
+ event.preventDefault()
58
+ focusAndSelect(nextIndex)
59
+ }
60
+ </script>
61
+
62
+ <template>
63
+ <div :class="classes">
64
+ <div ref="tablistRef" role="tablist" @keydown="handleKeyDown">
65
+ <button
66
+ v-for="tab in tabs"
67
+ :key="tab.id"
68
+ :id="`tab-${tab.id}`"
69
+ role="tab"
70
+ type="button"
71
+ :class="[
72
+ 'strand-tabs__tab',
73
+ tab.id === activeTab && 'strand-tabs__tab--active',
74
+ ].filter(Boolean).join(' ')"
75
+ :aria-selected="tab.id === activeTab ? 'true' : 'false'"
76
+ :aria-controls="`panel-${tab.id}`"
77
+ :tabindex="tab.id === activeTab ? 0 : -1"
78
+ @click="emit('change', tab.id)"
79
+ >
80
+ {{ tab.label }}
81
+ </button>
82
+ </div>
83
+
84
+ <div
85
+ v-for="tab in tabs"
86
+ :key="tab.id"
87
+ :id="`panel-${tab.id}`"
88
+ role="tabpanel"
89
+ :aria-labelledby="`tab-${tab.id}`"
90
+ :hidden="tab.id !== activeTab || undefined"
91
+ :tabindex="0"
92
+ >
93
+ <slot :name="`panel-${tab.id}`" />
94
+ </div>
95
+ </div>
96
+ </template>
@@ -0,0 +1,2 @@
1
+ /*! Strand Vue | MIT License | dillingerstaffing.com */
2
+ export { default as Tabs } from './Tabs.vue'
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, fireEvent } from '@testing-library/vue'
3
+ import Tag from './Tag.vue'
4
+
5
+ describe('Tag', () => {
6
+ // ── Rendering ──
7
+
8
+ it('renders a span element', () => {
9
+ const { container } = render(Tag, { slots: { default: 'Label' } })
10
+ expect(container.firstElementChild?.tagName).toBe('SPAN')
11
+ })
12
+
13
+ it('renders slot content inside text span', () => {
14
+ const { container } = render(Tag, { slots: { default: 'Active' } })
15
+ const textSpan = container.querySelector('.strand-tag__text')
16
+ expect(textSpan?.textContent).toBe('Active')
17
+ })
18
+
19
+ // ── Variants ──
20
+
21
+ it('applies solid variant class by default', () => {
22
+ const { container } = render(Tag, { slots: { default: 'Test' } })
23
+ expect(container.firstElementChild?.className).toContain('strand-tag--solid')
24
+ })
25
+
26
+ it('applies outlined variant class', () => {
27
+ const { container } = render(Tag, {
28
+ props: { variant: 'outlined' },
29
+ slots: { default: 'Test' },
30
+ })
31
+ expect(container.firstElementChild?.className).toContain('strand-tag--outlined')
32
+ })
33
+
34
+ // ── Status colors ──
35
+
36
+ it('applies default status class by default', () => {
37
+ const { container } = render(Tag, { slots: { default: 'Test' } })
38
+ expect(container.firstElementChild?.className).toContain('strand-tag--default')
39
+ })
40
+
41
+ it('applies teal status class', () => {
42
+ const { container } = render(Tag, {
43
+ props: { status: 'teal' },
44
+ slots: { default: 'Test' },
45
+ })
46
+ expect(container.firstElementChild?.className).toContain('strand-tag--teal')
47
+ })
48
+
49
+ it('applies red status class', () => {
50
+ const { container } = render(Tag, {
51
+ props: { status: 'red' },
52
+ slots: { default: 'Test' },
53
+ })
54
+ expect(container.firstElementChild?.className).toContain('strand-tag--red')
55
+ })
56
+
57
+ it('applies amber status class', () => {
58
+ const { container } = render(Tag, {
59
+ props: { status: 'amber' },
60
+ slots: { default: 'Test' },
61
+ })
62
+ expect(container.firstElementChild?.className).toContain('strand-tag--amber')
63
+ })
64
+
65
+ it('applies blue status class', () => {
66
+ const { container } = render(Tag, {
67
+ props: { status: 'blue' },
68
+ slots: { default: 'Test' },
69
+ })
70
+ expect(container.firstElementChild?.className).toContain('strand-tag--blue')
71
+ })
72
+
73
+ // ── Removable ──
74
+
75
+ it('does not show remove button by default', () => {
76
+ const { container } = render(Tag, { slots: { default: 'Test' } })
77
+ expect(container.querySelector('.strand-tag__remove')).toBeNull()
78
+ })
79
+
80
+ it('shows remove button when removable', () => {
81
+ const { container } = render(Tag, {
82
+ props: { removable: true },
83
+ slots: { default: 'Test' },
84
+ })
85
+ expect(container.querySelector('.strand-tag__remove')).toBeTruthy()
86
+ })
87
+
88
+ it('remove button has aria-label', () => {
89
+ const { container } = render(Tag, {
90
+ props: { removable: true },
91
+ slots: { default: 'Test' },
92
+ })
93
+ const btn = container.querySelector('.strand-tag__remove')
94
+ expect(btn?.getAttribute('aria-label')).toBe('Remove')
95
+ })
96
+
97
+ it('emits remove event on button click', async () => {
98
+ const { container, emitted } = render(Tag, {
99
+ props: { removable: true },
100
+ slots: { default: 'Test' },
101
+ })
102
+ const btn = container.querySelector('.strand-tag__remove') as HTMLButtonElement
103
+ await fireEvent.click(btn)
104
+ expect(emitted().remove).toHaveLength(1)
105
+ })
106
+
107
+ it('remove button contains SVG with aria-hidden', () => {
108
+ const { container } = render(Tag, {
109
+ props: { removable: true },
110
+ slots: { default: 'Test' },
111
+ })
112
+ const svg = container.querySelector('.strand-tag__remove svg')
113
+ expect(svg).toBeTruthy()
114
+ expect(svg?.getAttribute('aria-hidden')).toBe('true')
115
+ })
116
+
117
+ // ── Custom className ──
118
+
119
+ it('merges custom className', () => {
120
+ const { container } = render(Tag, {
121
+ props: { className: 'custom' },
122
+ slots: { default: 'Test' },
123
+ })
124
+ const el = container.firstElementChild
125
+ expect(el?.className).toContain('strand-tag')
126
+ expect(el?.className).toContain('custom')
127
+ })
128
+ })