@carto/ps-react-ui 4.15.0 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/components.js +786 -749
  2. package/dist/components.js.map +1 -1
  3. package/dist/legend/stores.js +20 -16
  4. package/dist/legend-store-registry-CVYzhR1-.js +464 -0
  5. package/dist/legend-store-registry-CVYzhR1-.js.map +1 -0
  6. package/dist/legend.js +1478 -794
  7. package/dist/legend.js.map +1 -1
  8. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +2 -9
  9. package/dist/types/legend/components/a11y.d.ts +6 -0
  10. package/dist/types/legend/components/contexts.d.ts +22 -4
  11. package/dist/types/legend/components/index.d.ts +2 -2
  12. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +4 -3
  13. package/dist/types/legend/components/legend-group/legend-group.d.ts +2 -11
  14. package/dist/types/legend/components/legend-group/styles.d.ts +40 -2
  15. package/dist/types/legend/components/legend-group-menu/legend-group-menu-items.d.ts +28 -0
  16. package/dist/types/legend/components/legend-group-menu/legend-group-menu.d.ts +11 -0
  17. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +7 -17
  18. package/dist/types/legend/components/legend-opacity/styles.d.ts +11 -6
  19. package/dist/types/legend/components/legend-overflow-menu/legend-overflow-menu.d.ts +12 -0
  20. package/dist/types/legend/components/legend-panel-menu/legend-panel-menu.d.ts +11 -0
  21. package/dist/types/legend/components/legend-row/legend-row.d.ts +4 -11
  22. package/dist/types/legend/components/legend-row/styles.d.ts +36 -1
  23. package/dist/types/legend/components/legend-row-menu/legend-row-menu-items.d.ts +12 -0
  24. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +4 -10
  25. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +3 -8
  26. package/dist/types/legend/components/legend-sortable/contexts.d.ts +7 -0
  27. package/dist/types/legend/components/legend-sortable/legend-sortable.d.ts +11 -0
  28. package/dist/types/legend/components/legend-sortable/sortable-entity.d.ts +27 -0
  29. package/dist/types/legend/components/legend-sortable/sortable-ids.d.ts +6 -0
  30. package/dist/types/legend/components/legend-sortable/styles.d.ts +54 -0
  31. package/dist/types/legend/components/legend-visible-layers/legend-visible-layers.d.ts +7 -0
  32. package/dist/types/legend/components/legend-visible-layers/styles.d.ts +48 -0
  33. package/dist/types/legend/components/run-async-menu-action.d.ts +7 -0
  34. package/dist/types/legend/index.d.ts +53 -6
  35. package/dist/types/legend/provider/labels.d.ts +21 -0
  36. package/dist/types/legend/stores/index.d.ts +1 -1
  37. package/dist/types/legend/stores/selectors.d.ts +31 -0
  38. package/dist/types/legend/stores/types.d.ts +11 -0
  39. package/package.json +3 -3
  40. package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
  41. package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
  42. package/src/legend/components/a11y.ts +14 -0
  43. package/src/legend/components/contexts.ts +29 -4
  44. package/src/legend/components/index.ts +3 -4
  45. package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
  46. package/src/legend/components/legend-group/legend-group.test.tsx +166 -9
  47. package/src/legend/components/legend-group/legend-group.tsx +88 -49
  48. package/src/legend/components/legend-group/styles.ts +59 -2
  49. package/src/legend/components/legend-group-menu/legend-group-menu-items.tsx +132 -0
  50. package/src/legend/components/legend-group-menu/legend-group-menu.tsx +25 -0
  51. package/src/legend/components/legend-opacity/legend-opacity.tsx +43 -70
  52. package/src/legend/components/legend-opacity/styles.ts +14 -9
  53. package/src/legend/components/legend-overflow-menu/legend-overflow-menu.tsx +84 -0
  54. package/src/legend/components/legend-panel-menu/legend-panel-menu.tsx +23 -0
  55. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +2 -1
  56. package/src/legend/components/legend-row/legend-row.test.tsx +304 -85
  57. package/src/legend/components/legend-row/legend-row.tsx +73 -50
  58. package/src/legend/components/legend-row/styles.ts +54 -1
  59. package/src/legend/components/legend-row-menu/legend-row-menu-items.tsx +57 -0
  60. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +12 -50
  61. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +25 -17
  62. package/src/legend/components/legend-sortable/contexts.ts +12 -0
  63. package/src/legend/components/legend-sortable/legend-sortable.test.tsx +144 -0
  64. package/src/legend/components/legend-sortable/legend-sortable.tsx +195 -0
  65. package/src/legend/components/legend-sortable/sortable-entity.tsx +91 -0
  66. package/src/legend/components/legend-sortable/sortable-ids.ts +17 -0
  67. package/src/legend/components/legend-sortable/styles.ts +56 -0
  68. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +14 -8
  69. package/src/legend/components/legend-visible-layers/legend-visible-layers.test.tsx +86 -0
  70. package/src/legend/components/legend-visible-layers/legend-visible-layers.tsx +135 -0
  71. package/src/legend/components/legend-visible-layers/styles.ts +60 -0
  72. package/src/legend/components/run-async-menu-action.ts +20 -0
  73. package/src/legend/index.ts +59 -4
  74. package/src/legend/provider/labels.ts +43 -1
  75. package/src/legend/stores/index.ts +4 -0
  76. package/src/legend/stores/legend-store-registry.ts +60 -0
  77. package/src/legend/stores/legend-store.test.ts +116 -0
  78. package/src/legend/stores/selectors.ts +64 -2
  79. package/src/legend/stores/types.ts +11 -0
  80. package/src/widgets/actions/download/exports.test.tsx +14 -1
  81. package/dist/selectors-DwHj6mE2.js +0 -388
  82. package/dist/selectors-DwHj6mE2.js.map +0 -1
@@ -3,10 +3,15 @@ import { fireEvent, render, screen } from '@testing-library/react'
3
3
  import { MenuItem } from '@mui/material'
4
4
  import { LegendProvider } from '../../provider'
5
5
  import { LegendRow } from './legend-row'
6
+ import { LegendGroup } from '../legend-group/legend-group'
6
7
  import { LegendActions } from '../legend-actions/legend-actions'
7
8
  import { LegendVisibilityToggle } from '../legend-visibility-toggle/legend-visibility-toggle'
8
9
  import { LegendRowMenu } from '../legend-row-menu/legend-row-menu'
9
10
  import { LegendZoomTo } from '../legend-row-menu/legend-zoom-to'
11
+ import {
12
+ LegendShowOnlyLayer,
13
+ LegendShowAllLayers,
14
+ } from '../legend-row-menu/legend-row-menu-items'
10
15
  import { LegendOpacity } from '../legend-opacity/legend-opacity'
11
16
  import { LegendConfigSelect } from '../legend-config-select/legend-config-select'
12
17
  import { LegendItem } from '../legend-item/legend-item'
@@ -25,37 +30,42 @@ const cat: LegendVariable = {
25
30
  afterEach(() => clearAllLegendStores())
26
31
 
27
32
  /**
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).
33
+ * Full row composition (the module shape): eye + ⋮ menu in the header slot
34
+ * (menu last rightmost), the inline opacity bar as first body child, then
35
+ * config-select + item. Menu items: built-ins first, ZoomTo (app extra) last.
31
36
  */
32
37
  function renderRow(
33
38
  id: string,
34
39
  layers: LegendLayerInput[],
35
- callbacks: { onZoomTo?: (id: string) => void } = {},
40
+ callbacks: { onZoomTo?: (id: string) => void | Promise<void> } = {},
36
41
  layerId = layers[0]!.id,
37
42
  ) {
38
43
  return render(
39
44
  <LegendProvider id={id} layers={layers}>
40
45
  <LegendRow layerId={layerId}>
41
- {/* L→R: options · opacity · visibility (chevron appended after). */}
42
46
  <LegendActions>
43
- {callbacks.onZoomTo && (
44
- <LegendRowMenu>
45
- <LegendZoomTo onZoomTo={callbacks.onZoomTo} />
46
- </LegendRowMenu>
47
- )}
48
- <LegendOpacity.Trigger />
49
47
  <LegendVisibilityToggle />
48
+ <LegendRowMenu>
49
+ <LegendShowOnlyLayer />
50
+ <LegendShowAllLayers />
51
+ <LegendOpacity.Item />
52
+ {callbacks.onZoomTo && (
53
+ <LegendZoomTo onZoomTo={callbacks.onZoomTo} />
54
+ )}
55
+ </LegendRowMenu>
50
56
  </LegendActions>
57
+ <LegendOpacity.Inline />
51
58
  <LegendConfigSelect />
52
59
  <LegendItem />
53
- <LegendOpacity.Popover />
54
60
  </LegendRow>
55
61
  </LegendProvider>,
56
62
  )
57
63
  }
58
64
 
65
+ /** The row's accordion header (title area), named by its content. */
66
+ const titleButton = (name: string | RegExp) =>
67
+ screen.getByRole('button', { name })
68
+
59
69
  describe('<LegendRow> composition', () => {
60
70
  it('partitions Actions into the header and other children into the body', () => {
61
71
  const { container } = renderRow('comp1', [
@@ -66,6 +76,8 @@ describe('<LegendRow> composition', () => {
66
76
  // The visibility action landed inside the header fade group…
67
77
  const fade = container.querySelector('.PsLegend-rowFade')!
68
78
  expect(fade.contains(screen.getByLabelText('Hide layer'))).toBe(true)
79
+ // …the ⋮ too (it fades like the rest)…
80
+ expect(fade.contains(screen.getByLabelText('Layer options'))).toBe(true)
69
81
  // …while the item renderer landed in the body, outside the fade group.
70
82
  expect(fade.contains(screen.getByText('A'))).toBe(false)
71
83
  })
@@ -78,29 +90,60 @@ describe('<LegendRow> composition', () => {
78
90
  expect(getLegendStore('r1').getState().layers.a!.visible).toBe(true)
79
91
  })
80
92
 
81
- it('toggles collapse through the store', () => {
93
+ it('clicking the title row toggles collapse (accordion header)', () => {
82
94
  renderRow('r2', [{ id: 'a', name: 'A', variables: [cat] }])
83
- fireEvent.click(screen.getByLabelText('Collapse layer'))
95
+ const header = titleButton(/A/)
96
+ expect(header.getAttribute('aria-expanded')).toBe('true')
97
+ fireEvent.click(header)
84
98
  expect(getLegendStore('r2').getState().layers.a!.collapsed).toBe(true)
99
+ expect(header.getAttribute('aria-expanded')).toBe('false')
100
+ // Keyboard parity: Space re-expands.
101
+ fireEvent.keyDown(header, { key: ' ' })
102
+ expect(getLegendStore('r2').getState().layers.a!.collapsed).toBe(false)
103
+ })
104
+
105
+ it('offers no collapse affordance when the layer has nothing to collapse', () => {
106
+ renderRow('r3', [{ id: 'a', name: 'Bodiless', variables: [] }])
107
+ expect(screen.queryByRole('button', { name: /Bodiless/ })).toBeNull()
85
108
  })
86
109
 
87
- it('eye toggle drives collapse; a manual chevron collapse persists', () => {
110
+ it('eye toggle drives collapse; a manual title-click collapse persists', () => {
88
111
  renderRow('r6', [{ id: 'a', name: 'A', variables: [cat] }])
89
- // Hide → collapses, and the chevron is disabled while hidden.
112
+ // Hide → collapses, and the title button disappears while hidden.
90
113
  fireEvent.click(screen.getByLabelText('Hide layer'))
91
114
  expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(true)
92
- const chevron = screen.getByLabelText<HTMLButtonElement>('Expand layer')
93
- expect(chevron.disabled).toBe(true)
115
+ expect(screen.queryByRole('button', { name: /A/ })).toBeNull()
94
116
  fireEvent.click(screen.getByLabelText('Show layer'))
95
117
  expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(false)
96
118
 
97
119
  // Manual collapse first → hide → show → stays collapsed.
98
- fireEvent.click(screen.getByLabelText('Collapse layer'))
120
+ fireEvent.click(titleButton(/A/))
99
121
  fireEvent.click(screen.getByLabelText('Hide layer'))
100
122
  fireEvent.click(screen.getByLabelText('Show layer'))
101
123
  expect(getLegendStore('r6').getState().layers.a!.collapsed).toBe(true)
102
124
  })
103
125
 
126
+ it('hides the subtitle while collapsed, restores it on expand', () => {
127
+ renderRow('sub1', [
128
+ { id: 'a', name: 'A', subtitle: 'Zoom 0–12', variables: [cat] },
129
+ ])
130
+ expect(screen.getByText('Zoom 0–12')).toBeTruthy()
131
+ fireEvent.click(titleButton(/A/))
132
+ expect(screen.queryByText('Zoom 0–12')).toBeNull()
133
+ fireEvent.click(titleButton(/A/))
134
+ expect(screen.getByText('Zoom 0–12')).toBeTruthy()
135
+ })
136
+
137
+ it('hides the subtitle while the layer is hidden (force-collapsed)', () => {
138
+ renderRow('sub2', [
139
+ { id: 'a', name: 'A', subtitle: 'Zoom 0–12', variables: [cat] },
140
+ ])
141
+ fireEvent.click(screen.getByLabelText('Hide layer'))
142
+ expect(screen.queryByText('Zoom 0–12')).toBeNull()
143
+ fireEvent.click(screen.getByLabelText('Show layer'))
144
+ expect(screen.getByText('Zoom 0–12')).toBeTruthy()
145
+ })
146
+
104
147
  it('renders the config select when the layer declares sections', () => {
105
148
  renderRow('r5', [
106
149
  {
@@ -136,15 +179,160 @@ describe('<LegendRow> composition', () => {
136
179
  })
137
180
  })
138
181
 
139
- describe('<LegendRowMenu> (agnostic shell)', () => {
140
- it('Legend.ZoomTo fires the composed handler with the layer id; the menu closes', () => {
182
+ describe('<LegendRow> grouped (inside Legend.Group)', () => {
183
+ it('renders inside a group and collapses via its own title row', () => {
184
+ render(
185
+ <LegendProvider
186
+ id='g1-row'
187
+ groups={[{ id: 'g1', label: 'G' }]}
188
+ layers={[{ id: 'a', name: 'Alpha', groupId: 'g1', variables: [cat] }]}
189
+ >
190
+ <LegendGroup groupId='g1'>
191
+ <LegendRow layerId='a'>
192
+ <LegendActions>
193
+ <LegendVisibilityToggle />
194
+ </LegendActions>
195
+ <LegendItem />
196
+ </LegendRow>
197
+ </LegendGroup>
198
+ </LegendProvider>,
199
+ )
200
+ fireEvent.click(screen.getByRole('button', { name: /Alpha/ }))
201
+ expect(getLegendStore('g1-row').getState().layers.a!.collapsed).toBe(true)
202
+ })
203
+ })
204
+
205
+ describe('<LegendRowMenu> (agnostic shell) + built-in items', () => {
206
+ it('Legend.ZoomTo fires the composed handler with the layer id; the menu closes', async () => {
141
207
  const onZoomTo = vi.fn()
142
208
  renderRow('m3', [{ id: 'a', name: 'A', variables: [cat] }], { onZoomTo })
143
209
  fireEvent.click(screen.getByLabelText('Layer options'))
144
- fireEvent.click(screen.getByText('Zoom to'))
210
+ fireEvent.click(screen.getByText('Zoom to layer'))
145
211
  expect(onZoomTo).toHaveBeenCalledWith('a')
146
- // Menu-level onClick closed it.
147
- expect(screen.queryByRole('menuitem')).toBeNull()
212
+ // Sync handler settles on the next microtask → item closes the menu.
213
+ await vi.waitFor(() => {
214
+ expect(screen.queryByRole('menuitem')).toBeNull()
215
+ })
216
+ })
217
+
218
+ it('Legend.ZoomTo keeps the menu open with loading feedback while the handler is pending', async () => {
219
+ let resolveZoom!: () => void
220
+ const onZoomTo = vi.fn(
221
+ () =>
222
+ new Promise<void>((resolve) => {
223
+ resolveZoom = resolve
224
+ }),
225
+ )
226
+ renderRow('m3-busy', [{ id: 'a', name: 'A', variables: [cat] }], {
227
+ onZoomTo,
228
+ })
229
+ fireEvent.click(screen.getByLabelText('Layer options'))
230
+ fireEvent.click(screen.getByText('Zoom to layer'))
231
+
232
+ expect(onZoomTo).toHaveBeenCalledWith('a')
233
+ // Menu stays open; item shows the in-flight label and is disabled.
234
+ const item = await screen.findByRole('menuitem', { name: /Loading/ })
235
+ expect(item.getAttribute('aria-disabled')).toBe('true')
236
+ expect(screen.getByRole('progressbar')).toBeDefined()
237
+
238
+ resolveZoom()
239
+ await vi.waitFor(() => {
240
+ expect(screen.queryByRole('menuitem')).toBeNull()
241
+ })
242
+ })
243
+
244
+ it('Legend.ZoomTo ignores a second click before the busy re-render', async () => {
245
+ let resolveZoom!: () => void
246
+ const onZoomTo = vi.fn(
247
+ () =>
248
+ new Promise<void>((resolve) => {
249
+ resolveZoom = resolve
250
+ }),
251
+ )
252
+ renderRow('m3-dbl', [{ id: 'a', name: 'A', variables: [cat] }], {
253
+ onZoomTo,
254
+ })
255
+ fireEvent.click(screen.getByLabelText('Layer options'))
256
+ const zoomItem = screen.getByText('Zoom to layer')
257
+ // Two clicks in the same tick — before React applies setBusy(true).
258
+ fireEvent.click(zoomItem)
259
+ fireEvent.click(zoomItem)
260
+
261
+ expect(onZoomTo).toHaveBeenCalledTimes(1)
262
+
263
+ resolveZoom()
264
+ await vi.waitFor(() => {
265
+ expect(screen.queryByRole('menuitem')).toBeNull()
266
+ })
267
+ })
268
+
269
+ it('Show only this layer / Show all layers dispatch the sibling actions', () => {
270
+ render(
271
+ <LegendProvider
272
+ id='m7'
273
+ layers={[
274
+ { id: 'a', name: 'A', variables: [cat] },
275
+ { id: 'b', name: 'B', variables: [cat] },
276
+ ]}
277
+ >
278
+ <LegendRow layerId='a'>
279
+ <LegendActions>
280
+ <LegendRowMenu>
281
+ <LegendShowOnlyLayer />
282
+ <LegendShowAllLayers />
283
+ </LegendRowMenu>
284
+ </LegendActions>
285
+ <LegendItem />
286
+ </LegendRow>
287
+ </LegendProvider>,
288
+ )
289
+ fireEvent.click(screen.getByLabelText('Layer options'))
290
+ fireEvent.click(screen.getByText('Show only this layer'))
291
+ expect(getLegendStore('m7').getState().layers.b!.visible).toBe(false)
292
+
293
+ fireEvent.click(screen.getByLabelText('Layer options'))
294
+ fireEvent.click(screen.getByText('Show all layers'))
295
+ expect(getLegendStore('m7').getState().layers.b!.visible).toBe(true)
296
+ })
297
+
298
+ it('show only/all scope to the group bucket and restore collapse snapshots', () => {
299
+ render(
300
+ <LegendProvider
301
+ id='m9'
302
+ groups={[{ id: 'g1', label: 'G' }]}
303
+ layers={[
304
+ { id: 'a', name: 'A', groupId: 'g1', variables: [cat] },
305
+ { id: 'b', name: 'B', groupId: 'g1', variables: [cat] },
306
+ { id: 'loose', name: 'Loose', variables: [cat] },
307
+ ]}
308
+ >
309
+ <LegendGroup groupId='g1'>
310
+ <LegendRow layerId='a'>
311
+ <LegendActions>
312
+ <LegendRowMenu>
313
+ <LegendShowOnlyLayer />
314
+ <LegendShowAllLayers />
315
+ </LegendRowMenu>
316
+ </LegendActions>
317
+ <LegendItem />
318
+ </LegendRow>
319
+ </LegendGroup>
320
+ </LegendProvider>,
321
+ )
322
+ // Manual collapse on the sibling: the snapshot must survive the cycle.
323
+ getLegendStore('m9').getState().setCollapsed('b', true)
324
+
325
+ fireEvent.click(screen.getByLabelText('Layer options'))
326
+ fireEvent.click(screen.getByText('Show only this layer'))
327
+ let st = getLegendStore('m9').getState()
328
+ expect(st.layers.b!.visible).toBe(false) // sibling in g1
329
+ expect(st.layers.loose!.visible).toBe(true) // other bucket untouched
330
+
331
+ fireEvent.click(screen.getByLabelText('Layer options'))
332
+ fireEvent.click(screen.getByText('Show all layers'))
333
+ st = getLegendStore('m9').getState()
334
+ expect(st.layers.b!.visible).toBe(true)
335
+ expect(st.layers.b!.collapsed).toBe(true) // manual collapse restored
148
336
  })
149
337
 
150
338
  it('renders any consumer MenuItem; clicks dispatch and close the menu', () => {
@@ -169,10 +357,7 @@ describe('<LegendRowMenu> (agnostic shell)', () => {
169
357
  expect(screen.queryByRole('menuitem')).toBeNull()
170
358
  })
171
359
 
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
-
360
+ it('renders nothing without children', () => {
176
361
  render(
177
362
  <LegendProvider
178
363
  id='m6'
@@ -187,6 +372,24 @@ describe('<LegendRowMenu> (agnostic shell)', () => {
187
372
  )
188
373
  expect(screen.queryByLabelText('Layer options')).toBeNull()
189
374
  })
375
+
376
+ it('stays available while hidden, with only the applicable items', () => {
377
+ renderRow(
378
+ 'm8',
379
+ [{ id: 'a', name: 'A', variables: [cat], opacityControl: true }],
380
+ { onZoomTo: vi.fn() },
381
+ )
382
+ fireEvent.click(screen.getByLabelText('Hide layer'))
383
+ // ⋮ still present (hover-revealed, not pinned).
384
+ const trigger = screen.getByLabelText('Layer options')
385
+ expect(trigger.className).not.toContain('active')
386
+ fireEvent.click(trigger)
387
+ // Show only/all still apply; Zoom + Opacity remove themselves.
388
+ expect(screen.getByText('Show only this layer')).toBeTruthy()
389
+ expect(screen.getByText('Show all layers')).toBeTruthy()
390
+ expect(screen.queryByText('Zoom to layer')).toBeNull()
391
+ expect(screen.queryByText('Opacity')).toBeNull()
392
+ })
190
393
  })
191
394
 
192
395
  describe('hover-fade dirty pinning (.active)', () => {
@@ -200,75 +403,80 @@ describe('hover-fade dirty pinning (.active)', () => {
200
403
  )
201
404
  })
202
405
 
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
- })
406
+ it('marks the menu trigger active while its menu or the opacity bar is open', () => {
407
+ renderRow('f2', [
408
+ { id: 'a', name: 'A', variables: [cat], opacityControl: true },
409
+ ])
207
410
  const trigger = screen.getByLabelText('Layer options')
208
411
  expect(trigger.className).not.toContain('active')
209
412
  fireEvent.click(trigger)
413
+ expect(trigger.className).toContain('active') // menu open
414
+
415
+ // Pick Opacity: the menu closes but the ⋮ stays pinned while the bar is
416
+ // open.
417
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Opacity' }))
418
+ expect(screen.queryByRole('menuitem')).toBeNull()
210
419
  expect(trigger.className).toContain('active')
211
420
  })
421
+ })
212
422
 
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')
423
+ describe('<LegendOpacity> Item + Inline', () => {
424
+ const layersWithOpacity: LegendLayerInput[] = [
425
+ { id: 'a', name: 'A', variables: [cat], opacityControl: true },
426
+ ]
221
427
 
222
- fireEvent.click(action)
223
- expect(pin.className).toContain('active') // open pins it
428
+ const openBar = () => {
429
+ fireEvent.click(screen.getByLabelText('Layer options'))
430
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Opacity' }))
431
+ }
224
432
 
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
- })
433
+ it('the menu item toggles the inline bar and stays selected while open', () => {
434
+ renderRow('o1', layersWithOpacity)
435
+ expect(screen.queryByRole('slider')).toBeNull()
232
436
 
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()
437
+ openBar()
438
+ expect(screen.getByRole('slider', { name: 'Opacity' })).toBeTruthy()
439
+ expect(screen.getByRole('textbox', { name: 'Opacity' })).toBeTruthy()
239
440
 
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()
441
+ // Reopen the menu: the item reflects the open bar and toggles it off.
442
+ fireEvent.click(screen.getByLabelText('Layer options'))
443
+ const item = screen.getByRole('menuitem', { name: 'Opacity' })
444
+ expect(item.className).toContain('Mui-selected')
445
+ fireEvent.click(item)
446
+ expect(screen.queryByRole('slider')).toBeNull()
447
+ })
247
448
 
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()
449
+ it('removes the item without opacityControl', () => {
450
+ renderRow('o2', [{ id: 'a', name: 'A', variables: [cat] }])
451
+ fireEvent.click(screen.getByLabelText('Layer options'))
452
+ expect(screen.queryByRole('menuitem', { name: 'Opacity' })).toBeNull()
252
453
  })
253
- })
254
454
 
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.
455
+ it('opening on a collapsed row auto-expands it; folding keeps the state', () => {
456
+ renderRow('o3', layersWithOpacity)
457
+ fireEvent.click(titleButton(/A/)) // collapse
458
+ openBar()
459
+ // Auto-expanded so the bar is visible.
460
+ expect(getLegendStore('o3').getState().layers.a!.collapsed).toBe(false)
263
461
  expect(screen.getByRole('slider', { name: 'Opacity' })).toBeTruthy()
264
- expect(screen.getByRole('textbox', { name: 'Opacity' })).toBeTruthy()
265
- fireEvent.click(baseElement.querySelector('.MuiBackdrop-root')!)
462
+
463
+ // Folding hides the bar with the body but keeps opacityOpen: expanding
464
+ // brings it back without touching the menu.
465
+ fireEvent.click(titleButton(/A/))
466
+ fireEvent.click(titleButton(/A/))
467
+ expect(screen.getByRole('slider', { name: 'Opacity' })).toBeTruthy()
468
+ })
469
+
470
+ it('the ✕ closes the bar', () => {
471
+ renderRow('o4', layersWithOpacity)
472
+ openBar()
473
+ fireEvent.click(screen.getByLabelText('Close'))
266
474
  expect(screen.queryByRole('slider')).toBeNull()
267
475
  })
268
476
 
269
477
  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' }))
478
+ renderRow('c1', layersWithOpacity)
479
+ openBar()
272
480
  const slider = screen.getByRole('slider', { name: 'Opacity' })
273
481
  // The hidden-input change path fires MUI's onChangeCommitted too.
274
482
  fireEvent.change(slider, { target: { value: '40' } })
@@ -276,8 +484,8 @@ describe('<LegendOpacity> / <LegendConfigSelect> dispatch', () => {
276
484
  })
277
485
 
278
486
  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' }))
487
+ renderRow('c3', layersWithOpacity)
488
+ openBar()
281
489
  const input = screen.getByRole('textbox', { name: 'Opacity' })
282
490
 
283
491
  fireEvent.change(input, { target: { value: '40' } })
@@ -294,7 +502,9 @@ describe('<LegendOpacity> / <LegendConfigSelect> dispatch', () => {
294
502
  fireEvent.blur(input)
295
503
  expect((input as HTMLInputElement).value).toBe('100')
296
504
  })
505
+ })
297
506
 
507
+ describe('<LegendConfigSelect> dispatch', () => {
298
508
  it('a single section renders without its title; a pick writes the store', () => {
299
509
  renderRow('c2', [
300
510
  {
@@ -433,20 +643,29 @@ describe('labels override', () => {
433
643
  render(
434
644
  <LegendProvider
435
645
  id='l10n'
436
- layers={[{ id: 'a', name: 'A', variables: [cat] }]}
646
+ layers={[
647
+ { id: 'a', name: 'A', variables: [cat], opacityControl: true },
648
+ ]}
437
649
  labels={{ opacity: 'Opacidad', hideLayer: 'Ocultar capa' }}
438
650
  >
439
651
  <LegendRow layerId='a'>
440
652
  <LegendActions>
441
- <LegendOpacity.Trigger />
442
653
  <LegendVisibilityToggle />
654
+ <LegendRowMenu>
655
+ <LegendOpacity.Item />
656
+ </LegendRowMenu>
443
657
  </LegendActions>
658
+ <LegendOpacity.Inline />
659
+ <LegendItem />
444
660
  </LegendRow>
445
661
  </LegendProvider>,
446
662
  )
447
- // The opacity action carries the overridden label (aria + tooltip).
448
- expect(screen.getByRole('button', { name: 'Opacidad' })).toBeTruthy()
449
663
  expect(screen.getByLabelText('Ocultar capa')).toBeTruthy()
664
+ fireEvent.click(screen.getByLabelText('Layer options'))
665
+ // The opacity menu item carries the overridden label…
666
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Opacidad' }))
667
+ // …and so does the inline bar's slider.
668
+ expect(screen.getByRole('slider', { name: 'Opacidad' })).toBeTruthy()
450
669
  })
451
670
  })
452
671