@furystack/shades-common-components 12.5.0 → 12.6.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/CHANGELOG.md +63 -0
- package/esm/components/data-grid/data-grid.d.ts +7 -1
- package/esm/components/data-grid/data-grid.d.ts.map +1 -1
- package/esm/components/data-grid/data-grid.js +1 -1
- package/esm/components/data-grid/data-grid.js.map +1 -1
- package/esm/components/data-grid/footer.d.ts +1 -0
- package/esm/components/data-grid/footer.d.ts.map +1 -1
- package/esm/components/data-grid/footer.js +8 -15
- package/esm/components/data-grid/footer.js.map +1 -1
- package/esm/components/data-grid/footer.spec.js +85 -47
- package/esm/components/data-grid/footer.spec.js.map +1 -1
- package/esm/components/grid.d.ts +3 -0
- package/esm/components/grid.d.ts.map +1 -1
- package/esm/components/grid.js +3 -0
- package/esm/components/grid.js.map +1 -1
- package/esm/components/inputs/autocomplete.d.ts +3 -0
- package/esm/components/inputs/autocomplete.d.ts.map +1 -1
- package/esm/components/inputs/autocomplete.js +3 -0
- package/esm/components/inputs/autocomplete.js.map +1 -1
- package/esm/components/list/list.d.ts +10 -0
- package/esm/components/list/list.d.ts.map +1 -1
- package/esm/components/list/list.js +23 -2
- package/esm/components/list/list.js.map +1 -1
- package/esm/components/list/list.spec.js +101 -0
- package/esm/components/list/list.spec.js.map +1 -1
- package/esm/components/markdown/markdown-input.d.ts +14 -0
- package/esm/components/markdown/markdown-input.d.ts.map +1 -1
- package/esm/components/markdown/markdown-input.js +48 -2
- package/esm/components/markdown/markdown-input.js.map +1 -1
- package/esm/components/markdown/markdown-input.spec.js +97 -0
- package/esm/components/markdown/markdown-input.spec.js.map +1 -1
- package/esm/components/suggest/index.d.ts +10 -2
- package/esm/components/suggest/index.d.ts.map +1 -1
- package/esm/components/suggest/index.js +21 -1
- package/esm/components/suggest/index.js.map +1 -1
- package/esm/components/suggest/index.spec.js +50 -0
- package/esm/components/suggest/index.spec.js.map +1 -1
- package/esm/components/wizard/index.d.ts +8 -0
- package/esm/components/wizard/index.d.ts.map +1 -1
- package/esm/components/wizard/index.js +90 -0
- package/esm/components/wizard/index.js.map +1 -1
- package/esm/components/wizard/index.spec.js +79 -2
- package/esm/components/wizard/index.spec.js.map +1 -1
- package/package.json +3 -3
- package/src/components/data-grid/data-grid.tsx +13 -2
- package/src/components/data-grid/footer.spec.tsx +104 -50
- package/src/components/data-grid/footer.tsx +25 -31
- package/src/components/grid.tsx +3 -0
- package/src/components/inputs/autocomplete.tsx +3 -0
- package/src/components/list/list.spec.tsx +173 -0
- package/src/components/list/list.tsx +56 -19
- package/src/components/markdown/markdown-input.spec.tsx +142 -0
- package/src/components/markdown/markdown-input.tsx +65 -1
- package/src/components/suggest/index.spec.tsx +83 -0
- package/src/components/suggest/index.tsx +36 -3
- package/src/components/wizard/index.spec.tsx +118 -1
- package/src/components/wizard/index.tsx +125 -0
|
@@ -2,6 +2,7 @@ import { Injector } from '@furystack/inject'
|
|
|
2
2
|
import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades'
|
|
3
3
|
import { usingAsync } from '@furystack/utils'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { Form } from '../form.js'
|
|
5
6
|
import { MarkdownInput } from './markdown-input.js'
|
|
6
7
|
|
|
7
8
|
describe('MarkdownInput', () => {
|
|
@@ -160,6 +161,147 @@ describe('MarkdownInput', () => {
|
|
|
160
161
|
})
|
|
161
162
|
})
|
|
162
163
|
|
|
164
|
+
it('should set name attribute on textarea', async () => {
|
|
165
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
166
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
167
|
+
|
|
168
|
+
initializeShadeRoot({
|
|
169
|
+
injector,
|
|
170
|
+
rootElement,
|
|
171
|
+
jsxElement: <MarkdownInput value="" name="description" />,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
await flushUpdates()
|
|
175
|
+
|
|
176
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
177
|
+
expect(textarea.name).toBe('description')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('should set required attribute on textarea', async () => {
|
|
182
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
183
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
184
|
+
|
|
185
|
+
initializeShadeRoot({
|
|
186
|
+
injector,
|
|
187
|
+
rootElement,
|
|
188
|
+
jsxElement: <MarkdownInput value="" required />,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
await flushUpdates()
|
|
192
|
+
|
|
193
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
194
|
+
expect(textarea.required).toBe(true)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('should set data-invalid when required and value is empty', async () => {
|
|
199
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
200
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
201
|
+
|
|
202
|
+
initializeShadeRoot({
|
|
203
|
+
injector,
|
|
204
|
+
rootElement,
|
|
205
|
+
jsxElement: <MarkdownInput value="" required />,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
await flushUpdates()
|
|
209
|
+
|
|
210
|
+
const wrapper = document.querySelector('shade-markdown-input') as HTMLElement
|
|
211
|
+
expect(wrapper.hasAttribute('data-invalid')).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('should not set data-invalid when required and value is provided', async () => {
|
|
216
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
217
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
218
|
+
|
|
219
|
+
initializeShadeRoot({
|
|
220
|
+
injector,
|
|
221
|
+
rootElement,
|
|
222
|
+
jsxElement: <MarkdownInput value="some content" required />,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
await flushUpdates()
|
|
226
|
+
|
|
227
|
+
const wrapper = document.querySelector('shade-markdown-input') as HTMLElement
|
|
228
|
+
expect(wrapper.hasAttribute('data-invalid')).toBe(false)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should show validation error message from getValidationResult', async () => {
|
|
233
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
234
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
235
|
+
|
|
236
|
+
initializeShadeRoot({
|
|
237
|
+
injector,
|
|
238
|
+
rootElement,
|
|
239
|
+
jsxElement: (
|
|
240
|
+
<MarkdownInput
|
|
241
|
+
value="short"
|
|
242
|
+
getValidationResult={({ value }) =>
|
|
243
|
+
value.length < 10 ? { isValid: false, message: 'Too short' } : { isValid: true }
|
|
244
|
+
}
|
|
245
|
+
/>
|
|
246
|
+
),
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
await flushUpdates()
|
|
250
|
+
|
|
251
|
+
const wrapper = document.querySelector('shade-markdown-input') as HTMLElement
|
|
252
|
+
expect(wrapper.hasAttribute('data-invalid')).toBe(true)
|
|
253
|
+
expect(wrapper.textContent).toContain('Too short')
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('should show helper text from getHelperText', async () => {
|
|
258
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
259
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
260
|
+
|
|
261
|
+
initializeShadeRoot({
|
|
262
|
+
injector,
|
|
263
|
+
rootElement,
|
|
264
|
+
jsxElement: <MarkdownInput value="" getHelperText={() => 'Write some markdown here'} />,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
await flushUpdates()
|
|
268
|
+
|
|
269
|
+
const wrapper = document.querySelector('shade-markdown-input') as HTMLElement
|
|
270
|
+
expect(wrapper.textContent).toContain('Write some markdown here')
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('should render with validation inside a Form', async () => {
|
|
275
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
276
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
277
|
+
|
|
278
|
+
initializeShadeRoot({
|
|
279
|
+
injector,
|
|
280
|
+
rootElement,
|
|
281
|
+
jsxElement: (
|
|
282
|
+
<Form onSubmit={() => {}} validate={(_data): _data is { content: string } => true}>
|
|
283
|
+
<MarkdownInput
|
|
284
|
+
value="short"
|
|
285
|
+
name="content"
|
|
286
|
+
getValidationResult={({ value }) =>
|
|
287
|
+
value.length < 10 ? { isValid: false, message: 'Too short' } : { isValid: true }
|
|
288
|
+
}
|
|
289
|
+
/>
|
|
290
|
+
</Form>
|
|
291
|
+
),
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
await flushUpdates()
|
|
295
|
+
|
|
296
|
+
const wrapper = document.querySelector('shade-markdown-input') as HTMLElement
|
|
297
|
+
expect(wrapper.hasAttribute('data-invalid')).toBe(true)
|
|
298
|
+
expect(wrapper.textContent).toContain('Too short')
|
|
299
|
+
|
|
300
|
+
const textarea = wrapper.querySelector('textarea') as HTMLTextAreaElement
|
|
301
|
+
expect(textarea.name).toBe('content')
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
163
305
|
describe('image paste', () => {
|
|
164
306
|
const createPasteEvent = (items: Array<{ type: string; file: File | null }>) => {
|
|
165
307
|
const pasteEvent = new Event('paste', { bubbles: true, cancelable: true })
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Shade, createComponent } from '@furystack/shades'
|
|
2
2
|
import { cssVariableTheme } from '../../services/css-variable-theme.js'
|
|
3
|
+
import type { InputValidationResult } from '../inputs/input.js'
|
|
4
|
+
import { FormService } from '../form.js'
|
|
3
5
|
|
|
4
6
|
const DEFAULT_MAX_IMAGE_SIZE = 256 * 1024
|
|
5
7
|
|
|
@@ -20,6 +22,14 @@ export type MarkdownInputProps = {
|
|
|
20
22
|
labelTitle?: string
|
|
21
23
|
/** Number of visible text rows */
|
|
22
24
|
rows?: number
|
|
25
|
+
/** Form field name for FormService integration */
|
|
26
|
+
name?: string
|
|
27
|
+
/** Whether the field is required */
|
|
28
|
+
required?: boolean
|
|
29
|
+
/** Custom validation callback */
|
|
30
|
+
getValidationResult?: (options: { value: string }) => InputValidationResult
|
|
31
|
+
/** Optional helper text callback */
|
|
32
|
+
getHelperText?: (options: { value: string; validationResult?: InputValidationResult }) => JSX.Element | string
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
/**
|
|
@@ -53,6 +63,11 @@ export const MarkdownInput = Shade<MarkdownInputProps>({
|
|
|
53
63
|
color: cssVariableTheme.palette.primary.main,
|
|
54
64
|
},
|
|
55
65
|
|
|
66
|
+
'&[data-invalid] label': {
|
|
67
|
+
borderColor: cssVariableTheme.palette.error.main,
|
|
68
|
+
color: cssVariableTheme.palette.error.main,
|
|
69
|
+
},
|
|
70
|
+
|
|
56
71
|
'& textarea': {
|
|
57
72
|
border: 'none',
|
|
58
73
|
backgroundColor: 'transparent',
|
|
@@ -71,15 +86,61 @@ export const MarkdownInput = Shade<MarkdownInputProps>({
|
|
|
71
86
|
'&:focus-within textarea': {
|
|
72
87
|
boxShadow: `0px 3px 0px ${cssVariableTheme.palette.primary.main}`,
|
|
73
88
|
},
|
|
89
|
+
|
|
90
|
+
'&[data-invalid]:focus-within textarea': {
|
|
91
|
+
boxShadow: `0px 3px 0px ${cssVariableTheme.palette.error.main}`,
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
'& .helperText': {
|
|
95
|
+
fontSize: cssVariableTheme.typography.fontSize.xs,
|
|
96
|
+
marginTop: '6px',
|
|
97
|
+
opacity: '0.85',
|
|
98
|
+
lineHeight: '1.4',
|
|
99
|
+
},
|
|
74
100
|
},
|
|
75
|
-
render: ({ props, useHostProps, useRef }) => {
|
|
101
|
+
render: ({ props, injector, useDisposable, useHostProps, useRef }) => {
|
|
76
102
|
const maxSize = props.maxImageSizeBytes ?? DEFAULT_MAX_IMAGE_SIZE
|
|
77
103
|
const textareaRef = useRef<HTMLTextAreaElement>('textarea')
|
|
78
104
|
|
|
105
|
+
useDisposable('form-registration', () => {
|
|
106
|
+
const formService = injector.cachedSingletons.has(FormService) ? injector.getInstance(FormService) : null
|
|
107
|
+
if (formService) {
|
|
108
|
+
queueMicrotask(() => {
|
|
109
|
+
if (textareaRef.current) formService.inputs.add(textareaRef.current as unknown as HTMLInputElement)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
[Symbol.dispose]: () => {
|
|
114
|
+
if (textareaRef.current && formService)
|
|
115
|
+
formService.inputs.delete(textareaRef.current as unknown as HTMLInputElement)
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const validationResult = props.getValidationResult?.({ value: props.value })
|
|
121
|
+
const isRequired = props.required && !props.value
|
|
122
|
+
const isInvalid = validationResult?.isValid === false || isRequired
|
|
123
|
+
|
|
124
|
+
if (injector.cachedSingletons.has(FormService) && props.name) {
|
|
125
|
+
const formService = injector.getInstance(FormService)
|
|
126
|
+
const fieldResult: InputValidationResult = isRequired
|
|
127
|
+
? { isValid: false, message: 'Value is required' }
|
|
128
|
+
: validationResult || { isValid: true }
|
|
129
|
+
const validity = textareaRef.current?.validity ?? ({} as ValidityState)
|
|
130
|
+
formService.setFieldState(props.name as keyof unknown, fieldResult, validity)
|
|
131
|
+
}
|
|
132
|
+
|
|
79
133
|
useHostProps({
|
|
80
134
|
'data-disabled': props.disabled ? '' : undefined,
|
|
135
|
+
'data-invalid': isInvalid ? '' : undefined,
|
|
81
136
|
})
|
|
82
137
|
|
|
138
|
+
const helperNode =
|
|
139
|
+
(validationResult?.isValid === false && validationResult?.message) ||
|
|
140
|
+
(isRequired && 'Value is required') ||
|
|
141
|
+
props.getHelperText?.({ value: props.value, validationResult }) ||
|
|
142
|
+
''
|
|
143
|
+
|
|
83
144
|
const handleInput = (ev: Event) => {
|
|
84
145
|
const target = ev.target as HTMLTextAreaElement
|
|
85
146
|
props.onValueChange?.(target.value)
|
|
@@ -129,6 +190,8 @@ export const MarkdownInput = Shade<MarkdownInputProps>({
|
|
|
129
190
|
{props.labelTitle ? <span>{props.labelTitle}</span> : null}
|
|
130
191
|
<textarea
|
|
131
192
|
ref={textareaRef}
|
|
193
|
+
name={props.name}
|
|
194
|
+
required={props.required}
|
|
132
195
|
value={props.value}
|
|
133
196
|
oninput={handleInput}
|
|
134
197
|
onpaste={handlePaste}
|
|
@@ -137,6 +200,7 @@ export const MarkdownInput = Shade<MarkdownInputProps>({
|
|
|
137
200
|
placeholder={props.placeholder}
|
|
138
201
|
rows={props.rows ?? 10}
|
|
139
202
|
/>
|
|
203
|
+
{helperNode ? <span className="helperText">{helperNode}</span> : null}
|
|
140
204
|
</label>
|
|
141
205
|
)
|
|
142
206
|
},
|
|
@@ -882,4 +882,87 @@ describe('Suggest', () => {
|
|
|
882
882
|
})
|
|
883
883
|
})
|
|
884
884
|
})
|
|
885
|
+
|
|
886
|
+
describe('synchronous suggestions mode', () => {
|
|
887
|
+
it('should render with string[] suggestions', async () => {
|
|
888
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
889
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
890
|
+
const onSelectSuggestion = vi.fn()
|
|
891
|
+
|
|
892
|
+
initializeShadeRoot({
|
|
893
|
+
injector,
|
|
894
|
+
rootElement,
|
|
895
|
+
jsxElement: (
|
|
896
|
+
<Suggest
|
|
897
|
+
defaultPrefix="🔍"
|
|
898
|
+
suggestions={['Apple', 'Banana', 'Cherry']}
|
|
899
|
+
onSelectSuggestion={onSelectSuggestion}
|
|
900
|
+
/>
|
|
901
|
+
),
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
await advanceTimers(50)
|
|
905
|
+
|
|
906
|
+
const suggest = document.querySelector('shade-suggest') as HTMLElement
|
|
907
|
+
expect(suggest).not.toBeNull()
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
it('should render input in sync mode', async () => {
|
|
912
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
913
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
914
|
+
const onSelectSuggestion = vi.fn()
|
|
915
|
+
|
|
916
|
+
initializeShadeRoot({
|
|
917
|
+
injector,
|
|
918
|
+
rootElement,
|
|
919
|
+
jsxElement: (
|
|
920
|
+
<Suggest
|
|
921
|
+
defaultPrefix="🔍"
|
|
922
|
+
suggestions={['Apple', 'Banana', 'Cherry']}
|
|
923
|
+
onSelectSuggestion={onSelectSuggestion}
|
|
924
|
+
/>
|
|
925
|
+
),
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
await advanceTimers(50)
|
|
929
|
+
|
|
930
|
+
const suggest = document.querySelector('shade-suggest') as HTMLElement
|
|
931
|
+
const input = suggest?.querySelector('input')
|
|
932
|
+
expect(input).not.toBeNull()
|
|
933
|
+
})
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('should show filtered suggestions in sync mode', async () => {
|
|
937
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
938
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
939
|
+
const onSelectSuggestion = vi.fn()
|
|
940
|
+
|
|
941
|
+
initializeShadeRoot({
|
|
942
|
+
injector,
|
|
943
|
+
rootElement,
|
|
944
|
+
jsxElement: (
|
|
945
|
+
<Suggest
|
|
946
|
+
defaultPrefix="🔍"
|
|
947
|
+
suggestions={['Apple', 'Apricot', 'Banana']}
|
|
948
|
+
onSelectSuggestion={onSelectSuggestion}
|
|
949
|
+
/>
|
|
950
|
+
),
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
await advanceTimers(50)
|
|
954
|
+
|
|
955
|
+
const suggest = document.querySelector('shade-suggest') as HTMLElement
|
|
956
|
+
const input = suggest?.querySelector('input') as HTMLInputElement
|
|
957
|
+
|
|
958
|
+
input.value = 'ap'
|
|
959
|
+
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'p', bubbles: true }))
|
|
960
|
+
|
|
961
|
+
await advanceTimers(300)
|
|
962
|
+
|
|
963
|
+
const suggestionItems = suggest?.querySelectorAll('.suggestion-item')
|
|
964
|
+
expect(suggestionItems?.length).toBe(2)
|
|
965
|
+
})
|
|
966
|
+
})
|
|
967
|
+
})
|
|
885
968
|
})
|
|
@@ -15,7 +15,7 @@ export * from './suggest-manager.js'
|
|
|
15
15
|
export * from './suggestion-list.js'
|
|
16
16
|
export * from './suggestion-result.js'
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
type SuggestAsyncProps<T> = {
|
|
19
19
|
defaultPrefix: JSX.Element | string
|
|
20
20
|
getEntries: (term: string) => Promise<T[]>
|
|
21
21
|
getSuggestionEntry: (entry: T) => SuggestionResult
|
|
@@ -23,6 +23,20 @@ export interface SuggestProps<T> {
|
|
|
23
23
|
style?: Partial<CSSStyleDeclaration>
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
type SuggestSyncProps = {
|
|
27
|
+
defaultPrefix: JSX.Element | string
|
|
28
|
+
/** Static list of string suggestions. Filtered client-side by the search term. When the term is empty, all suggestions are shown. */
|
|
29
|
+
suggestions: string[]
|
|
30
|
+
onSelectSuggestion: (entry: string) => void
|
|
31
|
+
style?: Partial<CSSStyleDeclaration>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SuggestProps<T> = SuggestAsyncProps<T> | SuggestSyncProps
|
|
35
|
+
|
|
36
|
+
const isSyncProps = (props: SuggestProps<unknown>): props is SuggestSyncProps => {
|
|
37
|
+
return 'suggestions' in props
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX.Element<any> = Shade<
|
|
27
41
|
SuggestProps<any>
|
|
28
42
|
>({
|
|
@@ -35,7 +49,24 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
|
|
|
35
49
|
},
|
|
36
50
|
},
|
|
37
51
|
render: ({ props, injector, useDisposable, useRef, useHostProps, useObservable }) => {
|
|
38
|
-
|
|
52
|
+
let getEntries: (term: string) => Promise<unknown[]>
|
|
53
|
+
let getSuggestionEntry: (entry: unknown) => SuggestionResult
|
|
54
|
+
|
|
55
|
+
if (isSyncProps(props)) {
|
|
56
|
+
const { suggestions } = props
|
|
57
|
+
getEntries = async (term: string) => {
|
|
58
|
+
const lower = term.toLowerCase()
|
|
59
|
+
return suggestions.filter((s) => s.toLowerCase().includes(lower))
|
|
60
|
+
}
|
|
61
|
+
getSuggestionEntry = (entry: unknown) => ({
|
|
62
|
+
element: <>{entry as string}</>,
|
|
63
|
+
score: 1,
|
|
64
|
+
})
|
|
65
|
+
} else {
|
|
66
|
+
;({ getEntries, getSuggestionEntry } = props)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const manager = useDisposable('manager', () => new SuggestManager(getEntries, getSuggestionEntry))
|
|
39
70
|
const wrapperRef = useRef<HTMLDivElement>('wrapper')
|
|
40
71
|
const loaderRef = useRef<HTMLSpanElement>('loader')
|
|
41
72
|
|
|
@@ -65,7 +96,9 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
|
|
|
65
96
|
})
|
|
66
97
|
}
|
|
67
98
|
})
|
|
68
|
-
useDisposable('onSelectSuggestion', () =>
|
|
99
|
+
useDisposable('onSelectSuggestion', () =>
|
|
100
|
+
manager.subscribe('onSelectSuggestion', props.onSelectSuggestion as (entry: unknown) => void),
|
|
101
|
+
)
|
|
69
102
|
return (
|
|
70
103
|
<div
|
|
71
104
|
ref={wrapperRef}
|
|
@@ -72,13 +72,21 @@ describe('Wizard', () => {
|
|
|
72
72
|
const renderWizard = async (
|
|
73
73
|
steps: Array<(props: WizardStepProps, children: ChildrenList) => JSX.Element>,
|
|
74
74
|
onFinish?: () => void,
|
|
75
|
+
options?: { stepLabels?: string[]; showProgress?: boolean },
|
|
75
76
|
) => {
|
|
76
77
|
const injector = new Injector()
|
|
77
78
|
const root = document.getElementById('root')!
|
|
78
79
|
initializeShadeRoot({
|
|
79
80
|
injector,
|
|
80
81
|
rootElement: root,
|
|
81
|
-
jsxElement:
|
|
82
|
+
jsxElement: (
|
|
83
|
+
<Wizard
|
|
84
|
+
steps={steps}
|
|
85
|
+
onFinish={onFinish}
|
|
86
|
+
stepLabels={options?.stepLabels}
|
|
87
|
+
showProgress={options?.showProgress}
|
|
88
|
+
/>
|
|
89
|
+
),
|
|
82
90
|
})
|
|
83
91
|
await flushUpdates()
|
|
84
92
|
|
|
@@ -215,6 +223,115 @@ describe('Wizard', () => {
|
|
|
215
223
|
})
|
|
216
224
|
})
|
|
217
225
|
|
|
226
|
+
describe('step indicator', () => {
|
|
227
|
+
it('should not render step indicator when stepLabels is not provided', async () => {
|
|
228
|
+
await usingAsync(await renderWizard([Step1, Step2]), async ({ wizard }) => {
|
|
229
|
+
const indicator = wizard.querySelector('[data-testid="wizard-step-indicator"]')
|
|
230
|
+
expect(indicator).toBeNull()
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('should render step indicator when stepLabels is provided', async () => {
|
|
235
|
+
await usingAsync(
|
|
236
|
+
await renderWizard([Step1, Step2, Step3], undefined, {
|
|
237
|
+
stepLabels: ['First', 'Second', 'Third'],
|
|
238
|
+
}),
|
|
239
|
+
async ({ wizard }) => {
|
|
240
|
+
const indicator = wizard.querySelector('[data-testid="wizard-step-indicator"]')
|
|
241
|
+
expect(indicator).toBeTruthy()
|
|
242
|
+
|
|
243
|
+
const circles = indicator?.querySelectorAll('.wizard-step-circle')
|
|
244
|
+
expect(circles?.length).toBe(3)
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should show step labels', async () => {
|
|
250
|
+
await usingAsync(
|
|
251
|
+
await renderWizard([Step1, Step2], undefined, {
|
|
252
|
+
stepLabels: ['Setup', 'Confirm'],
|
|
253
|
+
}),
|
|
254
|
+
async ({ wizard }) => {
|
|
255
|
+
const labels = wizard.querySelectorAll('.wizard-step-label')
|
|
256
|
+
expect(labels[0]?.textContent).toBe('Setup')
|
|
257
|
+
expect(labels[1]?.textContent).toBe('Confirm')
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('should mark the current step as active', async () => {
|
|
263
|
+
await usingAsync(
|
|
264
|
+
await renderWizard([Step1, Step2, Step3], undefined, {
|
|
265
|
+
stepLabels: ['A', 'B', 'C'],
|
|
266
|
+
}),
|
|
267
|
+
async ({ wizard }) => {
|
|
268
|
+
const circles = wizard.querySelectorAll('.wizard-step-circle')
|
|
269
|
+
expect(circles[0]?.hasAttribute('data-active')).toBe(true)
|
|
270
|
+
expect(circles[1]?.hasAttribute('data-active')).toBe(false)
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should update active step on navigation', async () => {
|
|
276
|
+
await usingAsync(
|
|
277
|
+
await renderWizard([Step1, Step2, Step3], undefined, {
|
|
278
|
+
stepLabels: ['A', 'B', 'C'],
|
|
279
|
+
}),
|
|
280
|
+
async ({ wizard, clickNext }) => {
|
|
281
|
+
await clickNext()
|
|
282
|
+
const circles = wizard.querySelectorAll('.wizard-step-circle')
|
|
283
|
+
expect(circles[0]?.hasAttribute('data-completed')).toBe(true)
|
|
284
|
+
expect(circles[1]?.hasAttribute('data-active')).toBe(true)
|
|
285
|
+
expect(circles[2]?.hasAttribute('data-active')).toBe(false)
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('progress bar', () => {
|
|
292
|
+
it('should not render progress bar when showProgress is false', async () => {
|
|
293
|
+
await usingAsync(await renderWizard([Step1, Step2]), async ({ wizard }) => {
|
|
294
|
+
const progressBar = wizard.querySelector('[data-testid="wizard-progress-bar"]')
|
|
295
|
+
expect(progressBar).toBeNull()
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should render progress bar when showProgress is true', async () => {
|
|
300
|
+
await usingAsync(
|
|
301
|
+
await renderWizard([Step1, Step2, Step3], undefined, { showProgress: true }),
|
|
302
|
+
async ({ wizard }) => {
|
|
303
|
+
const progressBar = wizard.querySelector('[data-testid="wizard-progress-bar"]')
|
|
304
|
+
expect(progressBar).toBeTruthy()
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('should start at 0% on first step', async () => {
|
|
310
|
+
await usingAsync(
|
|
311
|
+
await renderWizard([Step1, Step2, Step3], undefined, { showProgress: true }),
|
|
312
|
+
async ({ wizard }) => {
|
|
313
|
+
const fill = wizard.querySelector('.wizard-progress-fill') as HTMLElement
|
|
314
|
+
expect(fill?.style.width).toBe('0%')
|
|
315
|
+
},
|
|
316
|
+
)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('should update progress on navigation', async () => {
|
|
320
|
+
await usingAsync(
|
|
321
|
+
await renderWizard([Step1, Step2, Step3], undefined, { showProgress: true }),
|
|
322
|
+
async ({ wizard, clickNext }) => {
|
|
323
|
+
await clickNext()
|
|
324
|
+
const fill = wizard.querySelector('.wizard-progress-fill') as HTMLElement
|
|
325
|
+
expect(fill?.style.width).toBe('50%')
|
|
326
|
+
|
|
327
|
+
await clickNext()
|
|
328
|
+
const fill2 = wizard.querySelector('.wizard-progress-fill') as HTMLElement
|
|
329
|
+
expect(fill2?.style.width).toBe('100%')
|
|
330
|
+
},
|
|
331
|
+
)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
218
335
|
describe('Paper container', () => {
|
|
219
336
|
it('should render step inside a Paper component', async () => {
|
|
220
337
|
await usingAsync(await renderWizard([Step1]), async ({ wizard }) => {
|