@open-mercato/ui 0.9.0 → 0.9.1-develop.7459.1.99e3accd30

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.
@@ -1008,7 +1008,16 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1008
1008
  const navigationConfirmPendingRef = React.useRef(false)
1009
1009
  const submitNavigationBypassRef = React.useRef(false)
1010
1010
  const submitNavigationBypassTimeoutRef = React.useRef<number | null>(null)
1011
- const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false)
1011
+ const [hasUnsavedChanges, setHasUnsavedChangesState] = React.useState(false)
1012
+ const hasUnsavedChangesRef = React.useRef(false)
1013
+ // Writing a state hook the value it already holds still re-runs this whole
1014
+ // component once before React bails out. The dirty check runs on every edit,
1015
+ // so the flag is only written when it actually flips.
1016
+ const setHasUnsavedChanges = React.useCallback((next: boolean) => {
1017
+ if (hasUnsavedChangesRef.current === next) return
1018
+ hasUnsavedChangesRef.current = next
1019
+ setHasUnsavedChangesState(next)
1020
+ }, [])
1012
1021
  const popStateRollbackRef = React.useRef(false)
1013
1022
  const shouldBypassUnsavedChangesGuardRef = React.useRef(shouldBypassUnsavedChangesGuard)
1014
1023
  React.useEffect(() => {
@@ -1033,7 +1042,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1033
1042
  const dirty = currentSnapshot !== snapshot
1034
1043
  isDirtyRef.current = dirty
1035
1044
  setHasUnsavedChanges(dirty)
1036
- }, [embedded, trackDirtyWhenEmbedded, values])
1045
+ }, [embedded, trackDirtyWhenEmbedded, values, setHasUnsavedChanges])
1037
1046
 
1038
1047
  React.useEffect(() => {
1039
1048
  onDirtyChange?.(hasUnsavedChanges)
@@ -1083,7 +1092,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1083
1092
  everEditedFieldIdsRef.current.clear()
1084
1093
  isDirtyRef.current = false
1085
1094
  setHasUnsavedChanges(false)
1086
- }, [])
1095
+ }, [setHasUnsavedChanges])
1087
1096
 
1088
1097
  const confirmUnsavedChanges = React.useCallback(async (): Promise<boolean> => {
1089
1098
  if (!isDirtyRef.current || typeof window === 'undefined') return true
@@ -1201,7 +1210,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1201
1210
  window.history.pushState = originalPushState
1202
1211
  window.history.replaceState = originalReplaceState
1203
1212
  }
1204
- }, [allowNextNavigation, clearDirtyState, confirmUnsavedChanges, embedded, hasUnsavedChanges, router, trackDirtyWhenEmbedded])
1213
+ }, [allowNextNavigation, clearDirtyState, confirmUnsavedChanges, embedded, hasUnsavedChanges, router, trackDirtyWhenEmbedded, setHasUnsavedChanges])
1205
1214
 
1206
1215
  const { widgets: primaryInjectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
1207
1216
  context: injectionContext,
@@ -1629,6 +1638,15 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1629
1638
  {extraActions}
1630
1639
  </>
1631
1640
  ) : undefined
1641
+ const renderFooterActions = !hideFooterActions && !formReadOnly
1642
+ const embeddedExtraActions = renderFooterActions ? undefined : extraActions
1643
+ const embeddedHeaderActions = versionHistoryEnabled || headerInjectionAction || embeddedExtraActions ? (
1644
+ <>
1645
+ {versionHistoryEnabled ? versionHistoryAction : null}
1646
+ {headerInjectionAction}
1647
+ {embeddedExtraActions}
1648
+ </>
1649
+ ) : undefined
1632
1650
 
1633
1651
  // Auto-append custom fields for this entityId
1634
1652
  React.useEffect(() => {
@@ -2699,18 +2717,29 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2699
2717
  ) {
2700
2718
  activeFieldChangeDispatchRef.current = null
2701
2719
  }
2720
+ // Re-run the dispatcher only when an edit queued behind this dispatch is
2721
+ // still waiting. Bumping unconditionally re-rendered the whole form a
2722
+ // second time for every keystroke, even with no widget listening.
2702
2723
  if (
2703
2724
  fieldChangeDispatcherMountedRef.current &&
2704
- fieldChangeFormGenerationRef.current === dispatchToken.formGeneration
2725
+ fieldChangeFormGenerationRef.current === dispatchToken.formGeneration &&
2726
+ pendingFieldChangeEventsRef.current.length > 0
2705
2727
  ) {
2706
2728
  setFieldChangeDispatchVersion((version) => version + 1)
2707
2729
  }
2708
2730
  })
2709
2731
  }, [extendedInjectionEventsEnabled, fieldChangeDispatchVersion, triggerInjectionEvent, values])
2710
2732
 
2711
- const onBlurRequest = React.useCallback((fieldId: string) => {
2712
- void validateFieldOnBlur(fieldId)
2733
+ // `validateFieldOnBlur` is rebuilt whenever the form values change. Passing
2734
+ // that identity down would invalidate every memoized field control on each
2735
+ // keystroke, so the fields get a stable callback that reads the latest one.
2736
+ const validateFieldOnBlurRef = React.useRef(validateFieldOnBlur)
2737
+ React.useLayoutEffect(() => {
2738
+ validateFieldOnBlurRef.current = validateFieldOnBlur
2713
2739
  }, [validateFieldOnBlur])
2740
+ const onBlurRequest = React.useCallback((fieldId: string) => {
2741
+ void validateFieldOnBlurRef.current(fieldId)
2742
+ }, [])
2714
2743
 
2715
2744
  const handleFieldsetSelectionChange = React.useCallback(
2716
2745
  (entityId: string, nextCode: string | null) => {
@@ -3899,8 +3928,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3899
3928
  submit: formReadOnly ? undefined : { formId, pending: pending, label: resolvedSubmitLabel, pendingLabel: savingLabel, icon: submitIcon },
3900
3929
  }}
3901
3930
  />
3902
- ) : headerExtraActions ? (
3903
- <div className="flex justify-end gap-2 mb-2">{headerExtraActions}</div>
3931
+ ) : embeddedHeaderActions ? (
3932
+ <div className="flex justify-end gap-2 mb-2">{embeddedHeaderActions}</div>
3904
3933
  ) : null}
3905
3934
  {contentHeader}
3906
3935
  <DataLoader
@@ -3938,7 +3967,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3938
3967
  {hasSecondaryColumn ? <div className={densityStackMd} data-crud-injection-region>{col2Content}</div> : null}
3939
3968
  </div>
3940
3969
  {formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
3941
- {hideFooterActions || formReadOnly ? null : (
3970
+ {renderFooterActions ? (
3942
3971
  <FormFooter
3943
3972
  embedded={embedded}
3944
3973
  className={dialogFooterClass}
@@ -3952,7 +3981,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3952
3981
  submit: { pending: pending, label: resolvedSubmitLabel, pendingLabel: savingLabel, icon: submitIcon }
3953
3982
  }}
3954
3983
  />
3955
- )}
3984
+ ) : null}
3956
3985
  </form>,
3957
3986
  )}
3958
3987
  </DataLoader>
@@ -3982,8 +4011,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3982
4011
  submit: formReadOnly ? undefined : { formId, pending: pending, label: resolvedSubmitLabel, pendingLabel: savingLabel, icon: submitIcon },
3983
4012
  }}
3984
4013
  />
3985
- ) : headerExtraActions ? (
3986
- <div className="flex justify-end gap-2 mb-2">{headerExtraActions}</div>
4014
+ ) : embeddedHeaderActions ? (
4015
+ <div className="flex justify-end gap-2 mb-2">{embeddedHeaderActions}</div>
3987
4016
  ) : null}
3988
4017
  {contentHeader}
3989
4018
  <DataLoader
@@ -4036,7 +4065,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
4036
4065
  })}
4037
4066
  </div>
4038
4067
  {formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
4039
- {hideFooterActions || formReadOnly ? null : (
4068
+ {renderFooterActions ? (
4040
4069
  <FormFooter
4041
4070
  embedded={embedded}
4042
4071
  className={dialogFooterClass}
@@ -4050,7 +4079,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
4050
4079
  submit: { pending: pending, label: resolvedSubmitLabel, pendingLabel: savingLabel, icon: submitIcon },
4051
4080
  }}
4052
4081
  />
4053
- )}
4082
+ ) : null}
4054
4083
  </form>
4055
4084
  </div>,
4056
4085
  )}
@@ -0,0 +1,114 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ /**
4
+ * An embedded CrudForm is the body of a dialog: its footer is the only action
5
+ * row the user sees, and hosts put the dialog's Cancel button there by passing
6
+ * it through `extraActions`. Rendering that same node again in the embedded
7
+ * header strip produced two Cancel buttons in one dialog (issue #6750).
8
+ *
9
+ * The contract pinned here:
10
+ *
11
+ * 1. embedded + a rendered footer -> `extraActions` appear once, in the footer,
12
+ * 2. embedded + no footer -> they fall back to the header strip rather
13
+ * than disappearing, and
14
+ * 3. not embedded -> the intentional FormHeader/FormFooter
15
+ * mirroring is untouched.
16
+ */
17
+
18
+ jest.mock('next/navigation', () => ({
19
+ useRouter: () => ({ push: jest.fn() }),
20
+ usePathname: () => '/',
21
+ useSearchParams: () => new URLSearchParams(),
22
+ }))
23
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
24
+ jest.mock('../confirm-dialog', () => ({
25
+ useConfirmDialog: () => ({ confirm: jest.fn().mockResolvedValue(true), ConfirmDialogElement: null }),
26
+ }))
27
+ jest.mock('../custom-fields/FieldDefinitionsManager', () => {
28
+ const React = require('react')
29
+ return { __esModule: true, FieldDefinitionsManager: React.forwardRef(() => <div />) }
30
+ })
31
+ jest.mock('../utils/customFieldForms', () => ({
32
+ __esModule: true,
33
+ buildFormFieldFromCustomFieldDef: jest.fn(),
34
+ buildFormFieldsFromCustomFields: jest.fn(() => []),
35
+ fetchCustomFieldFormStructure: jest.fn(),
36
+ }))
37
+
38
+ import * as React from 'react'
39
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
40
+ import { Button } from '../../primitives/button'
41
+ import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
42
+
43
+ const fields: CrudField[] = [{ id: 'name', label: 'Name', type: 'text' }]
44
+ const groups: CrudFormGroup[] = [{ id: 'basic', title: 'Basic', column: 1, fields: ['name'] }]
45
+
46
+ type FormOptions = {
47
+ embedded?: boolean
48
+ hideFooterActions?: boolean
49
+ readOnly?: boolean
50
+ }
51
+
52
+ function renderForm({ embedded = true, hideFooterActions, readOnly }: FormOptions = {}) {
53
+ const { container } = renderWithProviders(
54
+ <CrudForm
55
+ fields={fields}
56
+ groups={groups}
57
+ initialValues={{ name: 'x' }}
58
+ onSubmit={jest.fn()}
59
+ submitLabel="Apply"
60
+ embedded={embedded}
61
+ hideFooterActions={hideFooterActions}
62
+ readOnly={readOnly}
63
+ extraActions={(
64
+ <Button type="button" variant="ghost">Cancel</Button>
65
+ )}
66
+ />,
67
+ )
68
+ return container
69
+ }
70
+
71
+ function cancelButtons(container: HTMLElement): HTMLElement[] {
72
+ return Array.from(container.querySelectorAll<HTMLElement>('button')).filter(
73
+ (button) => button.textContent?.trim() === 'Cancel',
74
+ )
75
+ }
76
+
77
+ describe('CrudForm embedded extraActions', () => {
78
+ it('renders a single Cancel action, in the footer beside the submit button', () => {
79
+ const container = renderForm()
80
+ const cancels = cancelButtons(container)
81
+
82
+ expect(cancels).toHaveLength(1)
83
+
84
+ const form = container.querySelector('form')
85
+ expect(form).not.toBeNull()
86
+ expect(form!.contains(cancels[0])).toBe(true)
87
+
88
+ const submit = container.querySelector<HTMLElement>('button[type="submit"]')
89
+ expect(submit?.textContent).toContain('Apply')
90
+ expect(cancels[0].parentElement).toBe(submit?.parentElement)
91
+ })
92
+
93
+ it('keeps the actions visible in the header strip when the footer is hidden', () => {
94
+ const container = renderForm({ hideFooterActions: true })
95
+ const cancels = cancelButtons(container)
96
+
97
+ expect(cancels).toHaveLength(1)
98
+ expect(container.querySelector('form')?.contains(cancels[0])).toBe(false)
99
+ })
100
+
101
+ it('keeps the actions visible in the header strip when the form is read-only', () => {
102
+ const container = renderForm({ readOnly: true })
103
+ const cancels = cancelButtons(container)
104
+
105
+ expect(cancels).toHaveLength(1)
106
+ expect(container.querySelector('form')?.contains(cancels[0])).toBe(false)
107
+ })
108
+
109
+ it('leaves the non-embedded header/footer mirroring in place', () => {
110
+ const container = renderForm({ embedded: false })
111
+
112
+ expect(cancelButtons(container)).toHaveLength(2)
113
+ })
114
+ })
@@ -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
+ })
@@ -60,6 +60,93 @@ describe('useProgressSse', () => {
60
60
  mockAppEventHandlers.clear()
61
61
  })
62
62
 
63
+ // Prior behavior (fixed): idle pages polled /api/progress/active every 5s
64
+ // regardless of whether any job was active. A characterization run against
65
+ // that code (1 mount fetch + 12 polls over 60s = 13 calls) confirmed the
66
+ // defect before this fix landed. The fix degrades the idle cadence to 30s
67
+ // (a reconciliation backstop for a dropped cross-process notify) rather
68
+ // than stopping outright, since the Postgres LISTEN/NOTIFY bridge has no
69
+ // replay for a listener that was mid-reconnect when a job was created.
70
+ it('idle with no active jobs: degrades to the 30s reconciliation cadence', async () => {
71
+ jest.useFakeTimers()
72
+ mockApiCall.mockResolvedValue(mockProgressResponse([]))
73
+
74
+ renderHook(() => useProgressSse())
75
+
76
+ await act(async () => {
77
+ await Promise.resolve()
78
+ })
79
+
80
+ expect(mockApiCall).toHaveBeenCalledTimes(1)
81
+
82
+ await act(async () => {
83
+ jest.advanceTimersByTime(60000)
84
+ await Promise.resolve()
85
+ })
86
+
87
+ // 1 mount fetch + 2 polls at 30s over 60s = 3.
88
+ expect(mockApiCall).toHaveBeenCalledTimes(3)
89
+ })
90
+
91
+ it('while a job is active: keeps polling at the 5s cadence', async () => {
92
+ jest.useFakeTimers()
93
+ mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
94
+
95
+ renderHook(() => useProgressSse())
96
+
97
+ await act(async () => {
98
+ await Promise.resolve()
99
+ })
100
+
101
+ expect(mockApiCall).toHaveBeenCalledTimes(1)
102
+
103
+ await act(async () => {
104
+ jest.advanceTimersByTime(60000)
105
+ await Promise.resolve()
106
+ })
107
+
108
+ // 1 mount fetch + 12 polls at 5s over 60s = 13.
109
+ expect(mockApiCall).toHaveBeenCalledTimes(13)
110
+ })
111
+
112
+ it('a progress.job.created event while idle triggers exactly one fetch', async () => {
113
+ mockApiCall.mockResolvedValue(mockProgressResponse([]))
114
+ renderHook(() => useProgressSse())
115
+
116
+ await waitFor(() => expect(mockApiCall).toHaveBeenCalledTimes(1))
117
+
118
+ act(() => {
119
+ for (const handler of mockAppEventHandlers.get('progress.job.created') ?? []) {
120
+ handler({})
121
+ }
122
+ })
123
+
124
+ await waitFor(() => expect(mockApiCall).toHaveBeenCalledTimes(2))
125
+ })
126
+
127
+ it('unmounting mid-interval stops further fetches', async () => {
128
+ jest.useFakeTimers()
129
+ mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
130
+
131
+ const { result, unmount } = renderHook(() => useProgressSse())
132
+
133
+ await act(async () => {
134
+ await Promise.resolve()
135
+ })
136
+
137
+ await waitFor(() => expect(result.current.activeJobs).toHaveLength(1))
138
+
139
+ const callsBeforeUnmount = mockApiCall.mock.calls.length
140
+ unmount()
141
+
142
+ await act(async () => {
143
+ jest.advanceTimersByTime(60000)
144
+ await Promise.resolve()
145
+ })
146
+
147
+ expect(mockApiCall).toHaveBeenCalledTimes(callsBeforeUnmount)
148
+ })
149
+
63
150
  it('removes a job from activeJobs when an SSE update carries a terminal status', async () => {
64
151
  mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
65
152
  const { result } = renderHook(() => useProgressSse())
@@ -79,6 +166,41 @@ describe('useProgressSse', () => {
79
166
  }))
80
167
  })
81
168
 
169
+ it('stays paused in a backgrounded tab across a hasActiveJobs transition', async () => {
170
+ jest.useFakeTimers()
171
+ mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
172
+
173
+ const { result } = renderHook(() => useProgressSse())
174
+
175
+ await act(async () => {
176
+ await Promise.resolve()
177
+ })
178
+ await waitFor(() => expect(result.current.activeJobs).toHaveLength(1))
179
+
180
+ Object.defineProperty(document, 'hidden', { value: true, configurable: true })
181
+ act(() => {
182
+ document.dispatchEvent(new Event('visibilitychange'))
183
+ })
184
+
185
+ const callsWhileHidden = mockApiCall.mock.calls.length
186
+
187
+ act(() => {
188
+ for (const handler of mockAppEventHandlers.get('progress.job.updated') ?? []) {
189
+ handler({ payload: { ...completedJob, jobId: completedJob.id } })
190
+ }
191
+ })
192
+ expect(result.current.activeJobs).toHaveLength(0)
193
+
194
+ await act(async () => {
195
+ jest.advanceTimersByTime(60000)
196
+ await Promise.resolve()
197
+ })
198
+
199
+ expect(mockApiCall).toHaveBeenCalledTimes(callsWhileHidden)
200
+
201
+ Object.defineProperty(document, 'hidden', { value: false, configurable: true })
202
+ })
203
+
82
204
  it('periodically reconciles active jobs in SSE mode when completion events are missed', async () => {
83
205
  jest.useFakeTimers()
84
206
  mockApiCall
@@ -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
+ })