@carto/ps-react-ui 4.15.0 → 4.17.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-Bo8U_qWM.js +468 -0
- package/dist/legend-store-registry-Bo8U_qWM.js.map +1 -0
- package/dist/legend.js +1485 -798
- 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 +19 -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 +191 -9
- package/src/legend/components/legend-group/legend-group.tsx +88 -52
- 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 +82 -2
- package/src/legend/stores/legend-store.test.ts +168 -0
- package/src/legend/stores/selectors.ts +64 -2
- package/src/legend/stores/types.ts +19 -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
|
@@ -3,13 +3,11 @@ import { createContext, useContext } from 'react'
|
|
|
3
3
|
/**
|
|
4
4
|
* Per-row composition context provided by `Legend.Row`. Actions placed inside
|
|
5
5
|
* the row resolve their target layer from here instead of taking a `layerId`
|
|
6
|
-
* prop, and the opacity compound
|
|
7
|
-
*
|
|
6
|
+
* prop, and the opacity compound (`Legend.Opacity.Item` / `.Inline`) shares
|
|
7
|
+
* its open state through it.
|
|
8
8
|
*/
|
|
9
9
|
export interface LegendRowContextValue {
|
|
10
10
|
layerId: string
|
|
11
|
-
/** The row's sticky header element — anchor for `Legend.Opacity.Popover`. */
|
|
12
|
-
headerEl: HTMLElement | null
|
|
13
11
|
opacityOpen: boolean
|
|
14
12
|
setOpacityOpen: (open: boolean) => void
|
|
15
13
|
}
|
|
@@ -30,3 +28,30 @@ export const LegendGroupContext = createContext<string | null>(null)
|
|
|
30
28
|
export function useLegendGroupId(): string | null {
|
|
31
29
|
return useContext(LegendGroupContext)
|
|
32
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Overflow-menu controller for `Legend.RowMenu` / `Legend.GroupMenu` /
|
|
34
|
+
* `Legend.PanelMenu`. Async items (e.g. `Legend.ZoomTo`) use `setBusy` to keep
|
|
35
|
+
* the menu open and show in-item loading feedback, then `close()` when the
|
|
36
|
+
* handler settles. Prefer `runAsyncMenuAction` over calling these by hand.
|
|
37
|
+
*/
|
|
38
|
+
export interface LegendMenuController {
|
|
39
|
+
/** Reactive busy flag for UI (spinner / disabled). */
|
|
40
|
+
busy: boolean
|
|
41
|
+
/**
|
|
42
|
+
* Synchronous busy check (ref-backed) — use this to gate clicks so a second
|
|
43
|
+
* press before React re-renders cannot start another async action.
|
|
44
|
+
*/
|
|
45
|
+
isBusy: () => boolean
|
|
46
|
+
setBusy: (busy: boolean) => void
|
|
47
|
+
close: () => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const LegendMenuContext = createContext<LegendMenuController | null>(
|
|
51
|
+
null,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
/** Nearest overflow-menu controller, or `null` outside a row/group menu. */
|
|
55
|
+
export function useLegendMenu(): LegendMenuController | null {
|
|
56
|
+
return useContext(LegendMenuContext)
|
|
57
|
+
}
|
|
@@ -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,183 @@ 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
|
})
|
|
128
|
+
|
|
129
|
+
it('renders no icon when the group has no icon configured', () => {
|
|
130
|
+
renderGroup('g-icon-none')
|
|
131
|
+
expect(screen.queryByTestId('LayersOutlinedIcon')).toBeNull()
|
|
132
|
+
expect(screen.queryByTestId('my-icon')).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('renders the custom icon when provided', () => {
|
|
136
|
+
render(
|
|
137
|
+
<LegendProvider
|
|
138
|
+
id='g-icon-custom'
|
|
139
|
+
layers={layers}
|
|
140
|
+
groups={[
|
|
141
|
+
{
|
|
142
|
+
id: 'g1',
|
|
143
|
+
label: 'Demographics',
|
|
144
|
+
icon: <span data-testid='my-icon' />,
|
|
145
|
+
},
|
|
146
|
+
]}
|
|
147
|
+
>
|
|
148
|
+
<LegendGroup groupId='g1' />
|
|
149
|
+
</LegendProvider>,
|
|
150
|
+
)
|
|
151
|
+
expect(screen.getByTestId('my-icon')).toBeTruthy()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('<LegendGroupMenu> + built-in items', () => {
|
|
156
|
+
function renderWithMenu(id: string) {
|
|
157
|
+
return render(
|
|
158
|
+
<LegendProvider
|
|
159
|
+
id={id}
|
|
160
|
+
layers={[...layers, { id: 'solo', name: 'Solo', variables: [cat] }]}
|
|
161
|
+
groups={[...groups, { id: 'g2', label: 'Business' }]}
|
|
162
|
+
>
|
|
163
|
+
<LegendGroup groupId='g1'>
|
|
164
|
+
<LegendActions>
|
|
165
|
+
<LegendVisibilityToggle />
|
|
166
|
+
<LegendGroupMenu>
|
|
167
|
+
<LegendShowOnlyGroup />
|
|
168
|
+
<LegendShowAllGroups />
|
|
169
|
+
<LegendCollapseAllGroups />
|
|
170
|
+
<LegendZoomToGroup onZoomTo={zoomSpy} />
|
|
171
|
+
</LegendGroupMenu>
|
|
172
|
+
</LegendActions>
|
|
173
|
+
</LegendGroup>
|
|
174
|
+
</LegendProvider>,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
const zoomSpy = vi.fn()
|
|
178
|
+
|
|
179
|
+
it('Show only this group hides other groups and ungrouped layers', () => {
|
|
180
|
+
renderWithMenu('gm-only')
|
|
181
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
182
|
+
fireEvent.click(screen.getByText('Show only this group'))
|
|
183
|
+
const state = getLegendStore('gm-only').getState()
|
|
184
|
+
expect(state.layers.a!.visible).toBe(true)
|
|
185
|
+
expect(state.layers.solo!.visible).toBe(false)
|
|
186
|
+
// Menu closed after the pick.
|
|
187
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
188
|
+
|
|
189
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
190
|
+
fireEvent.click(screen.getByText('Show all groups'))
|
|
191
|
+
expect(getLegendStore('gm-only').getState().layers.solo!.visible).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('Collapse all groups toggles and flips its label', () => {
|
|
195
|
+
renderWithMenu('gm-collapse')
|
|
196
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
197
|
+
fireEvent.click(screen.getByText('Collapse all groups'))
|
|
198
|
+
const state = getLegendStore('gm-collapse').getState()
|
|
199
|
+
expect(state.groups.g1!.collapsed).toBe(true)
|
|
200
|
+
expect(state.groups.g2!.collapsed).toBe(true)
|
|
201
|
+
// Reopen: the item now offers Expand.
|
|
202
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
203
|
+
fireEvent.click(screen.getByText('Expand all groups'))
|
|
204
|
+
expect(getLegendStore('gm-collapse').getState().groups.g1!.collapsed).toBe(
|
|
205
|
+
false,
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('Zoom to group fires the composed handler with the group id', async () => {
|
|
210
|
+
renderWithMenu('gm-zoom')
|
|
211
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
212
|
+
fireEvent.click(screen.getByText('Zoom to group'))
|
|
213
|
+
expect(zoomSpy).toHaveBeenCalledWith('g1')
|
|
214
|
+
await vi.waitFor(() => {
|
|
215
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('Zoom to group keeps the menu open with loading feedback while pending', async () => {
|
|
220
|
+
let resolveZoom!: () => void
|
|
221
|
+
const pendingZoom = vi.fn(
|
|
222
|
+
() =>
|
|
223
|
+
new Promise<void>((resolve) => {
|
|
224
|
+
resolveZoom = resolve
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
render(
|
|
228
|
+
<LegendProvider id='gm-zoom-busy' layers={layers} groups={groups}>
|
|
229
|
+
<LegendGroup groupId='g1'>
|
|
230
|
+
<LegendActions>
|
|
231
|
+
<LegendGroupMenu>
|
|
232
|
+
<LegendZoomToGroup onZoomTo={pendingZoom} />
|
|
233
|
+
</LegendGroupMenu>
|
|
234
|
+
</LegendActions>
|
|
235
|
+
</LegendGroup>
|
|
236
|
+
</LegendProvider>,
|
|
237
|
+
)
|
|
238
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
239
|
+
fireEvent.click(screen.getByText('Zoom to group'))
|
|
240
|
+
expect(pendingZoom).toHaveBeenCalledWith('g1')
|
|
241
|
+
const item = await screen.findByRole('menuitem', { name: /Loading/ })
|
|
242
|
+
expect(item.getAttribute('aria-disabled')).toBe('true')
|
|
243
|
+
expect(screen.getByRole('progressbar')).toBeDefined()
|
|
244
|
+
|
|
245
|
+
resolveZoom()
|
|
246
|
+
await vi.waitFor(() => {
|
|
247
|
+
expect(screen.queryByRole('menuitem')).toBeNull()
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('Show all layers composed directly in the group menu targets only that group', () => {
|
|
252
|
+
render(
|
|
253
|
+
<LegendProvider
|
|
254
|
+
id='gm-showall-group'
|
|
255
|
+
layers={[...layers, { id: 'solo', name: 'Solo', variables: [cat] }]}
|
|
256
|
+
groups={groups}
|
|
257
|
+
>
|
|
258
|
+
<LegendGroup groupId='g1'>
|
|
259
|
+
<LegendActions>
|
|
260
|
+
<LegendVisibilityToggle />
|
|
261
|
+
<LegendGroupMenu>
|
|
262
|
+
<LegendShowAllLayers />
|
|
263
|
+
</LegendGroupMenu>
|
|
264
|
+
</LegendActions>
|
|
265
|
+
</LegendGroup>
|
|
266
|
+
</LegendProvider>,
|
|
267
|
+
)
|
|
268
|
+
getLegendStore('gm-showall-group').getState().setVisibility('a', false)
|
|
269
|
+
getLegendStore('gm-showall-group').getState().setVisibility('b', false)
|
|
270
|
+
getLegendStore('gm-showall-group').getState().setVisibility('solo', false)
|
|
271
|
+
|
|
272
|
+
fireEvent.click(screen.getByLabelText('Group options'))
|
|
273
|
+
fireEvent.click(screen.getByText('Show all layers'))
|
|
274
|
+
const state = getLegendStore('gm-showall-group').getState()
|
|
275
|
+
expect(state.layers.a!.visible).toBe(true)
|
|
276
|
+
expect(state.layers.b!.visible).toBe(true)
|
|
277
|
+
// Ungrouped sibling untouched — this instance only affects g1.
|
|
278
|
+
expect(state.layers.solo!.visible).toBe(false)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('panel-wide group actions with no groups', () => {
|
|
283
|
+
it('ShowAllGroups / CollapseAllGroups render null when the legend has no groups', () => {
|
|
284
|
+
render(
|
|
285
|
+
<LegendProvider
|
|
286
|
+
id='no-groups'
|
|
287
|
+
layers={[{ id: 'solo', name: 'Solo', variables: [cat] }]}
|
|
288
|
+
groups={[]}
|
|
289
|
+
>
|
|
290
|
+
<LegendShowAllGroups />
|
|
291
|
+
<LegendCollapseAllGroups />
|
|
292
|
+
</LegendProvider>,
|
|
293
|
+
)
|
|
294
|
+
expect(screen.queryByText('Show all groups')).toBeNull()
|
|
295
|
+
expect(screen.queryByText('Collapse all groups')).toBeNull()
|
|
296
|
+
})
|
|
115
297
|
})
|
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
|
-
import { Box, Collapse,
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
ExpandMore as ExpandMoreIcon,
|
|
6
|
-
ExpandLess as ExpandLessIcon,
|
|
7
|
-
} from '@mui/icons-material'
|
|
2
|
+
import { Box, Collapse, Typography } from '@mui/material'
|
|
3
|
+
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material'
|
|
4
|
+
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|
8
5
|
import { SmartTooltip } from '../../../components/smart-tooltip/smart-tooltip'
|
|
9
|
-
import {
|
|
10
|
-
|
|
6
|
+
import {
|
|
7
|
+
selectGroupVisibility,
|
|
8
|
+
selectLayersByGroup,
|
|
9
|
+
useLegendId,
|
|
10
|
+
useLegendShallow,
|
|
11
|
+
useLegendStore,
|
|
12
|
+
} from '../../stores'
|
|
13
|
+
import { accordionKeyDown } from '../a11y'
|
|
11
14
|
import { LegendGroupContext } from '../contexts'
|
|
12
15
|
import { partitionLegendChildren } from '../legend-actions/legend-actions'
|
|
16
|
+
import { useLegendSortable } from '../legend-sortable/contexts'
|
|
17
|
+
import {
|
|
18
|
+
SortableEntity,
|
|
19
|
+
type SortableEntityRenderProps,
|
|
20
|
+
} from '../legend-sortable/sortable-entity'
|
|
21
|
+
import { toSortableId } from '../legend-sortable/sortable-ids'
|
|
13
22
|
import { styles } from './styles'
|
|
14
23
|
|
|
15
24
|
export interface LegendGroupProps {
|
|
16
25
|
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
|
-
*/
|
|
26
|
+
/** `<Legend.Actions>` in the sticky header; member `<Legend.Row>`s in the body. */
|
|
23
27
|
children?: ReactNode
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/**
|
|
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.
|
|
31
|
+
* Store-connected group wrapper. Draggable inside `<Legend.Sortable>`.
|
|
32
32
|
*
|
|
33
33
|
* @experimental This API is new and may change in a future release.
|
|
34
34
|
*/
|
|
@@ -36,53 +36,89 @@ export function LegendGroup({ groupId, children }: LegendGroupProps) {
|
|
|
36
36
|
const id = useLegendId()
|
|
37
37
|
const group = useLegendShallow(id, (s) => s.groups[groupId])
|
|
38
38
|
const setGroupCollapsed = useLegendShallow(id, (s) => s.setGroupCollapsed)
|
|
39
|
-
const
|
|
39
|
+
const hidden = useLegendStore(
|
|
40
|
+
id,
|
|
41
|
+
(s) => selectGroupVisibility(s, groupId) === 'hidden',
|
|
42
|
+
)
|
|
43
|
+
const sortable = useLegendSortable()
|
|
44
|
+
// Member ids only — useShallow ignores opacity/visibility noise on siblings.
|
|
45
|
+
const memberSortableIds = useLegendShallow(id, (s) =>
|
|
46
|
+
selectLayersByGroup(s, groupId).map((l) => toSortableId('layer', l.id)),
|
|
47
|
+
)
|
|
40
48
|
|
|
41
49
|
if (!group) return null
|
|
42
50
|
|
|
43
51
|
const { actions, body } = partitionLegendChildren(children)
|
|
52
|
+
const toggle = () => setGroupCollapsed(groupId, !group.collapsed)
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
const renderGroup = (sortableProps?: SortableEntityRenderProps) => (
|
|
46
55
|
<LegendGroupContext.Provider value={groupId}>
|
|
47
|
-
<Box
|
|
56
|
+
<Box
|
|
57
|
+
ref={sortableProps?.rootRef}
|
|
58
|
+
style={sortableProps?.rootStyle}
|
|
59
|
+
sx={[
|
|
60
|
+
styles.group,
|
|
61
|
+
sortableProps?.dragging === true && styles.groupDragging,
|
|
62
|
+
]}
|
|
63
|
+
>
|
|
48
64
|
<Box sx={styles.header}>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
</Typography>
|
|
65
|
+
{sortableProps?.handle}
|
|
66
|
+
{/* Whole title area toggles collapse (accordion header); the
|
|
67
|
+
actions box is a sibling outside the click target. */}
|
|
68
|
+
<Box
|
|
69
|
+
sx={styles.titleArea}
|
|
70
|
+
role='button'
|
|
71
|
+
tabIndex={0}
|
|
72
|
+
aria-expanded={!group.collapsed}
|
|
73
|
+
onClick={toggle}
|
|
74
|
+
onKeyDown={accordionKeyDown(toggle)}
|
|
75
|
+
>
|
|
76
|
+
{group.icon != null && (
|
|
77
|
+
<Box sx={styles.groupIcon}>{group.icon}</Box>
|
|
63
78
|
)}
|
|
64
|
-
</SmartTooltip>
|
|
65
79
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
) : (
|
|
78
|
-
<ExpandLessIcon fontSize='small' />
|
|
80
|
+
<SmartTooltip title={group.label} dependencies={[group.label]}>
|
|
81
|
+
{({ ref }) => (
|
|
82
|
+
<Typography
|
|
83
|
+
ref={ref}
|
|
84
|
+
variant='button'
|
|
85
|
+
fontWeight='bold'
|
|
86
|
+
color={hidden ? 'text.disabled' : 'text.primary'}
|
|
87
|
+
sx={styles.label}
|
|
88
|
+
>
|
|
89
|
+
{group.label}
|
|
90
|
+
</Typography>
|
|
79
91
|
)}
|
|
80
|
-
</
|
|
92
|
+
</SmartTooltip>
|
|
93
|
+
|
|
94
|
+
{/* Informative only (the row is the button); hidden on touch. */}
|
|
95
|
+
<ExpandMoreIcon
|
|
96
|
+
sx={[styles.chevron, group.collapsed && styles.chevronCollapsed]}
|
|
97
|
+
/>
|
|
81
98
|
</Box>
|
|
99
|
+
|
|
100
|
+
<Box sx={styles.actions}>{actions}</Box>
|
|
82
101
|
</Box>
|
|
83
102
|
|
|
84
|
-
<Collapse in={!group.collapsed}>
|
|
103
|
+
<Collapse in={!group.collapsed}>
|
|
104
|
+
{sortable ? (
|
|
105
|
+
<SortableContext
|
|
106
|
+
items={memberSortableIds}
|
|
107
|
+
strategy={verticalListSortingStrategy}
|
|
108
|
+
>
|
|
109
|
+
{body}
|
|
110
|
+
</SortableContext>
|
|
111
|
+
) : (
|
|
112
|
+
body
|
|
113
|
+
)}
|
|
114
|
+
</Collapse>
|
|
85
115
|
</Box>
|
|
86
116
|
</LegendGroupContext.Provider>
|
|
87
117
|
)
|
|
118
|
+
|
|
119
|
+
return sortable ? (
|
|
120
|
+
<SortableEntity kind='group' id={groupId} render={renderGroup} />
|
|
121
|
+
) : (
|
|
122
|
+
renderGroup()
|
|
123
|
+
)
|
|
88
124
|
}
|
|
@@ -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',
|