@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.
- package/CHANGELOG.md +94 -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-editor.d.ts +16 -2
- package/esm/components/markdown/markdown-editor.d.ts.map +1 -1
- package/esm/components/markdown/markdown-editor.js +42 -8
- package/esm/components/markdown/markdown-editor.js.map +1 -1
- package/esm/components/markdown/markdown-editor.spec.js +190 -0
- package/esm/components/markdown/markdown-editor.spec.js.map +1 -1
- package/esm/components/markdown/markdown-input.d.ts +16 -0
- package/esm/components/markdown/markdown-input.d.ts.map +1 -1
- package/esm/components/markdown/markdown-input.js +44 -3
- package/esm/components/markdown/markdown-input.js.map +1 -1
- package/esm/components/markdown/markdown-input.spec.js +140 -0
- package/esm/components/markdown/markdown-input.spec.js.map +1 -1
- package/esm/components/markdown/markdown-validation.d.ts +25 -0
- package/esm/components/markdown/markdown-validation.d.ts.map +1 -0
- package/esm/components/markdown/markdown-validation.js +15 -0
- package/esm/components/markdown/markdown-validation.js.map +1 -0
- 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-editor.spec.tsx +261 -0
- package/src/components/markdown/markdown-editor.tsx +63 -10
- package/src/components/markdown/markdown-input.spec.tsx +205 -0
- package/src/components/markdown/markdown-input.tsx +61 -2
- package/src/components/markdown/markdown-validation.ts +33 -0
- 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
|
@@ -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
|
-
|
|
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 }) => {
|
|
@@ -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}
|