@furystack/shades-common-components 12.5.0 → 12.7.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 (70) hide show
  1. package/CHANGELOG.md +94 -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-editor.d.ts +16 -2
  27. package/esm/components/markdown/markdown-editor.d.ts.map +1 -1
  28. package/esm/components/markdown/markdown-editor.js +42 -8
  29. package/esm/components/markdown/markdown-editor.js.map +1 -1
  30. package/esm/components/markdown/markdown-editor.spec.js +190 -0
  31. package/esm/components/markdown/markdown-editor.spec.js.map +1 -1
  32. package/esm/components/markdown/markdown-input.d.ts +16 -0
  33. package/esm/components/markdown/markdown-input.d.ts.map +1 -1
  34. package/esm/components/markdown/markdown-input.js +44 -3
  35. package/esm/components/markdown/markdown-input.js.map +1 -1
  36. package/esm/components/markdown/markdown-input.spec.js +140 -0
  37. package/esm/components/markdown/markdown-input.spec.js.map +1 -1
  38. package/esm/components/markdown/markdown-validation.d.ts +25 -0
  39. package/esm/components/markdown/markdown-validation.d.ts.map +1 -0
  40. package/esm/components/markdown/markdown-validation.js +15 -0
  41. package/esm/components/markdown/markdown-validation.js.map +1 -0
  42. package/esm/components/suggest/index.d.ts +10 -2
  43. package/esm/components/suggest/index.d.ts.map +1 -1
  44. package/esm/components/suggest/index.js +21 -1
  45. package/esm/components/suggest/index.js.map +1 -1
  46. package/esm/components/suggest/index.spec.js +50 -0
  47. package/esm/components/suggest/index.spec.js.map +1 -1
  48. package/esm/components/wizard/index.d.ts +8 -0
  49. package/esm/components/wizard/index.d.ts.map +1 -1
  50. package/esm/components/wizard/index.js +90 -0
  51. package/esm/components/wizard/index.js.map +1 -1
  52. package/esm/components/wizard/index.spec.js +79 -2
  53. package/esm/components/wizard/index.spec.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/components/data-grid/data-grid.tsx +13 -2
  56. package/src/components/data-grid/footer.spec.tsx +104 -50
  57. package/src/components/data-grid/footer.tsx +25 -31
  58. package/src/components/grid.tsx +3 -0
  59. package/src/components/inputs/autocomplete.tsx +3 -0
  60. package/src/components/list/list.spec.tsx +173 -0
  61. package/src/components/list/list.tsx +56 -19
  62. package/src/components/markdown/markdown-editor.spec.tsx +261 -0
  63. package/src/components/markdown/markdown-editor.tsx +63 -10
  64. package/src/components/markdown/markdown-input.spec.tsx +205 -0
  65. package/src/components/markdown/markdown-input.tsx +61 -2
  66. package/src/components/markdown/markdown-validation.ts +33 -0
  67. package/src/components/suggest/index.spec.tsx +83 -0
  68. package/src/components/suggest/index.tsx +36 -3
  69. package/src/components/wizard/index.spec.tsx +118 -1
  70. package/src/components/wizard/index.tsx +125 -0
@@ -1,5 +1,8 @@
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'
5
+ import { resolveValidationState } from './markdown-validation.js'
3
6
 
4
7
  const DEFAULT_MAX_IMAGE_SIZE = 256 * 1024
5
8
 
@@ -20,6 +23,16 @@ export type MarkdownInputProps = {
20
23
  labelTitle?: string
21
24
  /** Number of visible text rows */
22
25
  rows?: number
26
+ /** Form field name for FormService integration */
27
+ name?: string
28
+ /** Whether the field is required */
29
+ required?: boolean
30
+ /** Custom validation callback */
31
+ getValidationResult?: (options: { value: string }) => InputValidationResult
32
+ /** Optional helper text callback */
33
+ getHelperText?: (options: { value: string; validationResult?: InputValidationResult }) => JSX.Element | string
34
+ /** When true, suppresses visual label and helper text rendering while keeping form mechanics */
35
+ hideChrome?: boolean
23
36
  }
24
37
 
25
38
  /**
@@ -53,6 +66,11 @@ export const MarkdownInput = Shade<MarkdownInputProps>({
53
66
  color: cssVariableTheme.palette.primary.main,
54
67
  },
55
68
 
69
+ '&[data-invalid] label': {
70
+ borderColor: cssVariableTheme.palette.error.main,
71
+ color: cssVariableTheme.palette.error.main,
72
+ },
73
+
56
74
  '& textarea': {
57
75
  border: 'none',
58
76
  backgroundColor: 'transparent',
@@ -71,13 +89,51 @@ export const MarkdownInput = Shade<MarkdownInputProps>({
71
89
  '&:focus-within textarea': {
72
90
  boxShadow: `0px 3px 0px ${cssVariableTheme.palette.primary.main}`,
73
91
  },
92
+
93
+ '&[data-invalid]:focus-within textarea': {
94
+ boxShadow: `0px 3px 0px ${cssVariableTheme.palette.error.main}`,
95
+ },
96
+
97
+ '& .helperText': {
98
+ fontSize: cssVariableTheme.typography.fontSize.xs,
99
+ marginTop: '6px',
100
+ opacity: '0.85',
101
+ lineHeight: '1.4',
102
+ },
74
103
  },
75
- render: ({ props, useHostProps, useRef }) => {
104
+ render: ({ props, injector, useDisposable, useHostProps, useRef }) => {
76
105
  const maxSize = props.maxImageSizeBytes ?? DEFAULT_MAX_IMAGE_SIZE
77
106
  const textareaRef = useRef<HTMLTextAreaElement>('textarea')
78
107
 
108
+ useDisposable('form-registration', () => {
109
+ const formService = injector.cachedSingletons.has(FormService) ? injector.getInstance(FormService) : null
110
+ if (formService) {
111
+ queueMicrotask(() => {
112
+ if (textareaRef.current) formService.inputs.add(textareaRef.current as unknown as HTMLInputElement)
113
+ })
114
+ }
115
+ return {
116
+ [Symbol.dispose]: () => {
117
+ if (textareaRef.current && formService)
118
+ formService.inputs.delete(textareaRef.current as unknown as HTMLInputElement)
119
+ },
120
+ }
121
+ })
122
+
123
+ const { validationResult, isRequired, isInvalid, helperNode } = resolveValidationState(props)
124
+
125
+ if (injector.cachedSingletons.has(FormService) && props.name) {
126
+ const formService = injector.getInstance(FormService)
127
+ const fieldResult: InputValidationResult = isRequired
128
+ ? { isValid: false, message: 'Value is required' }
129
+ : validationResult || { isValid: true }
130
+ const validity = textareaRef.current?.validity ?? ({} as ValidityState)
131
+ formService.setFieldState(props.name as keyof unknown, fieldResult, validity)
132
+ }
133
+
79
134
  useHostProps({
80
135
  'data-disabled': props.disabled ? '' : undefined,
136
+ 'data-invalid': isInvalid ? '' : undefined,
81
137
  })
82
138
 
83
139
  const handleInput = (ev: Event) => {
@@ -126,9 +182,11 @@ export const MarkdownInput = Shade<MarkdownInputProps>({
126
182
 
127
183
  return (
128
184
  <label>
129
- {props.labelTitle ? <span>{props.labelTitle}</span> : null}
185
+ {!props.hideChrome && props.labelTitle ? <span>{props.labelTitle}</span> : null}
130
186
  <textarea
131
187
  ref={textareaRef}
188
+ name={props.name}
189
+ required={props.required}
132
190
  value={props.value}
133
191
  oninput={handleInput}
134
192
  onpaste={handlePaste}
@@ -137,6 +195,7 @@ export const MarkdownInput = Shade<MarkdownInputProps>({
137
195
  placeholder={props.placeholder}
138
196
  rows={props.rows ?? 10}
139
197
  />
198
+ {!props.hideChrome && helperNode ? <span className="helperText">{helperNode}</span> : null}
140
199
  </label>
141
200
  )
142
201
  },
@@ -0,0 +1,33 @@
1
+ import type { InputValidationResult } from '../inputs/input.js'
2
+
3
+ type ValidationOptions = {
4
+ value: string
5
+ required?: boolean
6
+ getValidationResult?: (options: { value: string }) => InputValidationResult
7
+ getHelperText?: (options: { value: string; validationResult?: InputValidationResult }) => JSX.Element | string
8
+ }
9
+
10
+ export type ValidationState = {
11
+ validationResult: InputValidationResult | undefined
12
+ isRequired: boolean
13
+ isInvalid: boolean
14
+ helperNode: JSX.Element | string
15
+ }
16
+
17
+ /**
18
+ * Computes validation state from common markdown field props.
19
+ * Shared between MarkdownInput and MarkdownEditor to keep the logic in one place.
20
+ */
21
+ export const resolveValidationState = (options: ValidationOptions): ValidationState => {
22
+ const validationResult = options.getValidationResult?.({ value: options.value })
23
+ const isRequired = !!options.required && !options.value
24
+ const isInvalid = validationResult?.isValid === false || isRequired
25
+
26
+ const helperNode =
27
+ (validationResult?.isValid === false && validationResult?.message) ||
28
+ (isRequired && 'Value is required') ||
29
+ options.getHelperText?.({ value: options.value, validationResult }) ||
30
+ ''
31
+
32
+ return { validationResult, isRequired, isInvalid, helperNode }
33
+ }
@@ -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 }) => {
@@ -1,5 +1,6 @@
1
1
  import type { ChildrenList } from '@furystack/shades'
2
2
  import { createComponent, Shade } from '@furystack/shades'
3
+ import { cssVariableTheme } from '../../services/css-variable-theme.js'
3
4
  import { Paper } from '../paper.js'
4
5
 
5
6
  export interface WizardStepProps {
@@ -30,6 +31,14 @@ export interface WizardProps {
30
31
  * A callback that will be executed when the wizard is completed
31
32
  */
32
33
  onFinish?: () => void
34
+ /**
35
+ * Optional labels for each step. When provided, a step indicator is shown above the content.
36
+ */
37
+ stepLabels?: string[]
38
+ /**
39
+ * When true, a progress bar is shown above the content.
40
+ */
41
+ showProgress?: boolean
33
42
  }
34
43
 
35
44
  export const Wizard = Shade<WizardProps>({
@@ -42,15 +51,131 @@ export const Wizard = Shade<WizardProps>({
42
51
  width: '100%',
43
52
  height: '100%',
44
53
  },
54
+ '& .wizard-step-indicator': {
55
+ display: 'flex',
56
+ alignItems: 'center',
57
+ justifyContent: 'center',
58
+ padding: '16px 24px 8px',
59
+ gap: '0',
60
+ },
61
+ '& .wizard-step-node': {
62
+ display: 'flex',
63
+ flexDirection: 'column',
64
+ alignItems: 'center',
65
+ gap: '4px',
66
+ zIndex: '1',
67
+ minWidth: '32px',
68
+ },
69
+ '& .wizard-step-circle': {
70
+ width: '28px',
71
+ height: '28px',
72
+ borderRadius: '50%',
73
+ display: 'flex',
74
+ alignItems: 'center',
75
+ justifyContent: 'center',
76
+ fontSize: cssVariableTheme.typography.fontSize.xs,
77
+ fontWeight: cssVariableTheme.typography.fontWeight.semibold,
78
+ border: `2px solid ${cssVariableTheme.action.subtleBorder}`,
79
+ background: cssVariableTheme.background.default,
80
+ color: cssVariableTheme.text.secondary,
81
+ transition: `all ${cssVariableTheme.transitions.duration.normal} ease`,
82
+ },
83
+ '& .wizard-step-circle[data-active]': {
84
+ borderColor: cssVariableTheme.palette.primary.main,
85
+ background: cssVariableTheme.palette.primary.main,
86
+ color: cssVariableTheme.palette.primary.mainContrast,
87
+ },
88
+ '& .wizard-step-circle[data-completed]': {
89
+ borderColor: cssVariableTheme.palette.primary.main,
90
+ background: cssVariableTheme.palette.primary.main,
91
+ color: cssVariableTheme.palette.primary.mainContrast,
92
+ opacity: '0.7',
93
+ },
94
+ '& .wizard-step-label': {
95
+ fontSize: cssVariableTheme.typography.fontSize.xs,
96
+ color: cssVariableTheme.text.secondary,
97
+ textAlign: 'center',
98
+ maxWidth: '80px',
99
+ overflow: 'hidden',
100
+ textOverflow: 'ellipsis',
101
+ whiteSpace: 'nowrap',
102
+ },
103
+ '& .wizard-step-label[data-active]': {
104
+ color: cssVariableTheme.palette.primary.main,
105
+ fontWeight: cssVariableTheme.typography.fontWeight.semibold,
106
+ },
107
+ '& .wizard-step-connector': {
108
+ flex: '1',
109
+ height: '2px',
110
+ background: cssVariableTheme.action.subtleBorder,
111
+ minWidth: '24px',
112
+ alignSelf: 'flex-start',
113
+ marginTop: '14px',
114
+ transition: `background ${cssVariableTheme.transitions.duration.normal} ease`,
115
+ },
116
+ '& .wizard-step-connector[data-completed]': {
117
+ background: cssVariableTheme.palette.primary.main,
118
+ },
119
+ '& .wizard-progress-bar': {
120
+ height: '4px',
121
+ background: cssVariableTheme.action.subtleBorder,
122
+ margin: '12px 24px 4px',
123
+ borderRadius: '2px',
124
+ overflow: 'hidden',
125
+ },
126
+ '& .wizard-progress-fill': {
127
+ height: '100%',
128
+ background: cssVariableTheme.palette.primary.main,
129
+ borderRadius: '2px',
130
+ transition: `width ${cssVariableTheme.transitions.duration.normal} ease`,
131
+ },
45
132
  },
46
133
  render: ({ props, useState }) => {
47
134
  const [currentPage, setCurrentPage] = useState('currentPage', 0)
48
135
 
136
+ if (props.stepLabels && props.stepLabels.length !== props.steps.length) {
137
+ console.warn(
138
+ `[Wizard] stepLabels length (${props.stepLabels.length}) does not match steps length (${props.steps.length}).`,
139
+ )
140
+ }
141
+
49
142
  const CurrentPage = props.steps[currentPage]
143
+ const progressPercent = props.steps.length > 1 ? (currentPage / (props.steps.length - 1)) * 100 : 100
50
144
 
51
145
  return (
52
146
  <div className="wizard-container">
53
147
  <Paper style={{ maxWidth: '100%', maxHeight: '100%' }} elevation={3} onclick={(ev) => ev.stopPropagation()}>
148
+ {props.stepLabels && props.stepLabels.length > 0 && (
149
+ <div className="wizard-step-indicator" data-testid="wizard-step-indicator">
150
+ {props.steps.map((_, index) => (
151
+ <>
152
+ {index > 0 && (
153
+ <div
154
+ className="wizard-step-connector"
155
+ {...(index <= currentPage ? { 'data-completed': '' } : {})}
156
+ />
157
+ )}
158
+ <div className="wizard-step-node">
159
+ <div
160
+ className="wizard-step-circle"
161
+ {...(index === currentPage ? { 'data-active': '' } : {})}
162
+ {...(index < currentPage ? { 'data-completed': '' } : {})}
163
+ >
164
+ {(index + 1).toString()}
165
+ </div>
166
+ <span className="wizard-step-label" {...(index === currentPage ? { 'data-active': '' } : {})}>
167
+ {props.stepLabels?.[index] ?? ''}
168
+ </span>
169
+ </div>
170
+ </>
171
+ ))}
172
+ </div>
173
+ )}
174
+ {props.showProgress && (
175
+ <div className="wizard-progress-bar" data-testid="wizard-progress-bar">
176
+ <div className="wizard-progress-fill" style={{ width: `${progressPercent}%` }} />
177
+ </div>
178
+ )}
54
179
  <CurrentPage
55
180
  currentPage={currentPage}
56
181
  maxPages={props.steps.length}