@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/esm/components/data-grid/data-grid.d.ts +7 -1
  3. package/esm/components/data-grid/data-grid.d.ts.map +1 -1
  4. package/esm/components/data-grid/data-grid.js +1 -1
  5. package/esm/components/data-grid/data-grid.js.map +1 -1
  6. package/esm/components/data-grid/footer.d.ts +1 -0
  7. package/esm/components/data-grid/footer.d.ts.map +1 -1
  8. package/esm/components/data-grid/footer.js +8 -15
  9. package/esm/components/data-grid/footer.js.map +1 -1
  10. package/esm/components/data-grid/footer.spec.js +85 -47
  11. package/esm/components/data-grid/footer.spec.js.map +1 -1
  12. package/esm/components/grid.d.ts +3 -0
  13. package/esm/components/grid.d.ts.map +1 -1
  14. package/esm/components/grid.js +3 -0
  15. package/esm/components/grid.js.map +1 -1
  16. package/esm/components/inputs/autocomplete.d.ts +3 -0
  17. package/esm/components/inputs/autocomplete.d.ts.map +1 -1
  18. package/esm/components/inputs/autocomplete.js +3 -0
  19. package/esm/components/inputs/autocomplete.js.map +1 -1
  20. package/esm/components/list/list.d.ts +10 -0
  21. package/esm/components/list/list.d.ts.map +1 -1
  22. package/esm/components/list/list.js +23 -2
  23. package/esm/components/list/list.js.map +1 -1
  24. package/esm/components/list/list.spec.js +101 -0
  25. package/esm/components/list/list.spec.js.map +1 -1
  26. package/esm/components/markdown/markdown-input.d.ts +14 -0
  27. package/esm/components/markdown/markdown-input.d.ts.map +1 -1
  28. package/esm/components/markdown/markdown-input.js +48 -2
  29. package/esm/components/markdown/markdown-input.js.map +1 -1
  30. package/esm/components/markdown/markdown-input.spec.js +97 -0
  31. package/esm/components/markdown/markdown-input.spec.js.map +1 -1
  32. package/esm/components/suggest/index.d.ts +10 -2
  33. package/esm/components/suggest/index.d.ts.map +1 -1
  34. package/esm/components/suggest/index.js +21 -1
  35. package/esm/components/suggest/index.js.map +1 -1
  36. package/esm/components/suggest/index.spec.js +50 -0
  37. package/esm/components/suggest/index.spec.js.map +1 -1
  38. package/esm/components/wizard/index.d.ts +8 -0
  39. package/esm/components/wizard/index.d.ts.map +1 -1
  40. package/esm/components/wizard/index.js +90 -0
  41. package/esm/components/wizard/index.js.map +1 -1
  42. package/esm/components/wizard/index.spec.js +79 -2
  43. package/esm/components/wizard/index.spec.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/components/data-grid/data-grid.tsx +13 -2
  46. package/src/components/data-grid/footer.spec.tsx +104 -50
  47. package/src/components/data-grid/footer.tsx +25 -31
  48. package/src/components/grid.tsx +3 -0
  49. package/src/components/inputs/autocomplete.tsx +3 -0
  50. package/src/components/list/list.spec.tsx +173 -0
  51. package/src/components/list/list.tsx +56 -19
  52. package/src/components/markdown/markdown-input.spec.tsx +142 -0
  53. package/src/components/markdown/markdown-input.tsx +65 -1
  54. package/src/components/suggest/index.spec.tsx +83 -0
  55. package/src/components/suggest/index.tsx +36 -3
  56. package/src/components/wizard/index.spec.tsx +118 -1
  57. 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
- export interface SuggestProps<T> {
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
- const manager = useDisposable('manager', () => new SuggestManager(props.getEntries, props.getSuggestionEntry))
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', () => manager.subscribe('onSelectSuggestion', props.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: <Wizard steps={steps} onFinish={onFinish} />,
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 }) => {