@open-mercato/ui 0.8.1-develop.7452.1.5e211edd51 → 0.8.1-develop.7455.1.8f7e53badb
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/CrudForm.js +17 -7
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +11 -0
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +17 -5
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +27 -7
- package/src/backend/__tests__/CrudForm.fieldChangeRenders.test.tsx +127 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -0
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +29 -0
- package/src/backend/inputs/PhoneNumberField.tsx +17 -11
- package/src/backend/inputs/__tests__/PhoneNumberField.renders.test.tsx +35 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
const triggerEventMock = jest.fn()
|
|
3
|
+
|
|
4
|
+
jest.mock('next/navigation', () => ({
|
|
5
|
+
useRouter: () => ({ push: jest.fn() }),
|
|
6
|
+
usePathname: () => '/',
|
|
7
|
+
useSearchParams: () => new URLSearchParams(),
|
|
8
|
+
}))
|
|
9
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
10
|
+
jest.mock('../confirm-dialog', () => ({
|
|
11
|
+
useConfirmDialog: () => ({ confirm: jest.fn(), ConfirmDialogElement: null }),
|
|
12
|
+
}))
|
|
13
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
14
|
+
__esModule: true,
|
|
15
|
+
InjectionSpot: () => null,
|
|
16
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
17
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
18
|
+
}))
|
|
19
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const mockControlRender = jest.fn()
|
|
25
|
+
jest.mock('../../primitives/textarea', () => {
|
|
26
|
+
const actual = jest.requireActual('../../primitives/textarea')
|
|
27
|
+
const ReactActual = jest.requireActual('react')
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
Textarea: ReactActual.forwardRef((props: Record<string, unknown>, ref: unknown) => {
|
|
31
|
+
mockControlRender('textarea')
|
|
32
|
+
return ReactActual.createElement(actual.Textarea, { ...props, ref })
|
|
33
|
+
}),
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
jest.mock('../../primitives/checkbox', () => {
|
|
37
|
+
const actual = jest.requireActual('../../primitives/checkbox')
|
|
38
|
+
const ReactActual = jest.requireActual('react')
|
|
39
|
+
return {
|
|
40
|
+
...actual,
|
|
41
|
+
Checkbox: ReactActual.forwardRef((props: Record<string, unknown>, ref: unknown) => {
|
|
42
|
+
mockControlRender('checkbox')
|
|
43
|
+
return ReactActual.createElement(actual.Checkbox, { ...props, ref })
|
|
44
|
+
}),
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
import * as React from 'react'
|
|
49
|
+
import { act, fireEvent, screen } from '@testing-library/react'
|
|
50
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
51
|
+
import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
|
|
52
|
+
|
|
53
|
+
describe('CrudForm field-change render cost', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
triggerEventMock.mockReset()
|
|
56
|
+
triggerEventMock.mockImplementation(async (_event: string, data: Record<string, unknown>) => ({ ok: true, data }))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('renders the form once per steady-state edit when no widget answers the field-change event', async () => {
|
|
60
|
+
const groupRender = jest.fn()
|
|
61
|
+
const groups: CrudFormGroup[] = [{
|
|
62
|
+
id: 'details',
|
|
63
|
+
bare: true,
|
|
64
|
+
component: ({ values, setValue }) => {
|
|
65
|
+
groupRender()
|
|
66
|
+
return <input aria-label="Draft title" value={String(values.title ?? '')} onChange={(event) => setValue('title', event.target.value)} />
|
|
67
|
+
},
|
|
68
|
+
}]
|
|
69
|
+
renderWithProviders(
|
|
70
|
+
<CrudForm embedded fields={[]} groups={groups} initialValues={{ title: '' }} injectionSpotId="example:field-change" onSubmit={() => {}} />,
|
|
71
|
+
)
|
|
72
|
+
await act(async () => { await Promise.resolve() })
|
|
73
|
+
const rendersPerEdit: number[] = []
|
|
74
|
+
for (const value of ['a', 'ab', 'abc', 'abcd']) {
|
|
75
|
+
groupRender.mockClear()
|
|
76
|
+
triggerEventMock.mockClear()
|
|
77
|
+
await act(async () => {
|
|
78
|
+
fireEvent.change(screen.getByLabelText('Draft title'), { target: { value } })
|
|
79
|
+
await Promise.resolve()
|
|
80
|
+
await Promise.resolve()
|
|
81
|
+
})
|
|
82
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
83
|
+
expect(screen.getByLabelText('Draft title')).toHaveValue(value)
|
|
84
|
+
expect(triggerEventMock.mock.calls.filter(([event]) => event === 'onFieldChange')).toHaveLength(1)
|
|
85
|
+
rendersPerEdit.push(groupRender.mock.calls.length)
|
|
86
|
+
}
|
|
87
|
+
expect(rendersPerEdit.slice(1)).toEqual([1, 1, 1])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('leaves the other fields alone while one field is being typed in', async () => {
|
|
91
|
+
const fields: CrudField[] = [
|
|
92
|
+
{ id: 'title', label: 'Title', type: 'text', required: true },
|
|
93
|
+
{ id: 'notes', label: 'Notes', type: 'textarea' },
|
|
94
|
+
{ id: 'flag', label: 'Flag', type: 'checkbox' },
|
|
95
|
+
]
|
|
96
|
+
const rendered = renderWithProviders(
|
|
97
|
+
<CrudForm title="Form" fields={fields} initialValues={{ title: '', notes: 'keep', flag: false }} injectionSpotId="example:field-change" onSubmit={() => {}} />,
|
|
98
|
+
{ dict: { 'ui.forms.actions.save': 'Save', 'ui.forms.errors.required': 'Required' } },
|
|
99
|
+
)
|
|
100
|
+
const input = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
101
|
+
const edit = async (value: string) => {
|
|
102
|
+
await act(async () => {
|
|
103
|
+
fireEvent.change(input, { target: { value } })
|
|
104
|
+
await Promise.resolve()
|
|
105
|
+
})
|
|
106
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
107
|
+
}
|
|
108
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
109
|
+
expect(mockControlRender).toHaveBeenCalledWith('textarea')
|
|
110
|
+
expect(mockControlRender).toHaveBeenCalledWith('checkbox')
|
|
111
|
+
await edit('a')
|
|
112
|
+
mockControlRender.mockClear()
|
|
113
|
+
await edit('ab')
|
|
114
|
+
await edit('abc')
|
|
115
|
+
expect(input).toHaveValue('abc')
|
|
116
|
+
expect(mockControlRender).not.toHaveBeenCalled()
|
|
117
|
+
|
|
118
|
+
// The stable blur callback must still validate against the latest values.
|
|
119
|
+
await act(async () => { fireEvent.blur(input) })
|
|
120
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
121
|
+
expect(screen.queryByText('Required')).not.toBeInTheDocument()
|
|
122
|
+
await edit('')
|
|
123
|
+
await act(async () => { fireEvent.blur(input) })
|
|
124
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
125
|
+
expect(await screen.findByText('Required')).toBeInTheDocument()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -54,6 +54,7 @@ export function ConfirmDialog({
|
|
|
54
54
|
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
55
55
|
const cancelButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
56
56
|
const confirmButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
57
|
+
const restoreAriaHiddenRef = React.useRef(false);
|
|
57
58
|
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null);
|
|
58
59
|
// Unique IDs so multiple ConfirmDialog instances on the same page don't
|
|
59
60
|
// collide and make `aria-labelledby` resolve to the wrong dialog's title.
|
|
@@ -111,10 +112,25 @@ export function ConfirmDialog({
|
|
|
111
112
|
}
|
|
112
113
|
}, 0);
|
|
113
114
|
}
|
|
115
|
+
// A modal opened underneath (e.g. a Radix Dialog) marks every sibling in
|
|
116
|
+
// <body> `aria-hidden`, including this portalled element. Shown on top
|
|
117
|
+
// of that modal it must stay reachable for assistive technology.
|
|
118
|
+
if (dialog.getAttribute("aria-hidden") === "true") {
|
|
119
|
+
dialog.removeAttribute("aria-hidden");
|
|
120
|
+
restoreAriaHiddenRef.current = true;
|
|
121
|
+
}
|
|
114
122
|
} else {
|
|
115
123
|
if (dialog.open) {
|
|
116
124
|
dialog.close();
|
|
117
125
|
}
|
|
126
|
+
if (restoreAriaHiddenRef.current) {
|
|
127
|
+
restoreAriaHiddenRef.current = false;
|
|
128
|
+
// Hand the attribute back only while the modal that hid us is still
|
|
129
|
+
// open; its own cleanup has otherwise already removed the marker.
|
|
130
|
+
if (dialog.hasAttribute("data-aria-hidden")) {
|
|
131
|
+
dialog.setAttribute("aria-hidden", "true");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
118
134
|
}
|
|
119
135
|
}, [open, resolvedCancelText, portalTarget]);
|
|
120
136
|
|
|
@@ -61,6 +61,35 @@ describe('ConfirmDialog', () => {
|
|
|
61
61
|
expect(dialog.parentElement).toBe(document.body)
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
it('stays exposed to assistive technology when a modal underneath hid its siblings', () => {
|
|
65
|
+
const renderDialog = (open: boolean) => (
|
|
66
|
+
<ConfirmDialog
|
|
67
|
+
open={open}
|
|
68
|
+
onOpenChange={() => undefined}
|
|
69
|
+
onConfirm={() => undefined}
|
|
70
|
+
title="Discard unsaved changes?"
|
|
71
|
+
confirmText="Discard"
|
|
72
|
+
cancelText="Cancel"
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
const { rerender } = renderWithProviders(renderDialog(false))
|
|
76
|
+
const dialog = document.body.querySelector('dialog')!
|
|
77
|
+
dialog.setAttribute('aria-hidden', 'true')
|
|
78
|
+
dialog.setAttribute('data-aria-hidden', 'true')
|
|
79
|
+
|
|
80
|
+
rerender(renderDialog(true))
|
|
81
|
+
expect(dialog).not.toHaveAttribute('aria-hidden')
|
|
82
|
+
expect(screen.getByRole('alertdialog')).toBe(dialog)
|
|
83
|
+
|
|
84
|
+
rerender(renderDialog(false))
|
|
85
|
+
expect(dialog).toHaveAttribute('aria-hidden', 'true')
|
|
86
|
+
|
|
87
|
+
rerender(renderDialog(true))
|
|
88
|
+
dialog.removeAttribute('data-aria-hidden')
|
|
89
|
+
rerender(renderDialog(false))
|
|
90
|
+
expect(dialog).not.toHaveAttribute('aria-hidden')
|
|
91
|
+
})
|
|
92
|
+
|
|
64
93
|
it('renders the destructive confirmation as the solid variant', () => {
|
|
65
94
|
renderWithProviders(
|
|
66
95
|
<ConfirmDialog
|
|
@@ -551,6 +551,22 @@ export function PhoneNumberField({
|
|
|
551
551
|
() => buildPhoneCountryOptions(countriesProp, t, locale),
|
|
552
552
|
[countriesProp, t, locale],
|
|
553
553
|
)
|
|
554
|
+
const countryItems = React.useMemo(
|
|
555
|
+
() => countryOptions.map(({ country: optionCountry, label }) => (
|
|
556
|
+
<SelectItem
|
|
557
|
+
key={`${optionCountry.iso2}-${optionCountry.dialCode}`}
|
|
558
|
+
value={optionCountry.iso2}
|
|
559
|
+
textValue={label}
|
|
560
|
+
>
|
|
561
|
+
<SelectItemLeading>
|
|
562
|
+
<span className="text-base leading-none">{optionCountry.flag}</span>
|
|
563
|
+
</SelectItemLeading>
|
|
564
|
+
<span className="flex-1 truncate">{label}</span>
|
|
565
|
+
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{optionCountry.dialCode}</span>
|
|
566
|
+
</SelectItem>
|
|
567
|
+
)),
|
|
568
|
+
[countryOptions],
|
|
569
|
+
)
|
|
554
570
|
const fallbackCountry = React.useMemo(
|
|
555
571
|
() => (defaultCountryIso2 && findCountryByIso(defaultCountryIso2)) || DEFAULT_COUNTRY,
|
|
556
572
|
[defaultCountryIso2],
|
|
@@ -723,17 +739,7 @@ export function PhoneNumberField({
|
|
|
723
739
|
<span className={cn('text-base leading-none', size && 'inline-flex size-5 shrink-0 items-center justify-center text-xl [&_img]:size-full')} aria-hidden="true">{renderCountryIcon ? renderCountryIcon(country) : country.flag}</span>
|
|
724
740
|
<span className={cn("text-sm text-foreground tabular-nums", disabled && "text-text-disabled")}>{country.dialCode}</span>
|
|
725
741
|
</SelectTrigger>
|
|
726
|
-
<SelectContent align="start">
|
|
727
|
-
{countryOptions.map(({ country: c, label }) => (
|
|
728
|
-
<SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2} textValue={label}>
|
|
729
|
-
<SelectItemLeading>
|
|
730
|
-
<span className="text-base leading-none">{c.flag}</span>
|
|
731
|
-
</SelectItemLeading>
|
|
732
|
-
<span className="flex-1 truncate">{label}</span>
|
|
733
|
-
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{c.dialCode}</span>
|
|
734
|
-
</SelectItem>
|
|
735
|
-
))}
|
|
736
|
-
</SelectContent>
|
|
742
|
+
<SelectContent align="start">{countryItems}</SelectContent>
|
|
737
743
|
</Select>
|
|
738
744
|
<div aria-hidden="true" className="w-px self-stretch bg-input" />
|
|
739
745
|
<input
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
const mockTranslate = (_key: string, fallback: string) => fallback
|
|
4
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
5
|
+
useT: () => mockTranslate,
|
|
6
|
+
useOptionalLocale: () => undefined,
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const mockItemRender = jest.fn()
|
|
10
|
+
jest.mock('../../../primitives/select', () => {
|
|
11
|
+
const actual = jest.requireActual('../../../primitives/select')
|
|
12
|
+
const ReactActual = jest.requireActual('react')
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
SelectItem: ReactActual.forwardRef((props: Record<string, unknown>, ref: unknown) => {
|
|
16
|
+
mockItemRender()
|
|
17
|
+
return ReactActual.createElement(actual.SelectItem, { ...props, ref })
|
|
18
|
+
}),
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
import * as React from 'react'
|
|
23
|
+
import { render } from '@testing-library/react'
|
|
24
|
+
import { PHONE_COUNTRIES, PhoneNumberField } from '../PhoneNumberField'
|
|
25
|
+
|
|
26
|
+
describe('PhoneNumberField render cost', () => {
|
|
27
|
+
it('does not re-render its country options when the host form re-renders it', () => {
|
|
28
|
+
const view = render(<PhoneNumberField value="+48 600 100 200" onValueChange={() => {}} />)
|
|
29
|
+
expect(mockItemRender.mock.calls.length).toBeGreaterThanOrEqual(PHONE_COUNTRIES.length)
|
|
30
|
+
mockItemRender.mockClear()
|
|
31
|
+
view.rerender(<PhoneNumberField value="+48 600 100 200" onValueChange={() => {}} />)
|
|
32
|
+
view.rerender(<PhoneNumberField value="+48 600 100 201" onValueChange={() => {}} />)
|
|
33
|
+
expect(mockItemRender).not.toHaveBeenCalled()
|
|
34
|
+
})
|
|
35
|
+
})
|