@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
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { LegendMenuController } from './contexts';
|
|
2
|
+
/**
|
|
3
|
+
* Run an async menu action with busy gating. Uses `isBusy()` (ref-backed) so a
|
|
4
|
+
* second click before React re-renders cannot fire the handler twice. Keeps
|
|
5
|
+
* the menu open while pending, then clears busy and closes when it settles.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runAsyncMenuAction(menu: LegendMenuController, action: () => void | Promise<void>): Promise<void>;
|
|
@@ -5,6 +5,12 @@ import { LegendActions } from './components/legend-actions/legend-actions';
|
|
|
5
5
|
import { LegendVisibilityToggle } from './components/legend-visibility-toggle/legend-visibility-toggle';
|
|
6
6
|
import { LegendRowMenu } from './components/legend-row-menu/legend-row-menu';
|
|
7
7
|
import { LegendZoomTo } from './components/legend-row-menu/legend-zoom-to';
|
|
8
|
+
import { LegendShowOnlyLayer, LegendShowAllLayers } from './components/legend-row-menu/legend-row-menu-items';
|
|
9
|
+
import { LegendGroupMenu } from './components/legend-group-menu/legend-group-menu';
|
|
10
|
+
import { LegendShowOnlyGroup, LegendShowAllGroups, LegendCollapseAllGroups, LegendZoomToGroup } from './components/legend-group-menu/legend-group-menu-items';
|
|
11
|
+
import { LegendPanelMenu } from './components/legend-panel-menu/legend-panel-menu';
|
|
12
|
+
import { LegendVisibleLayers } from './components/legend-visible-layers/legend-visible-layers';
|
|
13
|
+
import { LegendSortable } from './components/legend-sortable/legend-sortable';
|
|
8
14
|
import { LegendConfigSelect } from './components/legend-config-select/legend-config-select';
|
|
9
15
|
import { LegendItem } from './components/legend-item/legend-item';
|
|
10
16
|
import { LegendItemUI } from './components/legend-item/legend-item-ui';
|
|
@@ -22,31 +28,58 @@ import { LegendIconUI } from './components/legend-icon/legend-icon-ui';
|
|
|
22
28
|
* children — `<Legend.Actions>` renders in the sticky header (hover-fade),
|
|
23
29
|
* everything else in the collapsible body.
|
|
24
30
|
*
|
|
31
|
+
* Collapse is the title row itself (whole header minus the actions), with an
|
|
32
|
+
* informative chevron that touch devices don't show. The ⋮ menus are
|
|
33
|
+
* extensible shells: compose the built-in items and append your own
|
|
34
|
+
* `MenuItem`s. The built-in items are **self-contained** — their effect is
|
|
35
|
+
* implemented inside the component through the store's primitive setters, so
|
|
36
|
+
* they mount with zero wiring; only `ZoomTo`/`ZoomToGroup` take an `onZoomTo`
|
|
37
|
+
* handler (zooming is the map's concern). Pass an async handler to keep the
|
|
38
|
+
* menu open with in-item loading feedback until it settles.
|
|
39
|
+
*
|
|
25
40
|
* ```tsx
|
|
26
41
|
* import { Legend } from '@carto/ps-react-ui/legend'
|
|
27
42
|
*
|
|
28
43
|
* <Legend.Provider id='map-legend' layers={layers} groups={groups}>
|
|
44
|
+
* // Panel-wide actions (Show all groups / Collapse all) live once here,
|
|
45
|
+
* // not repeated per group.
|
|
46
|
+
* <Legend.PanelMenu>
|
|
47
|
+
* <Legend.ShowAllGroups />
|
|
48
|
+
* <Legend.CollapseAllGroups />
|
|
49
|
+
* </Legend.PanelMenu>
|
|
50
|
+
*
|
|
29
51
|
* <Legend.Group groupId='demographics'>
|
|
30
52
|
* <Legend.Actions>
|
|
31
53
|
* <Legend.VisibilityToggle /> // tri-state group eye
|
|
54
|
+
* <Legend.GroupMenu> // ⋮ (rightmost)
|
|
55
|
+
* <Legend.ShowOnlyGroup />
|
|
56
|
+
* <Legend.ShowAllLayers /> // this group's members
|
|
57
|
+
* <Legend.ZoomToGroup onZoomTo={zoomToGroup} />
|
|
58
|
+
* </Legend.GroupMenu>
|
|
32
59
|
* </Legend.Actions>
|
|
33
60
|
* <Legend.Row layerId='income'>
|
|
34
|
-
* // Actions read L→R:
|
|
61
|
+
* // Actions read L→R: eye · ⋮ options
|
|
35
62
|
* <Legend.Actions>
|
|
63
|
+
* <Legend.VisibilityToggle /> // layer eye
|
|
36
64
|
* <Legend.RowMenu>
|
|
65
|
+
* <Legend.ShowOnlyLayer />
|
|
66
|
+
* <Legend.Opacity.Item />
|
|
37
67
|
* <Legend.ZoomTo onZoomTo={zoomTo} />
|
|
38
68
|
* </Legend.RowMenu>
|
|
39
|
-
* <Legend.Opacity.Trigger />
|
|
40
|
-
* <Legend.VisibilityToggle /> // layer eye
|
|
41
69
|
* </Legend.Actions>
|
|
70
|
+
* <Legend.Opacity.Inline /> // bar between header and body
|
|
42
71
|
* <Legend.ConfigSelect />
|
|
43
72
|
* <Legend.Item />
|
|
44
|
-
* <Legend.Opacity.Popover /> // anchors to the row header
|
|
45
73
|
* </Legend.Row>
|
|
46
74
|
* </Legend.Group>
|
|
47
75
|
* </Legend.Provider>
|
|
48
76
|
* ```
|
|
49
77
|
*
|
|
78
|
+
* `Legend.ShowAllLayers` is composed at whichever site it targets: directly
|
|
79
|
+
* in a `Legend.GroupMenu` for that group's members (as above), or in an
|
|
80
|
+
* ungrouped row's `Legend.RowMenu` for the loose-layer bucket — it resolves
|
|
81
|
+
* its own scope from context either way.
|
|
82
|
+
*
|
|
50
83
|
* @experimental This API is new and may change in a future release.
|
|
51
84
|
*/
|
|
52
85
|
export declare const Legend: {
|
|
@@ -54,12 +87,22 @@ export declare const Legend: {
|
|
|
54
87
|
readonly Group: typeof LegendGroup;
|
|
55
88
|
readonly Row: typeof LegendRow;
|
|
56
89
|
readonly Actions: typeof LegendActions;
|
|
90
|
+
readonly Sortable: typeof LegendSortable;
|
|
57
91
|
readonly VisibilityToggle: typeof LegendVisibilityToggle;
|
|
58
92
|
readonly RowMenu: typeof LegendRowMenu;
|
|
59
93
|
readonly ZoomTo: typeof LegendZoomTo;
|
|
94
|
+
readonly ShowOnlyLayer: typeof LegendShowOnlyLayer;
|
|
95
|
+
readonly ShowAllLayers: typeof LegendShowAllLayers;
|
|
96
|
+
readonly GroupMenu: typeof LegendGroupMenu;
|
|
97
|
+
readonly ShowOnlyGroup: typeof LegendShowOnlyGroup;
|
|
98
|
+
readonly ShowAllGroups: typeof LegendShowAllGroups;
|
|
99
|
+
readonly CollapseAllGroups: typeof LegendCollapseAllGroups;
|
|
100
|
+
readonly ZoomToGroup: typeof LegendZoomToGroup;
|
|
101
|
+
readonly PanelMenu: typeof LegendPanelMenu;
|
|
102
|
+
readonly VisibleLayers: typeof LegendVisibleLayers;
|
|
60
103
|
readonly Opacity: {
|
|
61
|
-
|
|
62
|
-
|
|
104
|
+
Item: () => import("react/jsx-runtime").JSX.Element | null;
|
|
105
|
+
Inline: () => import("react/jsx-runtime").JSX.Element | null;
|
|
63
106
|
};
|
|
64
107
|
readonly ConfigSelect: typeof LegendConfigSelect;
|
|
65
108
|
readonly Item: typeof LegendItem;
|
|
@@ -75,9 +118,13 @@ export type { LegendProviderProps, LegendLabels } from './provider';
|
|
|
75
118
|
export type { LegendGroupProps } from './components/legend-group/legend-group';
|
|
76
119
|
export type { LegendRowProps } from './components/legend-row/legend-row';
|
|
77
120
|
export type { LegendActionsProps } from './components/legend-actions/legend-actions';
|
|
121
|
+
export type { LegendSortableProps } from './components/legend-sortable/legend-sortable';
|
|
78
122
|
export type { LegendVisibilityToggleProps } from './components/legend-visibility-toggle/legend-visibility-toggle';
|
|
79
123
|
export type { LegendRowMenuProps } from './components/legend-row-menu/legend-row-menu';
|
|
80
124
|
export type { LegendZoomToProps } from './components/legend-row-menu/legend-zoom-to';
|
|
125
|
+
export type { LegendGroupMenuProps } from './components/legend-group-menu/legend-group-menu';
|
|
126
|
+
export type { LegendZoomToGroupProps } from './components/legend-group-menu/legend-group-menu-items';
|
|
127
|
+
export type { LegendPanelMenuProps } from './components/legend-panel-menu/legend-panel-menu';
|
|
81
128
|
export type { LegendRowContextValue } from './components/contexts';
|
|
82
129
|
export type { LegendItemUIProps } from './components/legend-item/legend-item-ui';
|
|
83
130
|
export type { LegendSwatchProps } from './components/legend-swatch/legend-swatch';
|
|
@@ -10,12 +10,33 @@ export interface LegendLabels {
|
|
|
10
10
|
showLayer: string;
|
|
11
11
|
layerOptions: string;
|
|
12
12
|
zoomTo: string;
|
|
13
|
+
/** In-flight label for Zoom to layer / Zoom to group. */
|
|
14
|
+
loading: string;
|
|
15
|
+
showOnlyLayer: string;
|
|
16
|
+
showAllLayers: string;
|
|
13
17
|
hideGroup: string;
|
|
14
18
|
showGroup: string;
|
|
15
19
|
expandGroup: string;
|
|
16
20
|
collapseGroup: string;
|
|
21
|
+
groupOptions: string;
|
|
22
|
+
showOnlyGroup: string;
|
|
23
|
+
showAllGroups: string;
|
|
24
|
+
collapseAllGroups: string;
|
|
25
|
+
expandAllGroups: string;
|
|
26
|
+
zoomToGroup: string;
|
|
27
|
+
panelOptions: string;
|
|
28
|
+
/** Sticky header of the visible-layers summary; the count is appended. */
|
|
29
|
+
enabledLayers: string;
|
|
30
|
+
/** Tooltip of the visible-layers summary trigger. */
|
|
31
|
+
toggleEnabledLayers: string;
|
|
32
|
+
/** Tooltip shared by the ⋮ menu triggers (row, group and panel). */
|
|
33
|
+
moreOptions: string;
|
|
34
|
+
/** Drag handle of sortable groups/rows (aria + tooltip). */
|
|
35
|
+
reorder: string;
|
|
17
36
|
opacity: string;
|
|
18
37
|
attribute: string;
|
|
38
|
+
/** Generic close affordance (e.g. the inline opacity bar's ✕). */
|
|
39
|
+
close: string;
|
|
19
40
|
radiusRangeBy: string;
|
|
20
41
|
colorBasedOn: string;
|
|
21
42
|
colorGradient: string;
|
|
@@ -2,4 +2,4 @@ export type { LegendPosition, LegendOrientation, LegendSwatchShape, LegendFillPa
|
|
|
2
2
|
export { LegendContext, useLegendId } from './legend-context';
|
|
3
3
|
export { createLegendStore, getLegendStore, hasLegendStore, deleteLegendStore, clearAllLegendStores, setLegendStoreEntry, registerLegendStore, unregisterLegendStore, useLegendStore, useLegendShallow, } from './legend-store-registry';
|
|
4
4
|
export type { LegendVisibility } from './selectors';
|
|
5
|
-
export { selectOrderedGroups, selectLayersByGroup, selectOrderedLayers, selectGroupVisibility, } from './selectors';
|
|
5
|
+
export { selectOrderedGroups, selectLayersByGroup, selectOrderedLayers, selectGroupVisibility, selectLayerCollapsible, selectAllGroupsCollapsed, selectVisibleLayersGrouped, selectHasGroups, } from './selectors';
|
|
@@ -18,6 +18,37 @@ export declare function selectLayersByGroup(state: LegendState, groupId: string
|
|
|
18
18
|
* @experimental This API is new and may change in a future release.
|
|
19
19
|
*/
|
|
20
20
|
export declare function selectOrderedLayers(state: LegendState): LegendLayer[];
|
|
21
|
+
/**
|
|
22
|
+
* `true` when every group is collapsed (`false` with no groups) — drives the
|
|
23
|
+
* "Collapse all groups" / "Expand all groups" menu label flip.
|
|
24
|
+
*
|
|
25
|
+
* @experimental This API is new and may change in a future release.
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectAllGroupsCollapsed(state: LegendState): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Whether the legend has groups — gates panel-level group actions.
|
|
30
|
+
*
|
|
31
|
+
* @experimental This API is new and may change in a future release.
|
|
32
|
+
*/
|
|
33
|
+
export declare function selectHasGroups(state: LegendState): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Visible layers grouped for the enabled-layers menu.
|
|
36
|
+
*
|
|
37
|
+
* @experimental This API is new and may change in a future release.
|
|
38
|
+
*/
|
|
39
|
+
export declare function selectVisibleLayersGrouped(state: LegendState): {
|
|
40
|
+
group?: LegendGroup;
|
|
41
|
+
layers: LegendLayer[];
|
|
42
|
+
}[];
|
|
43
|
+
/**
|
|
44
|
+
* Whether a layer has any collapsible body — something the row's `Collapse`
|
|
45
|
+
* can actually fold. A layer with no variables, sections, or helper note
|
|
46
|
+
* renders an empty body, so there is nothing to collapse. Gates the row's
|
|
47
|
+
* title-area collapse affordance (chevron + click).
|
|
48
|
+
*
|
|
49
|
+
* @experimental This API is new and may change in a future release.
|
|
50
|
+
*/
|
|
51
|
+
export declare function selectLayerCollapsible(state: LegendState, layerId: string): boolean;
|
|
21
52
|
/**
|
|
22
53
|
* Tri-state visibility — derived for groups from their member layers, and the
|
|
23
54
|
* state contract of a controlled `Legend.VisibilityToggle` for any custom
|
|
@@ -198,6 +198,14 @@ export interface LegendLayer {
|
|
|
198
198
|
export interface LegendGroup {
|
|
199
199
|
id: string;
|
|
200
200
|
label: string;
|
|
201
|
+
/**
|
|
202
|
+
* Optional header icon. Rendered only when provided — no icon shows by
|
|
203
|
+
* default. A same-type element with shallow-equal props is treated as
|
|
204
|
+
* unchanged across re-syncs even when re-created inline (e.g.
|
|
205
|
+
* `icon={<MyIcon />}`); a deeply nested custom node should be memoized if
|
|
206
|
+
* you want to avoid unnecessary syncs.
|
|
207
|
+
*/
|
|
208
|
+
icon?: ReactNode;
|
|
201
209
|
collapsed: boolean;
|
|
202
210
|
/**
|
|
203
211
|
* @internal Collapsed state snapshotted when the group was hidden via
|
|
@@ -230,6 +238,12 @@ export interface LegendState {
|
|
|
230
238
|
* section's `options`.
|
|
231
239
|
*/
|
|
232
240
|
setSectionValue: (id: string, sectionId: string, value: string) => void;
|
|
241
|
+
/**
|
|
242
|
+
* Move a layer to `toIndex` **within its sibling bucket** (its group, or
|
|
243
|
+
* the ungrouped bucket). `toIndex` is clamped; orders stay dense. No-op
|
|
244
|
+
* for unknown ids or when the position doesn't change.
|
|
245
|
+
*/
|
|
246
|
+
moveLayer: (id: string, toIndex: number) => void;
|
|
233
247
|
/**
|
|
234
248
|
* Show/hide **all member layers** of a group (the group itself has no stored
|
|
235
249
|
* visibility — it is derived from its members). Each member keeps its own
|
|
@@ -239,6 +253,11 @@ export interface LegendState {
|
|
|
239
253
|
*/
|
|
240
254
|
setGroupVisibility: (id: string, visible: boolean) => void;
|
|
241
255
|
setGroupCollapsed: (id: string, collapsed: boolean) => void;
|
|
256
|
+
/**
|
|
257
|
+
* Move a group to `toIndex` within the group list. `toIndex` is clamped;
|
|
258
|
+
* orders stay dense. No-op for unknown ids or an unchanged position.
|
|
259
|
+
*/
|
|
260
|
+
moveGroup: (id: string, toIndex: number) => void;
|
|
242
261
|
/**
|
|
243
262
|
* Add a layer, or update it if `input.id` already exists. Interactive state
|
|
244
263
|
* (visible/opacity/collapsed/order and section actives) is preserved unless
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carto/ps-react-ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.17.0",
|
|
4
4
|
"description": "CARTO's Professional Service React Material library",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"devDependencies": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"react-markdown": "10.1.0",
|
|
19
19
|
"zustand": "5.0.12",
|
|
20
20
|
"@carto/ps-common-types": "1.0.0",
|
|
21
|
-
"@carto/ps-utils": "2.0
|
|
21
|
+
"@carto/ps-utils": "2.1.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"@carto/meridian-ds": "^2.0.0",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"react-dom": "^19.2.0",
|
|
36
36
|
"react-markdown": "^10.0.0",
|
|
37
37
|
"zustand": "^5.0.0",
|
|
38
|
-
"@carto/ps-utils": "2.0
|
|
38
|
+
"@carto/ps-utils": "2.1.0"
|
|
39
39
|
},
|
|
40
40
|
"exports": {
|
|
41
41
|
"./chat": {
|
|
@@ -154,6 +154,144 @@ describe('ResponsiveDrawer', () => {
|
|
|
154
154
|
).not.toThrow()
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
+
test('defaults to portaling into document.body when container is omitted', () => {
|
|
158
|
+
render(
|
|
159
|
+
<ThemeProvider theme={theme}>
|
|
160
|
+
<ResponsiveDrawer ref={anchor} {...defaultProps} />
|
|
161
|
+
</ThemeProvider>,
|
|
162
|
+
)
|
|
163
|
+
const content = screen.getByTestId('drawer-content')
|
|
164
|
+
const modalRoot = content.closest('.MuiModal-root')
|
|
165
|
+
expect(modalRoot?.parentElement).toBe(document.body)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('portals into the given container instead of document.body', () => {
|
|
169
|
+
const customContainer = document.createElement('div')
|
|
170
|
+
document.body.appendChild(customContainer)
|
|
171
|
+
try {
|
|
172
|
+
render(
|
|
173
|
+
<ThemeProvider theme={theme}>
|
|
174
|
+
<ResponsiveDrawer
|
|
175
|
+
ref={anchor}
|
|
176
|
+
{...defaultProps}
|
|
177
|
+
container={customContainer}
|
|
178
|
+
/>
|
|
179
|
+
</ThemeProvider>,
|
|
180
|
+
)
|
|
181
|
+
const content = screen.getByTestId('drawer-content')
|
|
182
|
+
const modalRoot = content.closest('.MuiModal-root')
|
|
183
|
+
expect(modalRoot?.parentElement).toBe(customContainer)
|
|
184
|
+
} finally {
|
|
185
|
+
customContainer.remove()
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('caps the popover paper height at 90% of a measured container element', () => {
|
|
190
|
+
const customContainer = document.createElement('div')
|
|
191
|
+
Object.defineProperty(customContainer, 'clientHeight', {
|
|
192
|
+
value: 500,
|
|
193
|
+
configurable: true,
|
|
194
|
+
})
|
|
195
|
+
document.body.appendChild(customContainer)
|
|
196
|
+
try {
|
|
197
|
+
render(
|
|
198
|
+
<ThemeProvider theme={theme}>
|
|
199
|
+
<ResponsiveDrawer
|
|
200
|
+
ref={anchor}
|
|
201
|
+
{...defaultProps}
|
|
202
|
+
container={customContainer}
|
|
203
|
+
/>
|
|
204
|
+
</ThemeProvider>,
|
|
205
|
+
)
|
|
206
|
+
const paper = document.querySelector<HTMLElement>('.MuiPopover-paper')!
|
|
207
|
+
expect(paper.style.maxHeight).toBe('450px')
|
|
208
|
+
expect(paper.style.overflow).toBe('hidden')
|
|
209
|
+
expect(paper.style.display).toBe('flex')
|
|
210
|
+
expect(paper.style.flexDirection).toBe('column')
|
|
211
|
+
|
|
212
|
+
const content = screen.getByTestId('drawer-content')
|
|
213
|
+
const wrapper = content.parentElement!
|
|
214
|
+
expect(wrapper.style.display).toBe('flex')
|
|
215
|
+
expect(wrapper.style.flexDirection).toBe('column')
|
|
216
|
+
expect(wrapper.style.flex).toBe('1 1 0%')
|
|
217
|
+
expect(wrapper.style.minHeight).toBe('0')
|
|
218
|
+
expect(wrapper.style.maxHeight).toBe('100%')
|
|
219
|
+
expect(wrapper.style.overflow).toBe('hidden')
|
|
220
|
+
} finally {
|
|
221
|
+
customContainer.remove()
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('leaves paper maxHeight unset when no container is given', () => {
|
|
226
|
+
render(
|
|
227
|
+
<ThemeProvider theme={theme}>
|
|
228
|
+
<ResponsiveDrawer ref={anchor} {...defaultProps} />
|
|
229
|
+
</ThemeProvider>,
|
|
230
|
+
)
|
|
231
|
+
const paper = document.querySelector<HTMLElement>('.MuiPopover-paper')!
|
|
232
|
+
expect(paper.style.maxHeight).toBe('')
|
|
233
|
+
expect(paper.style.display).toBe('')
|
|
234
|
+
|
|
235
|
+
const content = screen.getByTestId('drawer-content')
|
|
236
|
+
expect(content.parentElement!.style.display).toBe('')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('leaves paper maxHeight unset when the container reports height 0', () => {
|
|
240
|
+
const customContainer = document.createElement('div')
|
|
241
|
+
Object.defineProperty(customContainer, 'clientHeight', {
|
|
242
|
+
value: 0,
|
|
243
|
+
configurable: true,
|
|
244
|
+
})
|
|
245
|
+
document.body.appendChild(customContainer)
|
|
246
|
+
try {
|
|
247
|
+
render(
|
|
248
|
+
<ThemeProvider theme={theme}>
|
|
249
|
+
<ResponsiveDrawer
|
|
250
|
+
ref={anchor}
|
|
251
|
+
{...defaultProps}
|
|
252
|
+
container={customContainer}
|
|
253
|
+
/>
|
|
254
|
+
</ThemeProvider>,
|
|
255
|
+
)
|
|
256
|
+
const paper = document.querySelector<HTMLElement>('.MuiPopover-paper')!
|
|
257
|
+
expect(paper.style.maxHeight).toBe('')
|
|
258
|
+
} finally {
|
|
259
|
+
customContainer.remove()
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('preserves slotProps.root when capping paper height under modal=true', () => {
|
|
264
|
+
const customContainer = document.createElement('div')
|
|
265
|
+
Object.defineProperty(customContainer, 'clientHeight', {
|
|
266
|
+
value: 500,
|
|
267
|
+
configurable: true,
|
|
268
|
+
})
|
|
269
|
+
document.body.appendChild(customContainer)
|
|
270
|
+
try {
|
|
271
|
+
render(
|
|
272
|
+
<ThemeProvider theme={theme}>
|
|
273
|
+
<ResponsiveDrawer
|
|
274
|
+
ref={anchor}
|
|
275
|
+
{...defaultProps}
|
|
276
|
+
container={customContainer}
|
|
277
|
+
slotProps={
|
|
278
|
+
{
|
|
279
|
+
root: { 'data-testid': 'drawer-root-slot' },
|
|
280
|
+
paper: { 'data-testid': 'drawer-paper-slot' },
|
|
281
|
+
} as never
|
|
282
|
+
}
|
|
283
|
+
/>
|
|
284
|
+
</ThemeProvider>,
|
|
285
|
+
)
|
|
286
|
+
expect(screen.getByTestId('drawer-root-slot')).toBeDefined()
|
|
287
|
+
expect(screen.getByTestId('drawer-paper-slot')).toBeDefined()
|
|
288
|
+
const paper = document.querySelector<HTMLElement>('.MuiPopover-paper')!
|
|
289
|
+
expect(paper.style.maxHeight).toBe('450px')
|
|
290
|
+
} finally {
|
|
291
|
+
customContainer.remove()
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
157
295
|
test('respects sx prop on the Popover', () => {
|
|
158
296
|
expect(() =>
|
|
159
297
|
render(
|
|
@@ -5,7 +5,13 @@ import {
|
|
|
5
5
|
type PopoverOrigin,
|
|
6
6
|
type PopoverProps,
|
|
7
7
|
} from '@mui/material'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
useCallback,
|
|
10
|
+
useRef,
|
|
11
|
+
useSyncExternalStore,
|
|
12
|
+
type CSSProperties,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from 'react'
|
|
9
15
|
|
|
10
16
|
type ResponsiveDrawerPosition =
|
|
11
17
|
| 'top-left'
|
|
@@ -17,14 +23,7 @@ type ResponsiveDrawerPosition =
|
|
|
17
23
|
export type ResponsiveDrawerPlacement = 'side' | 'overlay'
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* `transformOrigin` (the panel corner that pins to the anchor point) is the same
|
|
23
|
-
* for both placements: the panel's own corner matching `position`. `anchorOrigin`
|
|
24
|
-
* (the point on the anchor the panel pins to) differs:
|
|
25
|
-
* - `'side'` flips horizontally → the panel opens beside the trigger.
|
|
26
|
-
* - `'overlay'` uses the same corner → the panel sits **on top of** the anchor
|
|
27
|
-
* (e.g. a trigger that's hidden while the panel is open), pinned corner-to-corner.
|
|
26
|
+
* Popover anchor/transform origins. `'overlay'` pins corner-to-corner on the anchor.
|
|
28
27
|
*/
|
|
29
28
|
export function resolveOrigins(
|
|
30
29
|
position: ResponsiveDrawerPosition,
|
|
@@ -37,8 +36,6 @@ export function resolveOrigins(
|
|
|
37
36
|
return {
|
|
38
37
|
anchorOrigin: {
|
|
39
38
|
vertical: isBottom ? 'bottom' : 'top',
|
|
40
|
-
// `'side'` flips horizontally to open beside the anchor; `'overlay'` keeps
|
|
41
|
-
// the same edge so the panel lands on top of it.
|
|
42
39
|
horizontal: overlay
|
|
43
40
|
? isRight
|
|
44
41
|
? 'right'
|
|
@@ -69,11 +66,12 @@ export function ResponsiveDrawer<
|
|
|
69
66
|
position,
|
|
70
67
|
sx,
|
|
71
68
|
slotProps,
|
|
69
|
+
container,
|
|
72
70
|
onChangeCollapsed,
|
|
73
71
|
modal = true,
|
|
74
72
|
placement = 'side',
|
|
75
73
|
}: Required<Pick<T, 'collapsed' | 'onChangeCollapsed' | 'position'>> &
|
|
76
|
-
Pick<PopoverProps, 'slotProps' | 'sx'> & {
|
|
74
|
+
Pick<PopoverProps, 'slotProps' | 'sx' | 'container'> & {
|
|
77
75
|
ref: HTMLElement | null
|
|
78
76
|
isMobile: boolean
|
|
79
77
|
children: ReactNode
|
|
@@ -92,18 +90,7 @@ export function ResponsiveDrawer<
|
|
|
92
90
|
*/
|
|
93
91
|
placement?: ResponsiveDrawerPlacement
|
|
94
92
|
}) {
|
|
95
|
-
//
|
|
96
|
-
// its content resizes — so a panel that grows/shrinks (e.g. collapsing legend
|
|
97
|
-
// groups) keeps its frozen `top` and detaches from the anchored edge (the
|
|
98
|
-
// "blink"). Re-run the Popover's own positioning (`action.updatePosition`)
|
|
99
|
-
// whenever the content changes size: with `transformOrigin` matching the
|
|
100
|
-
// anchor edge, that edge stays pinned (bottom for `bottom-*`, top for
|
|
101
|
-
// `top-*`) — and because updatePosition is synchronous, it tracks every
|
|
102
|
-
// animation frame smoothly rather than snapping after a debounce.
|
|
103
|
-
//
|
|
104
|
-
// A callback ref (not a `useEffect`) attaches the observer the moment the
|
|
105
|
-
// content mounts — the Popover renders into a portal behind a transition, so
|
|
106
|
-
// the element isn't available on the effect tick that follows `open`.
|
|
93
|
+
// Popover position is frozen on open — re-measure on content resize (callback ref: portal mounts after open).
|
|
107
94
|
const popoverActions = useRef<PopoverActions>(null)
|
|
108
95
|
const observerRef = useRef<ResizeObserver | null>(null)
|
|
109
96
|
const observeContent = useCallback((el: HTMLDivElement | null) => {
|
|
@@ -118,8 +105,27 @@ export function ResponsiveDrawer<
|
|
|
118
105
|
}
|
|
119
106
|
}, [])
|
|
120
107
|
|
|
108
|
+
// Popover Paper is auto-height — `%` max-height needs a measured px cap from `container`.
|
|
109
|
+
const measurableContainer =
|
|
110
|
+
container && typeof container !== 'function' ? container : null
|
|
111
|
+
const subscribe = useCallback(
|
|
112
|
+
(onStoreChange: () => void) => {
|
|
113
|
+
if (!measurableContainer || typeof ResizeObserver === 'undefined') {
|
|
114
|
+
return () => undefined
|
|
115
|
+
}
|
|
116
|
+
const observer = new ResizeObserver(onStoreChange)
|
|
117
|
+
observer.observe(measurableContainer)
|
|
118
|
+
return () => observer.disconnect()
|
|
119
|
+
},
|
|
120
|
+
[measurableContainer],
|
|
121
|
+
)
|
|
122
|
+
const getSnapshot = useCallback(
|
|
123
|
+
() => measurableContainer?.clientHeight,
|
|
124
|
+
[measurableContainer],
|
|
125
|
+
)
|
|
126
|
+
const containerHeight = useSyncExternalStore(subscribe, getSnapshot)
|
|
127
|
+
|
|
121
128
|
if (isMobile) {
|
|
122
|
-
// MUI calls `onClose(event, reason)`; adapt it to the boolean contract.
|
|
123
129
|
return (
|
|
124
130
|
<Drawer
|
|
125
131
|
anchor='bottom'
|
|
@@ -133,41 +139,60 @@ export function ResponsiveDrawer<
|
|
|
133
139
|
|
|
134
140
|
const { anchorOrigin, transformOrigin } = resolveOrigins(position, placement)
|
|
135
141
|
|
|
136
|
-
// Non-modal mode injects pointer-through `style` into the root/paper slots so
|
|
137
|
-
// clicks fall through to the content behind (the map) while the panel stays
|
|
138
|
-
// interactive. Only the legend uses it and it passes no `slotProps`, so the
|
|
139
|
-
// common path (modal, incl. Basemaps' callback/object slotProps) is passed
|
|
140
|
-
// straight through untouched.
|
|
141
142
|
const objectSlotProps = slotProps as
|
|
142
143
|
| { root?: Record<string, unknown>; paper?: { style?: CSSProperties } }
|
|
143
144
|
| undefined
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
145
|
+
// Capped Paper: disable outer scroll + flex so inner scroll regions can shrink.
|
|
146
|
+
const paperMaxHeightStyle: CSSProperties | undefined =
|
|
147
|
+
containerHeight != null && containerHeight > 0
|
|
148
|
+
? {
|
|
149
|
+
maxHeight: containerHeight * 0.9,
|
|
150
|
+
overflow: 'hidden',
|
|
151
|
+
display: 'flex',
|
|
152
|
+
flexDirection: 'column',
|
|
153
|
+
}
|
|
154
|
+
: undefined
|
|
155
|
+
const contentWrapperStyle: CSSProperties | undefined = paperMaxHeightStyle
|
|
156
|
+
? {
|
|
157
|
+
display: 'flex',
|
|
158
|
+
flexDirection: 'column',
|
|
159
|
+
flex: 1,
|
|
160
|
+
minHeight: 0,
|
|
161
|
+
maxHeight: '100%',
|
|
162
|
+
overflow: 'hidden',
|
|
163
|
+
width: '100%',
|
|
158
164
|
}
|
|
165
|
+
: undefined
|
|
166
|
+
const mergedSlotProps: PopoverProps['slotProps'] =
|
|
167
|
+
modal && !paperMaxHeightStyle
|
|
168
|
+
? slotProps
|
|
169
|
+
: {
|
|
170
|
+
...(typeof slotProps === 'object' && slotProps != null
|
|
171
|
+
? slotProps
|
|
172
|
+
: {}),
|
|
173
|
+
...(!modal && {
|
|
174
|
+
root: {
|
|
175
|
+
style: { pointerEvents: 'none' },
|
|
176
|
+
...objectSlotProps?.root,
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
paper: {
|
|
180
|
+
...objectSlotProps?.paper,
|
|
181
|
+
style: {
|
|
182
|
+
...(!modal && { pointerEvents: 'auto' }),
|
|
183
|
+
...paperMaxHeightStyle,
|
|
184
|
+
...objectSlotProps?.paper?.style,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}
|
|
159
188
|
|
|
160
189
|
return (
|
|
161
190
|
<Popover
|
|
162
191
|
action={popoverActions}
|
|
163
192
|
sx={sx}
|
|
193
|
+
container={container}
|
|
164
194
|
anchorEl={ref}
|
|
165
|
-
// Guard on `ref` so an open-by-default panel doesn't render before the
|
|
166
|
-
// anchor callback-ref has resolved (avoids the null-anchorEl warning +
|
|
167
|
-
// one-frame misposition; it re-renders non-null immediately after mount).
|
|
168
195
|
open={!collapsed && ref != null}
|
|
169
|
-
// In non-modal mode neither backdrop nor Escape dismisses — close via
|
|
170
|
-
// `onChangeCollapsed` (e.g. the panel's own ✕).
|
|
171
196
|
onClose={modal ? () => onChangeCollapsed(true) : undefined}
|
|
172
197
|
hideBackdrop={!modal}
|
|
173
198
|
disableScrollLock={!modal}
|
|
@@ -179,8 +204,9 @@ export function ResponsiveDrawer<
|
|
|
179
204
|
transformOrigin={transformOrigin}
|
|
180
205
|
slotProps={mergedSlotProps}
|
|
181
206
|
>
|
|
182
|
-
{
|
|
183
|
-
|
|
207
|
+
<div ref={observeContent} style={contentWrapperStyle}>
|
|
208
|
+
{children}
|
|
209
|
+
</div>
|
|
184
210
|
</Popover>
|
|
185
211
|
)
|
|
186
212
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { KeyboardEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enter/Space activate a non-native `role='button'` element (the accordion
|
|
5
|
+
* title areas of `Legend.Row` / `Legend.Group`), like a real button.
|
|
6
|
+
*/
|
|
7
|
+
export function accordionKeyDown(toggle: () => void) {
|
|
8
|
+
return (event: KeyboardEvent<HTMLElement>) => {
|
|
9
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
10
|
+
event.preventDefault()
|
|
11
|
+
toggle()
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|