@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.
- package/dist/components.js +786 -749
- package/dist/components.js.map +1 -1
- package/dist/legend/stores.js +20 -16
- package/dist/legend-store-registry-CVYzhR1-.js +464 -0
- package/dist/legend-store-registry-CVYzhR1-.js.map +1 -0
- package/dist/legend.js +1478 -794
- package/dist/legend.js.map +1 -1
- package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +2 -9
- package/dist/types/legend/components/a11y.d.ts +6 -0
- package/dist/types/legend/components/contexts.d.ts +22 -4
- package/dist/types/legend/components/index.d.ts +2 -2
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +4 -3
- package/dist/types/legend/components/legend-group/legend-group.d.ts +2 -11
- package/dist/types/legend/components/legend-group/styles.d.ts +40 -2
- package/dist/types/legend/components/legend-group-menu/legend-group-menu-items.d.ts +28 -0
- package/dist/types/legend/components/legend-group-menu/legend-group-menu.d.ts +11 -0
- package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +7 -17
- package/dist/types/legend/components/legend-opacity/styles.d.ts +11 -6
- package/dist/types/legend/components/legend-overflow-menu/legend-overflow-menu.d.ts +12 -0
- package/dist/types/legend/components/legend-panel-menu/legend-panel-menu.d.ts +11 -0
- package/dist/types/legend/components/legend-row/legend-row.d.ts +4 -11
- package/dist/types/legend/components/legend-row/styles.d.ts +36 -1
- package/dist/types/legend/components/legend-row-menu/legend-row-menu-items.d.ts +12 -0
- package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +4 -10
- package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +3 -8
- package/dist/types/legend/components/legend-sortable/contexts.d.ts +7 -0
- package/dist/types/legend/components/legend-sortable/legend-sortable.d.ts +11 -0
- package/dist/types/legend/components/legend-sortable/sortable-entity.d.ts +27 -0
- package/dist/types/legend/components/legend-sortable/sortable-ids.d.ts +6 -0
- package/dist/types/legend/components/legend-sortable/styles.d.ts +54 -0
- package/dist/types/legend/components/legend-visible-layers/legend-visible-layers.d.ts +7 -0
- package/dist/types/legend/components/legend-visible-layers/styles.d.ts +48 -0
- package/dist/types/legend/components/run-async-menu-action.d.ts +7 -0
- package/dist/types/legend/index.d.ts +53 -6
- package/dist/types/legend/provider/labels.d.ts +21 -0
- package/dist/types/legend/stores/index.d.ts +1 -1
- package/dist/types/legend/stores/selectors.d.ts +31 -0
- package/dist/types/legend/stores/types.d.ts +11 -0
- package/package.json +3 -3
- package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
- package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
- package/src/legend/components/a11y.ts +14 -0
- package/src/legend/components/contexts.ts +29 -4
- package/src/legend/components/index.ts +3 -4
- package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
- package/src/legend/components/legend-group/legend-group.test.tsx +166 -9
- package/src/legend/components/legend-group/legend-group.tsx +88 -49
- package/src/legend/components/legend-group/styles.ts +59 -2
- package/src/legend/components/legend-group-menu/legend-group-menu-items.tsx +132 -0
- package/src/legend/components/legend-group-menu/legend-group-menu.tsx +25 -0
- package/src/legend/components/legend-opacity/legend-opacity.tsx +43 -70
- package/src/legend/components/legend-opacity/styles.ts +14 -9
- package/src/legend/components/legend-overflow-menu/legend-overflow-menu.tsx +84 -0
- package/src/legend/components/legend-panel-menu/legend-panel-menu.tsx +23 -0
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +2 -1
- package/src/legend/components/legend-row/legend-row.test.tsx +304 -85
- package/src/legend/components/legend-row/legend-row.tsx +73 -50
- package/src/legend/components/legend-row/styles.ts +54 -1
- package/src/legend/components/legend-row-menu/legend-row-menu-items.tsx +57 -0
- package/src/legend/components/legend-row-menu/legend-row-menu.tsx +12 -50
- package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +25 -17
- package/src/legend/components/legend-sortable/contexts.ts +12 -0
- package/src/legend/components/legend-sortable/legend-sortable.test.tsx +144 -0
- package/src/legend/components/legend-sortable/legend-sortable.tsx +195 -0
- package/src/legend/components/legend-sortable/sortable-entity.tsx +91 -0
- package/src/legend/components/legend-sortable/sortable-ids.ts +17 -0
- package/src/legend/components/legend-sortable/styles.ts +56 -0
- package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +14 -8
- package/src/legend/components/legend-visible-layers/legend-visible-layers.test.tsx +86 -0
- package/src/legend/components/legend-visible-layers/legend-visible-layers.tsx +135 -0
- package/src/legend/components/legend-visible-layers/styles.ts +60 -0
- package/src/legend/components/run-async-menu-action.ts +20 -0
- package/src/legend/index.ts +59 -4
- package/src/legend/provider/labels.ts +43 -1
- package/src/legend/stores/index.ts +4 -0
- package/src/legend/stores/legend-store-registry.ts +60 -0
- package/src/legend/stores/legend-store.test.ts +116 -0
- package/src/legend/stores/selectors.ts +64 -2
- package/src/legend/stores/types.ts +11 -0
- package/src/widgets/actions/download/exports.test.tsx +14 -1
- package/dist/selectors-DwHj6mE2.js +0 -388
- package/dist/selectors-DwHj6mE2.js.map +0 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// Composition shell
|
|
2
1
|
export { LegendGroup } from './legend-group/legend-group'
|
|
3
2
|
export type { LegendGroupProps } from './legend-group/legend-group'
|
|
4
3
|
export { LegendRow } from './legend-row/legend-row'
|
|
@@ -8,12 +7,13 @@ export type { LegendActionsProps } from './legend-actions/legend-actions'
|
|
|
8
7
|
export {
|
|
9
8
|
LegendRowContext,
|
|
10
9
|
LegendGroupContext,
|
|
10
|
+
LegendMenuContext,
|
|
11
11
|
useLegendRow,
|
|
12
12
|
useLegendGroupId,
|
|
13
|
+
useLegendMenu,
|
|
13
14
|
} from './contexts'
|
|
14
|
-
export type { LegendRowContextValue } from './contexts'
|
|
15
|
+
export type { LegendRowContextValue, LegendMenuController } from './contexts'
|
|
15
16
|
|
|
16
|
-
// Store-connected actions & controls (context-driven)
|
|
17
17
|
export { LegendVisibilityToggle } from './legend-visibility-toggle/legend-visibility-toggle'
|
|
18
18
|
export { LegendRowMenu } from './legend-row-menu/legend-row-menu'
|
|
19
19
|
export { LegendZoomTo } from './legend-row-menu/legend-zoom-to'
|
|
@@ -21,7 +21,6 @@ export { LegendOpacity } from './legend-opacity/legend-opacity'
|
|
|
21
21
|
export { LegendConfigSelect } from './legend-config-select/legend-config-select'
|
|
22
22
|
export { LegendItem } from './legend-item/legend-item'
|
|
23
23
|
|
|
24
|
-
// Item renderers (pure UI)
|
|
25
24
|
export { LegendItemUI } from './legend-item/legend-item-ui'
|
|
26
25
|
export type { LegendItemUIProps } from './legend-item/legend-item-ui'
|
|
27
26
|
export {
|
|
@@ -15,9 +15,10 @@ export interface LegendActionsProps {
|
|
|
15
15
|
/**
|
|
16
16
|
* Header actions slot for `Legend.Row` / `Legend.Group` (widgets-v2 `Actions`
|
|
17
17
|
* parity): the wrapper partitions its children by component identity and
|
|
18
|
-
* renders this slot inside the header,
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* renders this slot inside the header, wrapped in the hover-fade group
|
|
19
|
+
* (`PsLegend-rowFade` in rows, `PsLegend-groupFade` in group headers —
|
|
20
|
+
* resolved from the nearest context). Compose the ⋮ menu last so it sits
|
|
21
|
+
* rightmost, per design.
|
|
21
22
|
*
|
|
22
23
|
* @experimental This API is new and may change in a future release.
|
|
23
24
|
*/
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { afterEach, describe, it, expect } from 'vitest'
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
3
|
import { LegendProvider } from '../../provider'
|
|
4
4
|
import { LegendGroup } from './legend-group'
|
|
5
5
|
import { LegendRow } from '../legend-row/legend-row'
|
|
6
6
|
import { LegendActions } from '../legend-actions/legend-actions'
|
|
7
7
|
import { LegendVisibilityToggle } from '../legend-visibility-toggle/legend-visibility-toggle'
|
|
8
|
+
import { LegendGroupMenu } from '../legend-group-menu/legend-group-menu'
|
|
9
|
+
import {
|
|
10
|
+
LegendShowOnlyGroup,
|
|
11
|
+
LegendShowAllGroups,
|
|
12
|
+
LegendCollapseAllGroups,
|
|
13
|
+
LegendZoomToGroup,
|
|
14
|
+
} from '../legend-group-menu/legend-group-menu-items'
|
|
15
|
+
import { LegendShowAllLayers } from '../legend-row-menu/legend-row-menu-items'
|
|
8
16
|
import {
|
|
9
17
|
clearAllLegendStores,
|
|
10
18
|
getLegendStore,
|
|
@@ -70,10 +78,16 @@ describe('<LegendGroup> composition', () => {
|
|
|
70
78
|
expect(state.layers.b!.visible).toBe(true)
|
|
71
79
|
})
|
|
72
80
|
|
|
73
|
-
it('toggles group collapse
|
|
81
|
+
it('clicking the title row toggles group collapse (accordion header)', () => {
|
|
74
82
|
renderGroup('g-col')
|
|
75
|
-
|
|
83
|
+
const header = screen.getByRole('button', { name: 'Demographics' })
|
|
84
|
+
expect(header.getAttribute('aria-expanded')).toBe('true')
|
|
85
|
+
fireEvent.click(header)
|
|
76
86
|
expect(getLegendStore('g-col').getState().groups.g1!.collapsed).toBe(true)
|
|
87
|
+
expect(header.getAttribute('aria-expanded')).toBe('false')
|
|
88
|
+
// Keyboard parity: Enter re-expands.
|
|
89
|
+
fireEvent.keyDown(header, { key: 'Enter' })
|
|
90
|
+
expect(getLegendStore('g-col').getState().groups.g1!.collapsed).toBe(false)
|
|
77
91
|
})
|
|
78
92
|
|
|
79
93
|
it('shows the indeterminate icon (mixed) when only some layers are hidden', () => {
|
|
@@ -101,15 +115,158 @@ describe('<LegendGroup> composition', () => {
|
|
|
101
115
|
expect(screen.getByLabelText('Show group').className).toContain('active')
|
|
102
116
|
})
|
|
103
117
|
|
|
104
|
-
it('hiding the group collapses it, but the
|
|
118
|
+
it('hiding the group collapses it, but the title row stays usable', () => {
|
|
105
119
|
renderGroup('g-hide')
|
|
106
120
|
fireEvent.click(screen.getByLabelText('Hide group'))
|
|
107
121
|
expect(getLegendStore('g-hide').getState().groups.g1!.collapsed).toBe(true)
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
fireEvent.click(chevron)
|
|
122
|
+
// Groups are never force-collapsed by visibility — a hidden group can be
|
|
123
|
+
// peeked into to re-show layers one by one.
|
|
124
|
+
const header = screen.getByRole('button', { name: 'Demographics' })
|
|
125
|
+
fireEvent.click(header)
|
|
113
126
|
expect(getLegendStore('g-hide').getState().groups.g1!.collapsed).toBe(false)
|
|
114
127
|
})
|
|
115
128
|
})
|
|
129
|
+
|
|
130
|
+
describe('<LegendGroupMenu> + built-in items', () => {
|
|
131
|
+
function renderWithMenu(id: string) {
|
|
132
|
+
return render(
|
|
133
|
+
<LegendProvider
|
|
134
|
+
id={id}
|
|
135
|
+
layers={[...layers, { id: 'solo', name: 'Solo', variables: [cat] }]}
|
|
136
|
+
groups={[...groups, { id: 'g2', label: 'Business' }]}
|
|
137
|
+
>
|
|
138
|
+
<LegendGroup groupId='g1'>
|
|
139
|
+
<LegendActions>
|
|
140
|
+
<LegendVisibilityToggle />
|
|
141
|
+
<LegendGroupMenu>
|
|
142
|
+
<LegendShowOnlyGroup />
|
|
143
|
+
<LegendShowAllGroups />
|
|
144
|
+
<LegendCollapseAllGroups />
|
|
145
|
+
<LegendZoomToGroup onZoomTo={zoomSpy} />
|
|
146
|
+
</LegendGroupMenu>
|
|
147
|
+
</LegendActions>
|
|
148
|
+
</LegendGroup>
|
|
149
|
+
</LegendProvider>,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
const zoomSpy = vi.fn()
|
|
153
|
+
|
|
154
|
+
it('Show only this group hides other groups and ungrouped layers', () => {
|
|
155
|
+
renderWithMenu('gm-only')
|
|
156
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
157
|
+
fireEvent.click(screen.getByText('Show only this group'))
|
|
158
|
+
const state = getLegendStore('gm-only').getState()
|
|
159
|
+
expect(state.layers.a!.visible).toBe(true)
|
|
160
|
+
expect(state.layers.solo!.visible).toBe(false)
|
|
161
|
+
// Menu closed after the pick.
|
|
162
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
163
|
+
|
|
164
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
165
|
+
fireEvent.click(screen.getByText('Show all groups'))
|
|
166
|
+
expect(getLegendStore('gm-only').getState().layers.solo!.visible).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('Collapse all groups toggles and flips its label', () => {
|
|
170
|
+
renderWithMenu('gm-collapse')
|
|
171
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
172
|
+
fireEvent.click(screen.getByText('Collapse all groups'))
|
|
173
|
+
const state = getLegendStore('gm-collapse').getState()
|
|
174
|
+
expect(state.groups.g1!.collapsed).toBe(true)
|
|
175
|
+
expect(state.groups.g2!.collapsed).toBe(true)
|
|
176
|
+
// Reopen: the item now offers Expand.
|
|
177
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
178
|
+
fireEvent.click(screen.getByText('Expand all groups'))
|
|
179
|
+
expect(getLegendStore('gm-collapse').getState().groups.g1!.collapsed).toBe(
|
|
180
|
+
false,
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('Zoom to group fires the composed handler with the group id', async () => {
|
|
185
|
+
renderWithMenu('gm-zoom')
|
|
186
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
187
|
+
fireEvent.click(screen.getByText('Zoom to group'))
|
|
188
|
+
expect(zoomSpy).toHaveBeenCalledWith('g1')
|
|
189
|
+
await vi.waitFor(() => {
|
|
190
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('Zoom to group keeps the menu open with loading feedback while pending', async () => {
|
|
195
|
+
let resolveZoom!: () => void
|
|
196
|
+
const pendingZoom = vi.fn(
|
|
197
|
+
() =>
|
|
198
|
+
new Promise<void>((resolve) => {
|
|
199
|
+
resolveZoom = resolve
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
render(
|
|
203
|
+
<LegendProvider id='gm-zoom-busy' layers={layers} groups={groups}>
|
|
204
|
+
<LegendGroup groupId='g1'>
|
|
205
|
+
<LegendActions>
|
|
206
|
+
<LegendGroupMenu>
|
|
207
|
+
<LegendZoomToGroup onZoomTo={pendingZoom} />
|
|
208
|
+
</LegendGroupMenu>
|
|
209
|
+
</LegendActions>
|
|
210
|
+
</LegendGroup>
|
|
211
|
+
</LegendProvider>,
|
|
212
|
+
)
|
|
213
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
214
|
+
fireEvent.click(screen.getByText('Zoom to group'))
|
|
215
|
+
expect(pendingZoom).toHaveBeenCalledWith('g1')
|
|
216
|
+
const item = await screen.findByRole('menuitem', { name: /Loading/ })
|
|
217
|
+
expect(item.getAttribute('aria-disabled')).toBe('true')
|
|
218
|
+
expect(screen.getByRole('progressbar')).toBeDefined()
|
|
219
|
+
|
|
220
|
+
resolveZoom()
|
|
221
|
+
await vi.waitFor(() => {
|
|
222
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('Show all layers composed directly in the group menu targets only that group', () => {
|
|
227
|
+
render(
|
|
228
|
+
<LegendProvider
|
|
229
|
+
id='gm-showall-group'
|
|
230
|
+
layers={[...layers, { id: 'solo', name: 'Solo', variables: [cat] }]}
|
|
231
|
+
groups={groups}
|
|
232
|
+
>
|
|
233
|
+
<LegendGroup groupId='g1'>
|
|
234
|
+
<LegendActions>
|
|
235
|
+
<LegendVisibilityToggle />
|
|
236
|
+
<LegendGroupMenu>
|
|
237
|
+
<LegendShowAllLayers />
|
|
238
|
+
</LegendGroupMenu>
|
|
239
|
+
</LegendActions>
|
|
240
|
+
</LegendGroup>
|
|
241
|
+
</LegendProvider>,
|
|
242
|
+
)
|
|
243
|
+
getLegendStore('gm-showall-group').getState().setVisibility('a', false)
|
|
244
|
+
getLegendStore('gm-showall-group').getState().setVisibility('b', false)
|
|
245
|
+
getLegendStore('gm-showall-group').getState().setVisibility('solo', false)
|
|
246
|
+
|
|
247
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
248
|
+
fireEvent.click(screen.getByText('Show all layers'))
|
|
249
|
+
const state = getLegendStore('gm-showall-group').getState()
|
|
250
|
+
expect(state.layers.a!.visible).toBe(true)
|
|
251
|
+
expect(state.layers.b!.visible).toBe(true)
|
|
252
|
+
// Ungrouped sibling untouched — this instance only affects g1.
|
|
253
|
+
expect(state.layers.solo!.visible).toBe(false)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('panel-wide group actions with no groups', () => {
|
|
258
|
+
it('ShowAllGroups / CollapseAllGroups render null when the legend has no groups', () => {
|
|
259
|
+
render(
|
|
260
|
+
<LegendProvider
|
|
261
|
+
id='no-groups'
|
|
262
|
+
layers={[{ id: 'solo', name: 'Solo', variables: [cat] }]}
|
|
263
|
+
groups={[]}
|
|
264
|
+
>
|
|
265
|
+
<LegendShowAllGroups />
|
|
266
|
+
<LegendCollapseAllGroups />
|
|
267
|
+
</LegendProvider>,
|
|
268
|
+
)
|
|
269
|
+
expect(screen.queryByText('Show all groups')).toBeNull()
|
|
270
|
+
expect(screen.queryByText('Collapse all groups')).toBeNull()
|
|
271
|
+
})
|
|
272
|
+
})
|
|
@@ -1,34 +1,37 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
|
-
import { Box, Collapse,
|
|
2
|
+
import { Box, Collapse, Typography } from '@mui/material'
|
|
3
3
|
import {
|
|
4
4
|
LayersOutlined as GroupIcon,
|
|
5
5
|
ExpandMore as ExpandMoreIcon,
|
|
6
|
-
ExpandLess as ExpandLessIcon,
|
|
7
6
|
} from '@mui/icons-material'
|
|
7
|
+
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|
8
8
|
import { SmartTooltip } from '../../../components/smart-tooltip/smart-tooltip'
|
|
9
|
-
import {
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
selectGroupVisibility,
|
|
11
|
+
selectLayersByGroup,
|
|
12
|
+
useLegendId,
|
|
13
|
+
useLegendShallow,
|
|
14
|
+
useLegendStore,
|
|
15
|
+
} from '../../stores'
|
|
16
|
+
import { accordionKeyDown } from '../a11y'
|
|
11
17
|
import { LegendGroupContext } from '../contexts'
|
|
12
18
|
import { partitionLegendChildren } from '../legend-actions/legend-actions'
|
|
19
|
+
import { useLegendSortable } from '../legend-sortable/contexts'
|
|
20
|
+
import {
|
|
21
|
+
SortableEntity,
|
|
22
|
+
type SortableEntityRenderProps,
|
|
23
|
+
} from '../legend-sortable/sortable-entity'
|
|
24
|
+
import { toSortableId } from '../legend-sortable/sortable-ids'
|
|
13
25
|
import { styles } from './styles'
|
|
14
26
|
|
|
15
27
|
export interface LegendGroupProps {
|
|
16
28
|
groupId: string
|
|
17
|
-
/**
|
|
18
|
-
* Composition slots: `<Legend.Actions>` children render in the sticky
|
|
19
|
-
* header (hover-fade); everything else is the collapsible body — the
|
|
20
|
-
* consumer renders the member `<Legend.Row>`s explicitly (use
|
|
21
|
-
* `selectLayersByGroup` to resolve them).
|
|
22
|
-
*/
|
|
29
|
+
/** `<Legend.Actions>` in the sticky header; member `<Legend.Row>`s in the body. */
|
|
23
30
|
children?: ReactNode
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
|
-
* Store-connected group wrapper
|
|
28
|
-
* sticky 48px header — group icon + label from the store, the partitioned
|
|
29
|
-
* `<Legend.Actions>` slot, and the collapse chevron — over the collapsible
|
|
30
|
-
* body. Provides the group composition context so `Legend.VisibilityToggle`
|
|
31
|
-
* placed in the header becomes the tri-state group eye.
|
|
34
|
+
* Store-connected group wrapper. Draggable inside `<Legend.Sortable>`.
|
|
32
35
|
*
|
|
33
36
|
* @experimental This API is new and may change in a future release.
|
|
34
37
|
*/
|
|
@@ -36,53 +39,89 @@ export function LegendGroup({ groupId, children }: LegendGroupProps) {
|
|
|
36
39
|
const id = useLegendId()
|
|
37
40
|
const group = useLegendShallow(id, (s) => s.groups[groupId])
|
|
38
41
|
const setGroupCollapsed = useLegendShallow(id, (s) => s.setGroupCollapsed)
|
|
39
|
-
const
|
|
42
|
+
const hidden = useLegendStore(
|
|
43
|
+
id,
|
|
44
|
+
(s) => selectGroupVisibility(s, groupId) === 'hidden',
|
|
45
|
+
)
|
|
46
|
+
const sortable = useLegendSortable()
|
|
47
|
+
// Member ids only — useShallow ignores opacity/visibility noise on siblings.
|
|
48
|
+
const memberSortableIds = useLegendShallow(id, (s) =>
|
|
49
|
+
selectLayersByGroup(s, groupId).map((l) => toSortableId('layer', l.id)),
|
|
50
|
+
)
|
|
40
51
|
|
|
41
52
|
if (!group) return null
|
|
42
53
|
|
|
43
54
|
const { actions, body } = partitionLegendChildren(children)
|
|
55
|
+
const toggle = () => setGroupCollapsed(groupId, !group.collapsed)
|
|
44
56
|
|
|
45
|
-
|
|
57
|
+
const renderGroup = (sortableProps?: SortableEntityRenderProps) => (
|
|
46
58
|
<LegendGroupContext.Provider value={groupId}>
|
|
47
|
-
<Box
|
|
59
|
+
<Box
|
|
60
|
+
ref={sortableProps?.rootRef}
|
|
61
|
+
style={sortableProps?.rootStyle}
|
|
62
|
+
sx={[
|
|
63
|
+
styles.group,
|
|
64
|
+
sortableProps?.dragging === true && styles.groupDragging,
|
|
65
|
+
]}
|
|
66
|
+
>
|
|
48
67
|
<Box sx={styles.header}>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)}
|
|
64
|
-
</SmartTooltip>
|
|
68
|
+
{sortableProps?.handle}
|
|
69
|
+
{/* Whole title area toggles collapse (accordion header); the
|
|
70
|
+
actions box is a sibling outside the click target. */}
|
|
71
|
+
<Box
|
|
72
|
+
sx={styles.titleArea}
|
|
73
|
+
role='button'
|
|
74
|
+
tabIndex={0}
|
|
75
|
+
aria-expanded={!group.collapsed}
|
|
76
|
+
onClick={toggle}
|
|
77
|
+
onKeyDown={accordionKeyDown(toggle)}
|
|
78
|
+
>
|
|
79
|
+
<Box sx={styles.groupIcon}>
|
|
80
|
+
<GroupIcon fontSize='small' />
|
|
81
|
+
</Box>
|
|
65
82
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
) : (
|
|
78
|
-
<ExpandLessIcon fontSize='small' />
|
|
83
|
+
<SmartTooltip title={group.label} dependencies={[group.label]}>
|
|
84
|
+
{({ ref }) => (
|
|
85
|
+
<Typography
|
|
86
|
+
ref={ref}
|
|
87
|
+
variant='button'
|
|
88
|
+
fontWeight='bold'
|
|
89
|
+
color={hidden ? 'text.disabled' : 'text.primary'}
|
|
90
|
+
sx={styles.label}
|
|
91
|
+
>
|
|
92
|
+
{group.label}
|
|
93
|
+
</Typography>
|
|
79
94
|
)}
|
|
80
|
-
</
|
|
95
|
+
</SmartTooltip>
|
|
96
|
+
|
|
97
|
+
{/* Informative only (the row is the button); hidden on touch. */}
|
|
98
|
+
<ExpandMoreIcon
|
|
99
|
+
sx={[styles.chevron, group.collapsed && styles.chevronCollapsed]}
|
|
100
|
+
/>
|
|
81
101
|
</Box>
|
|
102
|
+
|
|
103
|
+
<Box sx={styles.actions}>{actions}</Box>
|
|
82
104
|
</Box>
|
|
83
105
|
|
|
84
|
-
<Collapse in={!group.collapsed}>
|
|
106
|
+
<Collapse in={!group.collapsed}>
|
|
107
|
+
{sortable ? (
|
|
108
|
+
<SortableContext
|
|
109
|
+
items={memberSortableIds}
|
|
110
|
+
strategy={verticalListSortingStrategy}
|
|
111
|
+
>
|
|
112
|
+
{body}
|
|
113
|
+
</SortableContext>
|
|
114
|
+
) : (
|
|
115
|
+
body
|
|
116
|
+
)}
|
|
117
|
+
</Collapse>
|
|
85
118
|
</Box>
|
|
86
119
|
</LegendGroupContext.Provider>
|
|
87
120
|
)
|
|
121
|
+
|
|
122
|
+
return sortable ? (
|
|
123
|
+
<SortableEntity kind='group' id={groupId} render={renderGroup} />
|
|
124
|
+
) : (
|
|
125
|
+
renderGroup()
|
|
126
|
+
)
|
|
88
127
|
}
|
|
@@ -38,6 +38,28 @@ export const styles = {
|
|
|
38
38
|
'&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
|
|
39
39
|
opacity: 1,
|
|
40
40
|
},
|
|
41
|
+
// Drag handle (Legend.Sortable): absolutely positioned (no reserved flex
|
|
42
|
+
// slot, so it never shifts the title/icon); the button inside fades in
|
|
43
|
+
// on hover/focus and is always visible on touch devices.
|
|
44
|
+
'& .PsLegend-dragHandle-group > *': {
|
|
45
|
+
opacity: 0,
|
|
46
|
+
transition: ({ transitions }: Theme) =>
|
|
47
|
+
transitions.create('opacity', {
|
|
48
|
+
duration: transitions.duration.standard,
|
|
49
|
+
easing: transitions.easing.easeInOut,
|
|
50
|
+
}),
|
|
51
|
+
'@media (hover: none)': {
|
|
52
|
+
opacity: 1,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
'&:hover .PsLegend-dragHandle-group > *, &:focus-within .PsLegend-dragHandle-group > *':
|
|
56
|
+
{
|
|
57
|
+
opacity: 1,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
// The original stays in place, dimmed, while its overlay clone is dragged.
|
|
61
|
+
groupDragging: {
|
|
62
|
+
opacity: 0.4,
|
|
41
63
|
},
|
|
42
64
|
// Figma: 48px tall, icon at 12px from the left, 8px gap to the label,
|
|
43
65
|
// actions right-aligned with ~8px edge inset (the small icon-button padding
|
|
@@ -56,19 +78,54 @@ export const styles = {
|
|
|
56
78
|
// Above the sticky row headers (2), which in turn sit above MUI's
|
|
57
79
|
// z-index-1 form internals in row content.
|
|
58
80
|
zIndex: 3,
|
|
59
|
-
|
|
81
|
+
// Distinct from the row header / panel body (both `background.paper`) so
|
|
82
|
+
// the group header visually separates from the rest.
|
|
83
|
+
backgroundColor: 'background.default',
|
|
84
|
+
},
|
|
85
|
+
// Accordion click target: everything in the header but the actions box.
|
|
86
|
+
// Non-native button (role='button'), so it carries its own focus ring.
|
|
87
|
+
titleArea: {
|
|
88
|
+
flex: 1,
|
|
89
|
+
minWidth: 0,
|
|
90
|
+
display: 'flex',
|
|
91
|
+
alignItems: 'center',
|
|
92
|
+
gap: 1,
|
|
93
|
+
cursor: 'pointer',
|
|
94
|
+
userSelect: 'none',
|
|
95
|
+
'&:focus-visible': {
|
|
96
|
+
outline: '2px solid',
|
|
97
|
+
outlineColor: 'primary.main',
|
|
98
|
+
outlineOffset: -2,
|
|
99
|
+
},
|
|
60
100
|
},
|
|
61
101
|
groupIcon: {
|
|
62
102
|
display: 'flex',
|
|
63
103
|
color: 'text.secondary',
|
|
64
104
|
},
|
|
65
105
|
label: {
|
|
66
|
-
flex: 1,
|
|
67
106
|
minWidth: 0,
|
|
68
107
|
overflow: 'hidden',
|
|
69
108
|
textOverflow: 'ellipsis',
|
|
70
109
|
whiteSpace: 'nowrap',
|
|
71
110
|
},
|
|
111
|
+
// Informative collapse indicator beside the title (the whole title area is
|
|
112
|
+
// the button). Points down when expanded, right when collapsed. Touch
|
|
113
|
+
// devices don't get it — the title row is the affordance there.
|
|
114
|
+
chevron: {
|
|
115
|
+
fontSize: 16,
|
|
116
|
+
color: 'text.secondary',
|
|
117
|
+
flexShrink: 0,
|
|
118
|
+
transition: ({ transitions }: Theme) =>
|
|
119
|
+
transitions.create('transform', {
|
|
120
|
+
duration: transitions.duration.shorter,
|
|
121
|
+
}),
|
|
122
|
+
'@media (hover: none)': {
|
|
123
|
+
display: 'none',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
chevronCollapsed: {
|
|
127
|
+
transform: 'rotate(-90deg)',
|
|
128
|
+
},
|
|
72
129
|
// Icon buttons match the row header's 24×24 (2px padding + 20px icon).
|
|
73
130
|
actions: {
|
|
74
131
|
display: 'flex',
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { MouseEvent } from 'react'
|
|
2
|
+
import { CircularProgress, MenuItem } from '@mui/material'
|
|
3
|
+
import {
|
|
4
|
+
getLegendStore,
|
|
5
|
+
selectAllGroupsCollapsed,
|
|
6
|
+
selectGroupVisibility,
|
|
7
|
+
selectHasGroups,
|
|
8
|
+
selectLayersByGroup,
|
|
9
|
+
selectOrderedGroups,
|
|
10
|
+
useLegendId,
|
|
11
|
+
useLegendStore,
|
|
12
|
+
} from '../../stores'
|
|
13
|
+
import { useLegendConfig } from '../../provider'
|
|
14
|
+
import { useLegendGroupId, useLegendMenu } from '../contexts'
|
|
15
|
+
import { runAsyncMenuAction } from '../run-async-menu-action'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Show only this group (hide all other layers).
|
|
19
|
+
*
|
|
20
|
+
* @experimental This API is new and may change in a future release.
|
|
21
|
+
*/
|
|
22
|
+
export function LegendShowOnlyGroup() {
|
|
23
|
+
const id = useLegendId()
|
|
24
|
+
const groupId = useLegendGroupId()
|
|
25
|
+
const { labels } = useLegendConfig()
|
|
26
|
+
|
|
27
|
+
if (groupId === null) return null
|
|
28
|
+
|
|
29
|
+
const showOnly = () => {
|
|
30
|
+
const state = getLegendStore(id).getState()
|
|
31
|
+
for (const group of selectOrderedGroups(state)) {
|
|
32
|
+
state.setGroupVisibility(group.id, group.id === groupId)
|
|
33
|
+
}
|
|
34
|
+
// Exclusive: the ungrouped layers hide too.
|
|
35
|
+
for (const layer of selectLayersByGroup(state, undefined)) {
|
|
36
|
+
state.setVisibility(layer.id, false)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return <MenuItem onClick={showOnly}>{labels.showOnlyGroup}</MenuItem>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Show every layer (panel-level `Legend.PanelMenu` only).
|
|
45
|
+
*
|
|
46
|
+
* @experimental This API is new and may change in a future release.
|
|
47
|
+
*/
|
|
48
|
+
export function LegendShowAllGroups() {
|
|
49
|
+
const id = useLegendId()
|
|
50
|
+
const hasGroups = useLegendStore(id, selectHasGroups)
|
|
51
|
+
const { labels } = useLegendConfig()
|
|
52
|
+
|
|
53
|
+
if (!hasGroups) return null
|
|
54
|
+
|
|
55
|
+
const showAll = () => {
|
|
56
|
+
const state = getLegendStore(id).getState()
|
|
57
|
+
for (const group of selectOrderedGroups(state)) {
|
|
58
|
+
state.setGroupVisibility(group.id, true)
|
|
59
|
+
}
|
|
60
|
+
for (const layer of selectLayersByGroup(state, undefined)) {
|
|
61
|
+
state.setVisibility(layer.id, true)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return <MenuItem onClick={showAll}>{labels.showAllGroups}</MenuItem>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Collapse / expand all groups (panel-level `Legend.PanelMenu` only).
|
|
70
|
+
*
|
|
71
|
+
* @experimental This API is new and may change in a future release.
|
|
72
|
+
*/
|
|
73
|
+
export function LegendCollapseAllGroups() {
|
|
74
|
+
const id = useLegendId()
|
|
75
|
+
const hasGroups = useLegendStore(id, selectHasGroups)
|
|
76
|
+
const allCollapsed = useLegendStore(id, selectAllGroupsCollapsed)
|
|
77
|
+
const { labels } = useLegendConfig()
|
|
78
|
+
|
|
79
|
+
if (!hasGroups) return null
|
|
80
|
+
|
|
81
|
+
const toggleAll = () => {
|
|
82
|
+
const state = getLegendStore(id).getState()
|
|
83
|
+
const collapsed = selectAllGroupsCollapsed(state)
|
|
84
|
+
for (const group of selectOrderedGroups(state)) {
|
|
85
|
+
state.setGroupCollapsed(group.id, !collapsed)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<MenuItem onClick={toggleAll}>
|
|
91
|
+
{allCollapsed ? labels.expandAllGroups : labels.collapseAllGroups}
|
|
92
|
+
</MenuItem>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface LegendZoomToGroupProps {
|
|
97
|
+
/** Fit viewport to the group; may return a Promise for async menu feedback. */
|
|
98
|
+
onZoomTo: (groupId: string) => void | Promise<void>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Zoom-to-group menu item for `Legend.GroupMenu`. Self-hides when all members are hidden.
|
|
103
|
+
*
|
|
104
|
+
* @experimental This API is new and may change in a future release.
|
|
105
|
+
*/
|
|
106
|
+
export function LegendZoomToGroup({ onZoomTo }: LegendZoomToGroupProps) {
|
|
107
|
+
const id = useLegendId()
|
|
108
|
+
const groupId = useLegendGroupId()
|
|
109
|
+
const menu = useLegendMenu()
|
|
110
|
+
const hidden = useLegendStore(
|
|
111
|
+
id,
|
|
112
|
+
(s) => groupId !== null && selectGroupVisibility(s, groupId) === 'hidden',
|
|
113
|
+
)
|
|
114
|
+
const { labels } = useLegendConfig()
|
|
115
|
+
|
|
116
|
+
if (groupId === null || hidden) return null
|
|
117
|
+
|
|
118
|
+
const busy = menu?.busy ?? false
|
|
119
|
+
|
|
120
|
+
const handleClick = (e: MouseEvent) => {
|
|
121
|
+
e.stopPropagation()
|
|
122
|
+
if (!menu) return
|
|
123
|
+
void runAsyncMenuAction(menu, () => onZoomTo(groupId))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<MenuItem disabled={busy} onClick={handleClick}>
|
|
128
|
+
{busy && <CircularProgress size={16} sx={{ mr: 1 }} />}
|
|
129
|
+
{busy ? labels.loading : labels.zoomToGroup}
|
|
130
|
+
</MenuItem>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useLegendConfig } from '../../provider'
|
|
3
|
+
import { useLegendGroupId } from '../contexts'
|
|
4
|
+
import { LegendOverflowMenu } from '../legend-overflow-menu/legend-overflow-menu'
|
|
5
|
+
|
|
6
|
+
export interface LegendGroupMenuProps {
|
|
7
|
+
/** Menu items; async items use `stopPropagation` + `runAsyncMenuAction`. */
|
|
8
|
+
children?: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Overflow (⋮) menu for a group header. Renders nothing outside a `Legend.Group`.
|
|
13
|
+
*
|
|
14
|
+
* @experimental This API is new and may change in a future release.
|
|
15
|
+
*/
|
|
16
|
+
export function LegendGroupMenu({ children }: LegendGroupMenuProps) {
|
|
17
|
+
const groupId = useLegendGroupId()
|
|
18
|
+
const { labels } = useLegendConfig()
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<LegendOverflowMenu gate={groupId !== null} ariaLabel={labels.groupOptions}>
|
|
22
|
+
{children}
|
|
23
|
+
</LegendOverflowMenu>
|
|
24
|
+
)
|
|
25
|
+
}
|