@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.
- package/dist/components/Alert/Alert.vue.d.ts +31 -0
- package/dist/components/Alert/Alert.vue.d.ts.map +1 -0
- package/dist/components/Alert/index.d.ts +3 -0
- package/dist/components/Alert/index.d.ts.map +1 -0
- package/dist/components/Avatar/Avatar.vue.d.ts +20 -0
- package/dist/components/Avatar/Avatar.vue.d.ts.map +1 -0
- package/dist/components/Avatar/index.d.ts +2 -0
- package/dist/components/Avatar/index.d.ts.map +1 -0
- package/dist/components/Badge/Badge.vue.d.ts +35 -0
- package/dist/components/Badge/Badge.vue.d.ts.map +1 -0
- package/dist/components/Badge/index.d.ts +2 -0
- package/dist/components/Badge/index.d.ts.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.vue.d.ts +15 -0
- package/dist/components/Breadcrumb/Breadcrumb.vue.d.ts.map +1 -0
- package/dist/components/Breadcrumb/index.d.ts +3 -0
- package/dist/components/Breadcrumb/index.d.ts.map +1 -0
- package/dist/components/Button/Button.vue.d.ts +46 -0
- package/dist/components/Button/Button.vue.d.ts.map +1 -0
- package/dist/components/Button/index.d.ts +3 -0
- package/dist/components/Button/index.d.ts.map +1 -0
- package/dist/components/Card/Card.vue.d.ts +30 -0
- package/dist/components/Card/Card.vue.d.ts.map +1 -0
- package/dist/components/Card/index.d.ts +2 -0
- package/dist/components/Card/index.d.ts.map +1 -0
- package/dist/components/Checkbox/Checkbox.vue.d.ts +23 -0
- package/dist/components/Checkbox/Checkbox.vue.d.ts.map +1 -0
- package/dist/components/Checkbox/index.d.ts +3 -0
- package/dist/components/Checkbox/index.d.ts.map +1 -0
- package/dist/components/Container/Container.vue.d.ts +27 -0
- package/dist/components/Container/Container.vue.d.ts.map +1 -0
- package/dist/components/Container/index.d.ts +2 -0
- package/dist/components/Container/index.d.ts.map +1 -0
- package/dist/components/DataReadout/DataReadout.vue.d.ts +15 -0
- package/dist/components/DataReadout/DataReadout.vue.d.ts.map +1 -0
- package/dist/components/DataReadout/index.d.ts +2 -0
- package/dist/components/DataReadout/index.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.vue.d.ts +39 -0
- package/dist/components/Dialog/Dialog.vue.d.ts.map +1 -0
- package/dist/components/Dialog/index.d.ts +3 -0
- package/dist/components/Dialog/index.d.ts.map +1 -0
- package/dist/components/Divider/Divider.vue.d.ts +14 -0
- package/dist/components/Divider/Divider.vue.d.ts.map +1 -0
- package/dist/components/Divider/index.d.ts +2 -0
- package/dist/components/Divider/index.d.ts.map +1 -0
- package/dist/components/FormField/FormField.vue.d.ts +32 -0
- package/dist/components/FormField/FormField.vue.d.ts.map +1 -0
- package/dist/components/FormField/index.d.ts +3 -0
- package/dist/components/FormField/index.d.ts.map +1 -0
- package/dist/components/Grid/Grid.vue.d.ts +30 -0
- package/dist/components/Grid/Grid.vue.d.ts.map +1 -0
- package/dist/components/Grid/index.d.ts +2 -0
- package/dist/components/Grid/index.d.ts.map +1 -0
- package/dist/components/Input/Input.vue.d.ts +37 -0
- package/dist/components/Input/Input.vue.d.ts.map +1 -0
- package/dist/components/Input/index.d.ts +3 -0
- package/dist/components/Input/index.d.ts.map +1 -0
- package/dist/components/Link/Link.vue.d.ts +29 -0
- package/dist/components/Link/Link.vue.d.ts.map +1 -0
- package/dist/components/Link/index.d.ts +2 -0
- package/dist/components/Link/index.d.ts.map +1 -0
- package/dist/components/Nav/Nav.vue.d.ts +30 -0
- package/dist/components/Nav/Nav.vue.d.ts.map +1 -0
- package/dist/components/Nav/index.d.ts +3 -0
- package/dist/components/Nav/index.d.ts.map +1 -0
- package/dist/components/Progress/Progress.vue.d.ts +17 -0
- package/dist/components/Progress/Progress.vue.d.ts.map +1 -0
- package/dist/components/Progress/index.d.ts +2 -0
- package/dist/components/Progress/index.d.ts.map +1 -0
- package/dist/components/Radio/Radio.vue.d.ts +22 -0
- package/dist/components/Radio/Radio.vue.d.ts.map +1 -0
- package/dist/components/Radio/index.d.ts +3 -0
- package/dist/components/Radio/index.d.ts.map +1 -0
- package/dist/components/Section/Section.vue.d.ts +30 -0
- package/dist/components/Section/Section.vue.d.ts.map +1 -0
- package/dist/components/Section/index.d.ts +2 -0
- package/dist/components/Section/index.d.ts.map +1 -0
- package/dist/components/Select/Select.vue.d.ts +26 -0
- package/dist/components/Select/Select.vue.d.ts.map +1 -0
- package/dist/components/Select/index.d.ts +3 -0
- package/dist/components/Select/index.d.ts.map +1 -0
- package/dist/components/Skeleton/Skeleton.vue.d.ts +16 -0
- package/dist/components/Skeleton/Skeleton.vue.d.ts.map +1 -0
- package/dist/components/Skeleton/index.d.ts +2 -0
- package/dist/components/Skeleton/index.d.ts.map +1 -0
- package/dist/components/Slider/Slider.vue.d.ts +24 -0
- package/dist/components/Slider/Slider.vue.d.ts.map +1 -0
- package/dist/components/Slider/index.d.ts +3 -0
- package/dist/components/Slider/index.d.ts.map +1 -0
- package/dist/components/Spinner/Spinner.vue.d.ts +12 -0
- package/dist/components/Spinner/Spinner.vue.d.ts.map +1 -0
- package/dist/components/Spinner/index.d.ts +2 -0
- package/dist/components/Spinner/index.d.ts.map +1 -0
- package/dist/components/Stack/Stack.vue.d.ts +38 -0
- package/dist/components/Stack/Stack.vue.d.ts.map +1 -0
- package/dist/components/Stack/index.d.ts +2 -0
- package/dist/components/Stack/index.d.ts.map +1 -0
- package/dist/components/Switch/Switch.vue.d.ts +18 -0
- package/dist/components/Switch/Switch.vue.d.ts.map +1 -0
- package/dist/components/Switch/index.d.ts +3 -0
- package/dist/components/Switch/index.d.ts.map +1 -0
- package/dist/components/Table/Table.vue.d.ts +23 -0
- package/dist/components/Table/Table.vue.d.ts.map +1 -0
- package/dist/components/Table/index.d.ts +3 -0
- package/dist/components/Table/index.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.vue.d.ts +34 -0
- package/dist/components/Tabs/Tabs.vue.d.ts.map +1 -0
- package/dist/components/Tabs/index.d.ts +3 -0
- package/dist/components/Tabs/index.d.ts.map +1 -0
- package/dist/components/Tag/Tag.vue.d.ts +37 -0
- package/dist/components/Tag/Tag.vue.d.ts.map +1 -0
- package/dist/components/Tag/index.d.ts +2 -0
- package/dist/components/Tag/index.d.ts.map +1 -0
- package/dist/components/Textarea/Textarea.vue.d.ts +29 -0
- package/dist/components/Textarea/Textarea.vue.d.ts.map +1 -0
- package/dist/components/Textarea/index.d.ts +3 -0
- package/dist/components/Textarea/index.d.ts.map +1 -0
- package/dist/components/Toast/Toast.vue.d.ts +16 -0
- package/dist/components/Toast/Toast.vue.d.ts.map +1 -0
- package/dist/components/Toast/ToastProvider.vue.d.ts +18 -0
- package/dist/components/Toast/ToastProvider.vue.d.ts.map +1 -0
- package/dist/components/Toast/index.d.ts +6 -0
- package/dist/components/Toast/index.d.ts.map +1 -0
- package/dist/components/Toast/useToast.d.ts +13 -0
- package/dist/components/Toast/useToast.d.ts.map +1 -0
- package/dist/components/Tooltip/Tooltip.vue.d.ts +29 -0
- package/dist/components/Tooltip/Tooltip.vue.d.ts.map +1 -0
- package/dist/components/Tooltip/index.d.ts +3 -0
- package/dist/components/Tooltip/index.d.ts.map +1 -0
- package/dist/css/strand-ui.css +2534 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1413 -0
- package/dist/index.js.map +1 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/components/Alert/Alert.test.ts +100 -0
- package/src/components/Alert/Alert.vue +54 -0
- package/src/components/Alert/index.ts +2 -0
- package/src/components/Avatar/Avatar.test.ts +105 -0
- package/src/components/Avatar/Avatar.vue +56 -0
- package/src/components/Avatar/index.ts +1 -0
- package/src/components/Badge/Badge.test.ts +114 -0
- package/src/components/Badge/Badge.vue +66 -0
- package/src/components/Badge/index.ts +1 -0
- package/src/components/Breadcrumb/Breadcrumb.test.ts +119 -0
- package/src/components/Breadcrumb/Breadcrumb.vue +58 -0
- package/src/components/Breadcrumb/index.ts +2 -0
- package/src/components/Button/Button.test.ts +148 -0
- package/src/components/Button/Button.vue +75 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Card/Card.test.ts +93 -0
- package/src/components/Card/Card.vue +36 -0
- package/src/components/Card/index.ts +1 -0
- package/src/components/Checkbox/Checkbox.test.ts +118 -0
- package/src/components/Checkbox/Checkbox.vue +117 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/Container/Container.test.ts +70 -0
- package/src/components/Container/Container.vue +32 -0
- package/src/components/Container/index.ts +1 -0
- package/src/components/DataReadout/DataReadout.test.ts +99 -0
- package/src/components/DataReadout/DataReadout.vue +36 -0
- package/src/components/DataReadout/index.ts +1 -0
- package/src/components/Dialog/Dialog.test.ts +224 -0
- package/src/components/Dialog/Dialog.vue +146 -0
- package/src/components/Dialog/index.ts +2 -0
- package/src/components/Divider/Divider.test.ts +95 -0
- package/src/components/Divider/Divider.vue +63 -0
- package/src/components/Divider/index.ts +1 -0
- package/src/components/FormField/FormField.test.ts +98 -0
- package/src/components/FormField/FormField.vue +59 -0
- package/src/components/FormField/index.ts +2 -0
- package/src/components/Grid/Grid.test.ts +73 -0
- package/src/components/Grid/Grid.vue +34 -0
- package/src/components/Grid/index.ts +1 -0
- package/src/components/Input/Input.test.ts +102 -0
- package/src/components/Input/Input.vue +63 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Link/Link.test.ts +92 -0
- package/src/components/Link/Link.vue +35 -0
- package/src/components/Link/index.ts +1 -0
- package/src/components/Nav/Nav.test.ts +171 -0
- package/src/components/Nav/Nav.vue +81 -0
- package/src/components/Nav/index.ts +2 -0
- package/src/components/Progress/Progress.test.ts +103 -0
- package/src/components/Progress/Progress.vue +96 -0
- package/src/components/Progress/index.ts +1 -0
- package/src/components/Radio/Radio.test.ts +92 -0
- package/src/components/Radio/Radio.vue +60 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Section/Section.test.ts +77 -0
- package/src/components/Section/Section.vue +36 -0
- package/src/components/Section/index.ts +1 -0
- package/src/components/Select/Select.test.ts +102 -0
- package/src/components/Select/Select.vue +70 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Skeleton/Skeleton.test.ts +77 -0
- package/src/components/Skeleton/Skeleton.vue +48 -0
- package/src/components/Skeleton/index.ts +1 -0
- package/src/components/Slider/Slider.test.ts +73 -0
- package/src/components/Slider/Slider.vue +60 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Spinner/Spinner.test.ts +66 -0
- package/src/components/Spinner/Spinner.vue +33 -0
- package/src/components/Spinner/index.ts +1 -0
- package/src/components/Stack/Stack.test.ts +140 -0
- package/src/components/Stack/Stack.vue +50 -0
- package/src/components/Stack/index.ts +1 -0
- package/src/components/Switch/Switch.test.ts +116 -0
- package/src/components/Switch/Switch.vue +62 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Table/Table.test.ts +152 -0
- package/src/components/Table/Table.vue +98 -0
- package/src/components/Table/index.ts +2 -0
- package/src/components/Tabs/Tabs.test.ts +138 -0
- package/src/components/Tabs/Tabs.vue +96 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Tag/Tag.test.ts +128 -0
- package/src/components/Tag/Tag.vue +65 -0
- package/src/components/Tag/index.ts +1 -0
- package/src/components/Textarea/Textarea.test.ts +107 -0
- package/src/components/Textarea/Textarea.vue +90 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/Toast/Toast.test.ts +204 -0
- package/src/components/Toast/Toast.vue +48 -0
- package/src/components/Toast/ToastProvider.vue +80 -0
- package/src/components/Toast/index.ts +5 -0
- package/src/components/Toast/useToast.ts +26 -0
- package/src/components/Tooltip/Tooltip.test.ts +145 -0
- package/src/components/Tooltip/Tooltip.vue +79 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/index.ts +44 -0
- 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
|
+
×
|
|
140
|
+
</button>
|
|
141
|
+
<div class="strand-dialog__body">
|
|
142
|
+
<slot />
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</template>
|
|
@@ -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>
|