@carto/ps-react-ui 4.12.2 → 4.13.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/dist/components.js +706 -658
- package/dist/components.js.map +1 -1
- package/dist/legend/stores.js +20 -0
- package/dist/legend/stores.js.map +1 -0
- package/dist/legend.js +1536 -0
- package/dist/legend.js.map +1 -0
- package/dist/selectors-DwHj6mE2.js +388 -0
- package/dist/selectors-DwHj6mE2.js.map +1 -0
- package/dist/types/components/index.d.ts +1 -0
- package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
- package/dist/types/legend/components/contexts.d.ts +20 -0
- package/dist/types/legend/components/icons.d.ts +12 -0
- package/dist/types/legend/components/index.d.ts +26 -0
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
- package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
- package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
- package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
- package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
- package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
- package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
- package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
- package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
- package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
- package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
- package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
- package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
- package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
- package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
- package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
- package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
- package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
- package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
- package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
- package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
- package/dist/types/legend/index.d.ts +88 -0
- package/dist/types/legend/provider/index.d.ts +6 -0
- package/dist/types/legend/provider/labels.d.ts +27 -0
- package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
- package/dist/types/legend/provider/legend-provider.d.ts +38 -0
- package/dist/types/legend/stores/index.d.ts +5 -0
- package/dist/types/legend/stores/legend-context.d.ts +4 -0
- package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
- package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
- package/dist/types/legend/stores/selectors.d.ts +33 -0
- package/dist/types/legend/stores/types.d.ts +275 -0
- package/package.json +11 -3
- package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
- package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
- package/src/components/index.ts +2 -0
- package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
- package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
- package/src/legend/components/contexts.ts +32 -0
- package/src/legend/components/icons.tsx +22 -0
- package/src/legend/components/index.ts +39 -0
- package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
- package/src/legend/components/legend-actions/styles.ts +9 -0
- package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
- package/src/legend/components/legend-category/styles.ts +61 -0
- package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
- package/src/legend/components/legend-config-select/styles.ts +48 -0
- package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
- package/src/legend/components/legend-group/legend-group.tsx +88 -0
- package/src/legend/components/legend-group/styles.ts +82 -0
- package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
- package/src/legend/components/legend-icon/styles.ts +56 -0
- package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
- package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
- package/src/legend/components/legend-item/legend-item.tsx +34 -0
- package/src/legend/components/legend-item/styles.ts +11 -0
- package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
- package/src/legend/components/legend-opacity/styles.ts +28 -0
- package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
- package/src/legend/components/legend-proportion/styles.ts +43 -0
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
- package/src/legend/components/legend-ramp/styles.ts +62 -0
- package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
- package/src/legend/components/legend-row/legend-row.tsx +129 -0
- package/src/legend/components/legend-row/styles.ts +111 -0
- package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
- package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
- package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
- package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
- package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
- package/src/legend/index.ts +121 -0
- package/src/legend/provider/index.ts +6 -0
- package/src/legend/provider/labels.ts +59 -0
- package/src/legend/provider/legend-config-context.ts +15 -0
- package/src/legend/provider/legend-provider.tsx +102 -0
- package/src/legend/stores/index.ts +46 -0
- package/src/legend/stores/legend-context.ts +17 -0
- package/src/legend/stores/legend-store-registry.ts +639 -0
- package/src/legend/stores/legend-store.test.ts +457 -0
- package/src/legend/stores/selectors.ts +65 -0
- package/src/legend/stores/types.ts +347 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { MenuItem } from '@mui/material'
|
|
4
|
+
import { LegendProvider } from '../../provider'
|
|
5
|
+
import { LegendRow } from './legend-row'
|
|
6
|
+
import { LegendActions } from '../legend-actions/legend-actions'
|
|
7
|
+
import { LegendVisibilityToggle } from '../legend-visibility-toggle/legend-visibility-toggle'
|
|
8
|
+
import { LegendRowMenu } from '../legend-row-menu/legend-row-menu'
|
|
9
|
+
import { LegendZoomTo } from '../legend-row-menu/legend-zoom-to'
|
|
10
|
+
import { LegendOpacity } from '../legend-opacity/legend-opacity'
|
|
11
|
+
import { LegendConfigSelect } from '../legend-config-select/legend-config-select'
|
|
12
|
+
import { LegendItem } from '../legend-item/legend-item'
|
|
13
|
+
import {
|
|
14
|
+
clearAllLegendStores,
|
|
15
|
+
getLegendStore,
|
|
16
|
+
type LegendLayerInput,
|
|
17
|
+
type LegendVariable,
|
|
18
|
+
} from '../../stores'
|
|
19
|
+
|
|
20
|
+
const cat: LegendVariable = {
|
|
21
|
+
type: 'category',
|
|
22
|
+
items: [{ label: 'A', color: '#123456' }],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => clearAllLegendStores())
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Full row composition (the module shape): actions in the header slot,
|
|
29
|
+
* config-select + item + opacity popover in the body. The menu is composed
|
|
30
|
+
* with its items (module-style: only when the app wires a handler).
|
|
31
|
+
*/
|
|
32
|
+
function renderRow(
|
|
33
|
+
id: string,
|
|
34
|
+
layers: LegendLayerInput[],
|
|
35
|
+
callbacks: { onZoomTo?: (id: string) => void } = {},
|
|
36
|
+
layerId = layers[0]!.id,
|
|
37
|
+
) {
|
|
38
|
+
return render(
|
|
39
|
+
<LegendProvider id={id} layers={layers}>
|
|
40
|
+
<LegendRow layerId={layerId}>
|
|
41
|
+
{/* L→R: options · opacity · visibility (chevron appended after). */}
|
|
42
|
+
<LegendActions>
|
|
43
|
+
{callbacks.onZoomTo && (
|
|
44
|
+
<LegendRowMenu>
|
|
45
|
+
<LegendZoomTo onZoomTo={callbacks.onZoomTo} />
|
|
46
|
+
</LegendRowMenu>
|
|
47
|
+
)}
|
|
48
|
+
<LegendOpacity.Trigger />
|
|
49
|
+
<LegendVisibilityToggle />
|
|
50
|
+
</LegendActions>
|
|
51
|
+
<LegendConfigSelect />
|
|
52
|
+
<LegendItem />
|
|
53
|
+
<LegendOpacity.Popover />
|
|
54
|
+
</LegendRow>
|
|
55
|
+
</LegendProvider>,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('<LegendRow> composition', () => {
|
|
60
|
+
it('partitions Actions into the header and other children into the body', () => {
|
|
61
|
+
const { container } = renderRow('comp1', [
|
|
62
|
+
{ id: 'a', name: 'My layer', variables: [cat] },
|
|
63
|
+
])
|
|
64
|
+
// Title from the store.
|
|
65
|
+
expect(screen.getByText('My layer')).toBeTruthy()
|
|
66
|
+
// The visibility action landed inside the header fade group…
|
|
67
|
+
const fade = container.querySelector('.PsLegend-rowFade')!
|
|
68
|
+
expect(fade.contains(screen.getByLabelText('Hide layer'))).toBe(true)
|
|
69
|
+
// …while the item renderer landed in the body, outside the fade group.
|
|
70
|
+
expect(fade.contains(screen.getByText('A'))).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('toggles visibility through the store', () => {
|
|
74
|
+
renderRow('r1', [{ id: 'a', name: 'A', variables: [cat] }])
|
|
75
|
+
fireEvent.click(screen.getByLabelText('Hide layer'))
|
|
76
|
+
expect(getLegendStore('r1').getState().layers.a!.visible).toBe(false)
|
|
77
|
+
fireEvent.click(screen.getByLabelText('Show layer'))
|
|
78
|
+
expect(getLegendStore('r1').getState().layers.a!.visible).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('toggles collapse through the store', () => {
|
|
82
|
+
renderRow('r2', [{ id: 'a', name: 'A', variables: [cat] }])
|
|
83
|
+
fireEvent.click(screen.getByLabelText('Collapse layer'))
|
|
84
|
+
expect(getLegendStore('r2').getState().layers.a!.collapsed).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('eye toggle drives collapse; a manual chevron collapse persists', () => {
|
|
88
|
+
renderRow('r6', [{ id: 'a', name: 'A', variables: [cat] }])
|
|
89
|
+
// Hide → collapses, and the chevron is disabled while hidden.
|
|
90
|
+
fireEvent.click(screen.getByLabelText('Hide layer'))
|
|
91
|
+
expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(true)
|
|
92
|
+
const chevron = screen.getByLabelText<HTMLButtonElement>('Expand layer')
|
|
93
|
+
expect(chevron.disabled).toBe(true)
|
|
94
|
+
fireEvent.click(screen.getByLabelText('Show layer'))
|
|
95
|
+
expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(false)
|
|
96
|
+
|
|
97
|
+
// Manual collapse first → hide → show → stays collapsed.
|
|
98
|
+
fireEvent.click(screen.getByLabelText('Collapse layer'))
|
|
99
|
+
fireEvent.click(screen.getByLabelText('Hide layer'))
|
|
100
|
+
fireEvent.click(screen.getByLabelText('Show layer'))
|
|
101
|
+
expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('renders the config select when the layer declares sections', () => {
|
|
105
|
+
renderRow('r5', [
|
|
106
|
+
{
|
|
107
|
+
id: 'a',
|
|
108
|
+
name: 'A',
|
|
109
|
+
variables: [cat],
|
|
110
|
+
sections: [{ id: 's1', options: ['x', 'y'], active: 'x' }],
|
|
111
|
+
},
|
|
112
|
+
])
|
|
113
|
+
// Closed field shows the first section's active value.
|
|
114
|
+
expect(screen.getByRole('button', { name: 'Attribute' }).textContent).toBe(
|
|
115
|
+
'x',
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('stacks every variable of the layer with a single layer-level note', () => {
|
|
120
|
+
renderRow('multi', [
|
|
121
|
+
{
|
|
122
|
+
id: 'a',
|
|
123
|
+
name: 'Multi',
|
|
124
|
+
variables: [
|
|
125
|
+
cat,
|
|
126
|
+
{ type: 'proportion', min: 0, max: 100, attribute: 'revenue' },
|
|
127
|
+
],
|
|
128
|
+
helperText: 'One note for the whole layer.',
|
|
129
|
+
},
|
|
130
|
+
])
|
|
131
|
+
// Both renderers are in the body…
|
|
132
|
+
expect(screen.getByText('A')).toBeTruthy() // category item
|
|
133
|
+
expect(screen.getByText('Radius range by')).toBeTruthy() // proportion header
|
|
134
|
+
// …and the note renders exactly once, after all variables.
|
|
135
|
+
expect(screen.getAllByText('One note for the whole layer.')).toHaveLength(1)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('<LegendRowMenu> (agnostic shell)', () => {
|
|
140
|
+
it('Legend.ZoomTo fires the composed handler with the layer id; the menu closes', () => {
|
|
141
|
+
const onZoomTo = vi.fn()
|
|
142
|
+
renderRow('m3', [{ id: 'a', name: 'A', variables: [cat] }], { onZoomTo })
|
|
143
|
+
fireEvent.click(screen.getByLabelText('Layer options'))
|
|
144
|
+
fireEvent.click(screen.getByText('Zoom to'))
|
|
145
|
+
expect(onZoomTo).toHaveBeenCalledWith('a')
|
|
146
|
+
// Menu-level onClick closed it.
|
|
147
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('renders any consumer MenuItem; clicks dispatch and close the menu', () => {
|
|
151
|
+
const onCustom = vi.fn()
|
|
152
|
+
render(
|
|
153
|
+
<LegendProvider
|
|
154
|
+
id='m5'
|
|
155
|
+
layers={[{ id: 'a', name: 'A', variables: [cat] }]}
|
|
156
|
+
>
|
|
157
|
+
<LegendRow layerId='a'>
|
|
158
|
+
<LegendActions>
|
|
159
|
+
<LegendRowMenu>
|
|
160
|
+
<MenuItem onClick={onCustom}>Custom thing</MenuItem>
|
|
161
|
+
</LegendRowMenu>
|
|
162
|
+
</LegendActions>
|
|
163
|
+
</LegendRow>
|
|
164
|
+
</LegendProvider>,
|
|
165
|
+
)
|
|
166
|
+
fireEvent.click(screen.getByLabelText('Layer options'))
|
|
167
|
+
fireEvent.click(screen.getByText('Custom thing'))
|
|
168
|
+
expect(onCustom).toHaveBeenCalled()
|
|
169
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('renders nothing without children (module gates on its own handler)', () => {
|
|
173
|
+
renderRow('m4', [{ id: 'a', name: 'A', variables: [cat] }])
|
|
174
|
+
expect(screen.queryByLabelText('Layer options')).toBeNull()
|
|
175
|
+
|
|
176
|
+
render(
|
|
177
|
+
<LegendProvider
|
|
178
|
+
id='m6'
|
|
179
|
+
layers={[{ id: 'a', name: 'A', variables: [cat] }]}
|
|
180
|
+
>
|
|
181
|
+
<LegendRow layerId='a'>
|
|
182
|
+
<LegendActions>
|
|
183
|
+
<LegendRowMenu />
|
|
184
|
+
</LegendActions>
|
|
185
|
+
</LegendRow>
|
|
186
|
+
</LegendProvider>,
|
|
187
|
+
)
|
|
188
|
+
expect(screen.queryByLabelText('Layer options')).toBeNull()
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('hover-fade dirty pinning (.active)', () => {
|
|
193
|
+
it('marks the visibility toggle active while the layer is hidden', () => {
|
|
194
|
+
renderRow('f1', [{ id: 'a', name: 'A', variables: [cat] }])
|
|
195
|
+
fireEvent.click(screen.getByLabelText('Hide layer'))
|
|
196
|
+
expect(screen.getByLabelText('Show layer').className).toContain('active')
|
|
197
|
+
fireEvent.click(screen.getByLabelText('Show layer'))
|
|
198
|
+
expect(screen.getByLabelText('Hide layer').className).not.toContain(
|
|
199
|
+
'active',
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('marks the menu trigger active while its menu is open', () => {
|
|
204
|
+
renderRow('f2', [{ id: 'a', name: 'A', variables: [cat] }], {
|
|
205
|
+
onZoomTo: vi.fn(),
|
|
206
|
+
})
|
|
207
|
+
const trigger = screen.getByLabelText('Layer options')
|
|
208
|
+
expect(trigger.className).not.toContain('active')
|
|
209
|
+
fireEvent.click(trigger)
|
|
210
|
+
expect(trigger.className).toContain('active')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('marks the opacity action active while its popover is open or opacity ≠ 1', () => {
|
|
214
|
+
const { baseElement } = renderRow('f3', [
|
|
215
|
+
{ id: 'a', name: 'A', variables: [cat] },
|
|
216
|
+
])
|
|
217
|
+
const action = screen.getByRole('button', { name: 'Opacity' })
|
|
218
|
+
// The pin lives on the action's tooltip span — the fade group's child.
|
|
219
|
+
const pin = action.parentElement!
|
|
220
|
+
expect(pin.className).not.toContain('active')
|
|
221
|
+
|
|
222
|
+
fireEvent.click(action)
|
|
223
|
+
expect(pin.className).toContain('active') // open pins it
|
|
224
|
+
|
|
225
|
+
fireEvent.change(screen.getByRole('slider', { name: 'Opacity' }), {
|
|
226
|
+
target: { value: '50' },
|
|
227
|
+
})
|
|
228
|
+
// Close via the backdrop: stays pinned because opacity is dirty.
|
|
229
|
+
fireEvent.click(baseElement.querySelector('.MuiBackdrop-root')!)
|
|
230
|
+
expect(pin.className).toContain('active')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('hides every action but the eye + chevron while the layer is hidden', () => {
|
|
234
|
+
renderRow('f4', [{ id: 'a', name: 'A', variables: [cat] }], {
|
|
235
|
+
onZoomTo: vi.fn(),
|
|
236
|
+
})
|
|
237
|
+
expect(screen.getByRole('button', { name: 'Opacity' })).toBeTruthy()
|
|
238
|
+
expect(screen.getByLabelText('Layer options')).toBeTruthy()
|
|
239
|
+
|
|
240
|
+
fireEvent.click(screen.getByLabelText('Hide layer'))
|
|
241
|
+
// Opacity + ⋮ menu disappear entirely…
|
|
242
|
+
expect(screen.queryByRole('button', { name: 'Opacity' })).toBeNull()
|
|
243
|
+
expect(screen.queryByLabelText('Layer options')).toBeNull()
|
|
244
|
+
// …only the visibility eye (now "Show layer") and the chevron remain.
|
|
245
|
+
expect(screen.getByLabelText('Show layer')).toBeTruthy()
|
|
246
|
+
expect(screen.getByLabelText('Expand layer')).toBeTruthy()
|
|
247
|
+
|
|
248
|
+
fireEvent.click(screen.getByLabelText('Show layer'))
|
|
249
|
+
// Showing the layer brings the actions back.
|
|
250
|
+
expect(screen.getByRole('button', { name: 'Opacity' })).toBeTruthy()
|
|
251
|
+
expect(screen.getByLabelText('Layer options')).toBeTruthy()
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('<LegendOpacity> / <LegendConfigSelect> dispatch', () => {
|
|
256
|
+
it('the trigger opens the popover declared elsewhere in the row (shared context)', () => {
|
|
257
|
+
const { baseElement } = renderRow('anchor', [
|
|
258
|
+
{ id: 'a', name: 'A', variables: [cat] },
|
|
259
|
+
])
|
|
260
|
+
expect(screen.queryByRole('slider')).toBeNull()
|
|
261
|
+
fireEvent.click(screen.getByRole('button', { name: 'Opacity' }))
|
|
262
|
+
// Trigger (header) and Popover (body) communicate through the row context.
|
|
263
|
+
expect(screen.getByRole('slider', { name: 'Opacity' })).toBeTruthy()
|
|
264
|
+
expect(screen.getByRole('textbox', { name: 'Opacity' })).toBeTruthy()
|
|
265
|
+
fireEvent.click(baseElement.querySelector('.MuiBackdrop-root')!)
|
|
266
|
+
expect(screen.queryByRole('slider')).toBeNull()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('slider release commits setOpacity (0–1) to the store', () => {
|
|
270
|
+
renderRow('c1', [{ id: 'a', name: 'A', variables: [cat] }])
|
|
271
|
+
fireEvent.click(screen.getByRole('button', { name: 'Opacity' }))
|
|
272
|
+
const slider = screen.getByRole('slider', { name: 'Opacity' })
|
|
273
|
+
// The hidden-input change path fires MUI's onChangeCommitted too.
|
|
274
|
+
fireEvent.change(slider, { target: { value: '40' } })
|
|
275
|
+
expect(getLegendStore('c1').getState().layers.a!.opacity).toBe(0.4)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('percent input commits live and clamps to 0–100', () => {
|
|
279
|
+
renderRow('c3', [{ id: 'a', name: 'A', variables: [cat] }])
|
|
280
|
+
fireEvent.click(screen.getByRole('button', { name: 'Opacity' }))
|
|
281
|
+
const input = screen.getByRole('textbox', { name: 'Opacity' })
|
|
282
|
+
|
|
283
|
+
fireEvent.change(input, { target: { value: '40' } })
|
|
284
|
+
expect(getLegendStore('c3').getState().layers.a!.opacity).toBe(0.4)
|
|
285
|
+
|
|
286
|
+
// Out-of-range input clamps (no ellipsized "1…" — the field shows 100).
|
|
287
|
+
fireEvent.change(input, { target: { value: '250' } })
|
|
288
|
+
expect(getLegendStore('c3').getState().layers.a!.opacity).toBe(1)
|
|
289
|
+
expect((input as HTMLInputElement).value).toBe('100')
|
|
290
|
+
|
|
291
|
+
// Empty input doesn't commit; blur snaps back to the store value.
|
|
292
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
293
|
+
expect(getLegendStore('c3').getState().layers.a!.opacity).toBe(1)
|
|
294
|
+
fireEvent.blur(input)
|
|
295
|
+
expect((input as HTMLInputElement).value).toBe('100')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('a single section renders without its title; a pick writes the store', () => {
|
|
299
|
+
renderRow('c2', [
|
|
300
|
+
{
|
|
301
|
+
id: 'a',
|
|
302
|
+
name: 'A',
|
|
303
|
+
variables: [cat],
|
|
304
|
+
sections: [
|
|
305
|
+
{ id: 's1', title: 'Global', options: ['x', 'y'], active: 'x' },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
])
|
|
309
|
+
fireEvent.click(screen.getByRole('button', { name: 'Attribute' }))
|
|
310
|
+
expect(screen.queryByText('Global')).toBeNull() // single section → no title
|
|
311
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'y' }))
|
|
312
|
+
expect(getLegendStore('c2').getState().layers.a!.sections![0]!.active).toBe(
|
|
313
|
+
'y',
|
|
314
|
+
)
|
|
315
|
+
// Menu closed after the pick.
|
|
316
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('multiple sections show titles and each keeps its own active', () => {
|
|
320
|
+
renderRow('c4', [
|
|
321
|
+
{
|
|
322
|
+
id: 'a',
|
|
323
|
+
name: 'A',
|
|
324
|
+
variables: [cat],
|
|
325
|
+
sections: [
|
|
326
|
+
{
|
|
327
|
+
id: 'global',
|
|
328
|
+
title: 'Global',
|
|
329
|
+
options: ['Preset 01', 'Preset 04'],
|
|
330
|
+
active: 'Preset 04',
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'vars',
|
|
334
|
+
title: 'Variables 1',
|
|
335
|
+
options: ['Atributo 01', 'Atributo 02'],
|
|
336
|
+
active: 'Atributo 01',
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
])
|
|
341
|
+
// Closed display = first section's active.
|
|
342
|
+
const trigger = screen.getByRole('button', { name: 'Attribute' })
|
|
343
|
+
expect(trigger.textContent).toBe('Preset 04')
|
|
344
|
+
|
|
345
|
+
fireEvent.click(trigger)
|
|
346
|
+
expect(screen.getByText('Global')).toBeTruthy()
|
|
347
|
+
expect(screen.getByText('Variables 1')).toBeTruthy()
|
|
348
|
+
// Both sections highlight their own active simultaneously.
|
|
349
|
+
expect(
|
|
350
|
+
screen.getByRole('menuitem', { name: 'Preset 04' }).className,
|
|
351
|
+
).toContain('Mui-selected')
|
|
352
|
+
expect(
|
|
353
|
+
screen.getByRole('menuitem', { name: 'Atributo 01' }).className,
|
|
354
|
+
).toContain('Mui-selected')
|
|
355
|
+
|
|
356
|
+
// Picking in the second section leaves the first untouched.
|
|
357
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Atributo 02' }))
|
|
358
|
+
const sections = getLegendStore('c4').getState().layers.a!.sections!
|
|
359
|
+
expect(sections[0]!.active).toBe('Preset 04')
|
|
360
|
+
expect(sections[1]!.active).toBe('Atributo 02')
|
|
361
|
+
expect(trigger.textContent).toBe('Preset 04') // display unchanged
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
describe('<LegendVisibilityToggle> controlled mode', () => {
|
|
366
|
+
it('renders the eye for each tri-state and fires onToggle with the next visibility', () => {
|
|
367
|
+
const onToggle = vi.fn()
|
|
368
|
+
const { rerender } = render(
|
|
369
|
+
<LegendProvider id='vt1' layers={[]}>
|
|
370
|
+
<LegendVisibilityToggle visibility='visible' onToggle={onToggle} />
|
|
371
|
+
</LegendProvider>,
|
|
372
|
+
)
|
|
373
|
+
// No Row/Group context needed — works standalone.
|
|
374
|
+
const eye = screen.getByLabelText('Hide layer')
|
|
375
|
+
expect(eye.className).not.toContain('active')
|
|
376
|
+
fireEvent.click(eye)
|
|
377
|
+
expect(onToggle).toHaveBeenLastCalledWith(false)
|
|
378
|
+
|
|
379
|
+
rerender(
|
|
380
|
+
<LegendProvider id='vt1' layers={[]}>
|
|
381
|
+
<LegendVisibilityToggle visibility='mixed' onToggle={onToggle} />
|
|
382
|
+
</LegendProvider>,
|
|
383
|
+
)
|
|
384
|
+
const mixed = screen.getByLabelText('Hide layer')
|
|
385
|
+
expect(mixed.className).toContain('active')
|
|
386
|
+
expect(
|
|
387
|
+
mixed.querySelector('[data-testid="VisibilityIndeterminateIcon"]'),
|
|
388
|
+
).toBeTruthy()
|
|
389
|
+
fireEvent.click(mixed)
|
|
390
|
+
expect(onToggle).toHaveBeenLastCalledWith(false)
|
|
391
|
+
|
|
392
|
+
rerender(
|
|
393
|
+
<LegendProvider id='vt1' layers={[]}>
|
|
394
|
+
<LegendVisibilityToggle visibility='hidden' onToggle={onToggle} />
|
|
395
|
+
</LegendProvider>,
|
|
396
|
+
)
|
|
397
|
+
const hidden = screen.getByLabelText('Show layer')
|
|
398
|
+
expect(hidden.className).toContain('active')
|
|
399
|
+
fireEvent.click(hidden)
|
|
400
|
+
expect(onToggle).toHaveBeenLastCalledWith(true)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('controlled props win over the row context and accept label overrides', () => {
|
|
404
|
+
const onToggle = vi.fn()
|
|
405
|
+
renderRow('vt2', [{ id: 'a', name: 'A', variables: [cat] }])
|
|
406
|
+
render(
|
|
407
|
+
<LegendProvider
|
|
408
|
+
id='vt3'
|
|
409
|
+
layers={[{ id: 'a', name: 'A', variables: [cat] }]}
|
|
410
|
+
>
|
|
411
|
+
<LegendRow layerId='a'>
|
|
412
|
+
<LegendActions>
|
|
413
|
+
<LegendVisibilityToggle
|
|
414
|
+
visibility='hidden'
|
|
415
|
+
onToggle={onToggle}
|
|
416
|
+
labels={{ hideLayer: 'Hide section', showLayer: 'Show section' }}
|
|
417
|
+
/>
|
|
418
|
+
</LegendActions>
|
|
419
|
+
</LegendRow>
|
|
420
|
+
</LegendProvider>,
|
|
421
|
+
)
|
|
422
|
+
// The layer is visible in the store, but the controlled props render the
|
|
423
|
+
// hidden eye with the custom label — context is ignored.
|
|
424
|
+
const eye = screen.getByLabelText('Show section')
|
|
425
|
+
fireEvent.click(eye)
|
|
426
|
+
expect(onToggle).toHaveBeenCalledWith(true)
|
|
427
|
+
expect(getLegendStore('vt3').getState().layers.a!.visible).toBe(true)
|
|
428
|
+
})
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
describe('labels override', () => {
|
|
432
|
+
it('Provider labels replace the defaults end-to-end', () => {
|
|
433
|
+
render(
|
|
434
|
+
<LegendProvider
|
|
435
|
+
id='l10n'
|
|
436
|
+
layers={[{ id: 'a', name: 'A', variables: [cat] }]}
|
|
437
|
+
labels={{ opacity: 'Opacidad', hideLayer: 'Ocultar capa' }}
|
|
438
|
+
>
|
|
439
|
+
<LegendRow layerId='a'>
|
|
440
|
+
<LegendActions>
|
|
441
|
+
<LegendOpacity.Trigger />
|
|
442
|
+
<LegendVisibilityToggle />
|
|
443
|
+
</LegendActions>
|
|
444
|
+
</LegendRow>
|
|
445
|
+
</LegendProvider>,
|
|
446
|
+
)
|
|
447
|
+
// The opacity action carries the overridden label (aria + tooltip).
|
|
448
|
+
expect(screen.getByRole('button', { name: 'Opacidad' })).toBeTruthy()
|
|
449
|
+
expect(screen.getByLabelText('Ocultar capa')).toBeTruthy()
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
describe('<LegendProvider> keepAlive', () => {
|
|
454
|
+
it('preserves interactive state across remounts by default', () => {
|
|
455
|
+
const ui = (
|
|
456
|
+
<LegendProvider
|
|
457
|
+
id='ka1'
|
|
458
|
+
layers={[{ id: 'a', name: 'A', variables: [cat] }]}
|
|
459
|
+
>
|
|
460
|
+
<LegendRow layerId='a'>
|
|
461
|
+
<LegendActions>
|
|
462
|
+
<LegendVisibilityToggle />
|
|
463
|
+
</LegendActions>
|
|
464
|
+
</LegendRow>
|
|
465
|
+
</LegendProvider>
|
|
466
|
+
)
|
|
467
|
+
const { unmount } = render(ui)
|
|
468
|
+
fireEvent.click(screen.getByLabelText('Hide layer'))
|
|
469
|
+
expect(getLegendStore('ka1').getState().layers.a!.visible).toBe(false)
|
|
470
|
+
unmount()
|
|
471
|
+
|
|
472
|
+
render(ui)
|
|
473
|
+
// Store survived: still hidden after remount.
|
|
474
|
+
expect(getLegendStore('ka1').getState().layers.a!.visible).toBe(false)
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('keepAlive=false resets state on remount', () => {
|
|
478
|
+
const ui = (
|
|
479
|
+
<LegendProvider
|
|
480
|
+
id='ka2'
|
|
481
|
+
keepAlive={false}
|
|
482
|
+
layers={[{ id: 'a', name: 'A', variables: [cat] }]}
|
|
483
|
+
>
|
|
484
|
+
<LegendRow layerId='a'>
|
|
485
|
+
<LegendActions>
|
|
486
|
+
<LegendVisibilityToggle />
|
|
487
|
+
</LegendActions>
|
|
488
|
+
</LegendRow>
|
|
489
|
+
</LegendProvider>
|
|
490
|
+
)
|
|
491
|
+
const { unmount } = render(ui)
|
|
492
|
+
fireEvent.click(screen.getByLabelText('Hide layer'))
|
|
493
|
+
unmount()
|
|
494
|
+
|
|
495
|
+
render(ui)
|
|
496
|
+
expect(getLegendStore('ka2').getState().layers.a!.visible).toBe(true)
|
|
497
|
+
})
|
|
498
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from 'react'
|
|
2
|
+
import { Box, Collapse, IconButton, Typography } from '@mui/material'
|
|
3
|
+
import {
|
|
4
|
+
ExpandMore as ExpandMoreIcon,
|
|
5
|
+
ExpandLess as ExpandLessIcon,
|
|
6
|
+
} from '@mui/icons-material'
|
|
7
|
+
import { SmartTooltip } from '../../../components/smart-tooltip/smart-tooltip'
|
|
8
|
+
import { useLegendId, useLegendShallow } from '../../stores'
|
|
9
|
+
import { useLegendConfig } from '../../provider'
|
|
10
|
+
import { LegendRowContext, type LegendRowContextValue } from '../contexts'
|
|
11
|
+
import { partitionLegendChildren } from '../legend-actions/legend-actions'
|
|
12
|
+
import { LEGEND_GROUP_HEADER_HEIGHT } from '../legend-group/styles'
|
|
13
|
+
import { styles } from './styles'
|
|
14
|
+
|
|
15
|
+
export interface LegendRowProps {
|
|
16
|
+
layerId: string
|
|
17
|
+
/**
|
|
18
|
+
* Composition slots: `<Legend.Actions>` children render in the sticky
|
|
19
|
+
* header next to the collapse chevron (hover-fade); everything else renders
|
|
20
|
+
* in the collapsible body, followed by the layer's `helperText` footer.
|
|
21
|
+
*/
|
|
22
|
+
children?: ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Store-connected layer-row wrapper (widgets-v2 `Wrapper` parity). Renders
|
|
27
|
+
* the sticky header — title + subtitle from the store with a smart-truncating
|
|
28
|
+
* tooltip, the partitioned `<Legend.Actions>` slot, and the collapse chevron —
|
|
29
|
+
* over the collapsible body. Provides the row composition context so actions
|
|
30
|
+
* (`Legend.VisibilityToggle`, `Legend.Opacity.*`, `Legend.RowMenu`,
|
|
31
|
+
* `Legend.ConfigSelect`, `Legend.Item`) resolve the layer without props.
|
|
32
|
+
* When the layer is hidden the body is force-collapsed, the content dims,
|
|
33
|
+
* and the chevron is disabled.
|
|
34
|
+
*
|
|
35
|
+
* @experimental This API is new and may change in a future release.
|
|
36
|
+
*/
|
|
37
|
+
export function LegendRow({ layerId, children }: LegendRowProps) {
|
|
38
|
+
const id = useLegendId()
|
|
39
|
+
const layer = useLegendShallow(id, (s) => s.layers[layerId])
|
|
40
|
+
const setCollapsed = useLegendShallow(id, (s) => s.setCollapsed)
|
|
41
|
+
const { labels } = useLegendConfig()
|
|
42
|
+
|
|
43
|
+
const [headerEl, setHeaderEl] = useState<HTMLElement | null>(null)
|
|
44
|
+
const [opacityOpen, setOpacityOpen] = useState(false)
|
|
45
|
+
const rowContext = useMemo<LegendRowContextValue>(
|
|
46
|
+
() => ({ layerId, headerEl, opacityOpen, setOpacityOpen }),
|
|
47
|
+
[layerId, headerEl, opacityOpen],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if (!layer) return null
|
|
51
|
+
|
|
52
|
+
const { actions, body } = partitionLegendChildren(children)
|
|
53
|
+
const effectivelyCollapsed = !layer.visible || layer.collapsed
|
|
54
|
+
// Grouped rows pin below their group's sticky header.
|
|
55
|
+
const stickyTop = layer.groupId ? LEGEND_GROUP_HEADER_HEIGHT : 0
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<LegendRowContext.Provider value={rowContext}>
|
|
59
|
+
<Box sx={styles.row}>
|
|
60
|
+
{/* Inline style: per-instance sticky offset (0 or the group-header
|
|
61
|
+
height), no emotion class churn. */}
|
|
62
|
+
<Box ref={setHeaderEl} sx={styles.header} style={{ top: stickyTop }}>
|
|
63
|
+
<Box sx={styles.titleBox}>
|
|
64
|
+
<Box sx={styles.titleLine}>
|
|
65
|
+
<SmartTooltip title={layer.name} dependencies={[layer.name]}>
|
|
66
|
+
{({ ref }) => (
|
|
67
|
+
<Typography
|
|
68
|
+
ref={ref}
|
|
69
|
+
variant='button'
|
|
70
|
+
color='text.primary'
|
|
71
|
+
sx={styles.title}
|
|
72
|
+
>
|
|
73
|
+
{layer.name}
|
|
74
|
+
</Typography>
|
|
75
|
+
)}
|
|
76
|
+
</SmartTooltip>
|
|
77
|
+
</Box>
|
|
78
|
+
{layer.subtitle && (
|
|
79
|
+
<Typography variant='caption' color='text.secondary'>
|
|
80
|
+
{layer.subtitle}
|
|
81
|
+
</Typography>
|
|
82
|
+
)}
|
|
83
|
+
</Box>
|
|
84
|
+
|
|
85
|
+
<Box sx={styles.actions}>
|
|
86
|
+
{actions}
|
|
87
|
+
<IconButton
|
|
88
|
+
size='small'
|
|
89
|
+
onClick={() => setCollapsed(layerId, !layer.collapsed)}
|
|
90
|
+
disabled={!layer.visible}
|
|
91
|
+
aria-label={
|
|
92
|
+
effectivelyCollapsed ? labels.expandLayer : labels.collapseLayer
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
{effectivelyCollapsed ? (
|
|
96
|
+
<ExpandMoreIcon fontSize='small' />
|
|
97
|
+
) : (
|
|
98
|
+
<ExpandLessIcon fontSize='small' />
|
|
99
|
+
)}
|
|
100
|
+
</IconButton>
|
|
101
|
+
</Box>
|
|
102
|
+
</Box>
|
|
103
|
+
|
|
104
|
+
<Collapse in={!effectivelyCollapsed}>
|
|
105
|
+
<Box
|
|
106
|
+
sx={
|
|
107
|
+
layer.visible
|
|
108
|
+
? styles.content
|
|
109
|
+
: { ...styles.content, ...styles.dimmed }
|
|
110
|
+
}
|
|
111
|
+
>
|
|
112
|
+
{body}
|
|
113
|
+
</Box>
|
|
114
|
+
{layer.helperText && (
|
|
115
|
+
<Box sx={styles.footer}>
|
|
116
|
+
{typeof layer.helperText === 'string' ? (
|
|
117
|
+
<Typography variant='caption' color='text.secondary'>
|
|
118
|
+
{layer.helperText}
|
|
119
|
+
</Typography>
|
|
120
|
+
) : (
|
|
121
|
+
layer.helperText
|
|
122
|
+
)}
|
|
123
|
+
</Box>
|
|
124
|
+
)}
|
|
125
|
+
</Collapse>
|
|
126
|
+
</Box>
|
|
127
|
+
</LegendRowContext.Provider>
|
|
128
|
+
)
|
|
129
|
+
}
|