@carto/ps-react-ui 4.12.2 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components.js +706 -658
- package/dist/components.js.map +1 -1
- package/dist/legend/stores.js +20 -0
- package/dist/legend/stores.js.map +1 -0
- package/dist/legend.js +1536 -0
- package/dist/legend.js.map +1 -0
- package/dist/selectors-DwHj6mE2.js +388 -0
- package/dist/selectors-DwHj6mE2.js.map +1 -0
- package/dist/types/components/index.d.ts +1 -0
- package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
- package/dist/types/legend/components/contexts.d.ts +20 -0
- package/dist/types/legend/components/icons.d.ts +12 -0
- package/dist/types/legend/components/index.d.ts +26 -0
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
- package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
- package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
- package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
- package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
- package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
- package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
- package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
- package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
- package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
- package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
- package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
- package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
- package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
- package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
- package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
- package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
- package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
- package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
- package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
- package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
- package/dist/types/legend/index.d.ts +88 -0
- package/dist/types/legend/provider/index.d.ts +6 -0
- package/dist/types/legend/provider/labels.d.ts +27 -0
- package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
- package/dist/types/legend/provider/legend-provider.d.ts +38 -0
- package/dist/types/legend/stores/index.d.ts +5 -0
- package/dist/types/legend/stores/legend-context.d.ts +4 -0
- package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
- package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
- package/dist/types/legend/stores/selectors.d.ts +33 -0
- package/dist/types/legend/stores/types.d.ts +275 -0
- package/package.json +11 -3
- package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
- package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
- package/src/components/index.ts +2 -0
- package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
- package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
- package/src/legend/components/contexts.ts +32 -0
- package/src/legend/components/icons.tsx +22 -0
- package/src/legend/components/index.ts +39 -0
- package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
- package/src/legend/components/legend-actions/styles.ts +9 -0
- package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
- package/src/legend/components/legend-category/styles.ts +61 -0
- package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
- package/src/legend/components/legend-config-select/styles.ts +48 -0
- package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
- package/src/legend/components/legend-group/legend-group.tsx +88 -0
- package/src/legend/components/legend-group/styles.ts +82 -0
- package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
- package/src/legend/components/legend-icon/styles.ts +56 -0
- package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
- package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
- package/src/legend/components/legend-item/legend-item.tsx +34 -0
- package/src/legend/components/legend-item/styles.ts +11 -0
- package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
- package/src/legend/components/legend-opacity/styles.ts +28 -0
- package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
- package/src/legend/components/legend-proportion/styles.ts +43 -0
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
- package/src/legend/components/legend-ramp/styles.ts +62 -0
- package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
- package/src/legend/components/legend-row/legend-row.tsx +129 -0
- package/src/legend/components/legend-row/styles.ts +111 -0
- package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
- package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
- package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
- package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
- package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
- package/src/legend/index.ts +121 -0
- package/src/legend/provider/index.ts +6 -0
- package/src/legend/provider/labels.ts +59 -0
- package/src/legend/provider/legend-config-context.ts +15 -0
- package/src/legend/provider/legend-provider.tsx +102 -0
- package/src/legend/stores/index.ts +46 -0
- package/src/legend/stores/legend-context.ts +17 -0
- package/src/legend/stores/legend-store-registry.ts +639 -0
- package/src/legend/stores/legend-store.test.ts +457 -0
- package/src/legend/stores/selectors.ts +65 -0
- package/src/legend/stores/types.ts +347 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SxProps, Theme } from '@mui/material'
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
list: {
|
|
5
|
+
display: 'flex',
|
|
6
|
+
flexDirection: 'column',
|
|
7
|
+
gap: 0.5,
|
|
8
|
+
// Values are hidden until the legend content is hovered (or keyboard-
|
|
9
|
+
// focused). On hover-incapable (touch) devices they're always visible —
|
|
10
|
+
// tap-:hover is unreliable on non-interactive elements.
|
|
11
|
+
'&:hover .PsLegend-value, &:focus-within .PsLegend-value': {
|
|
12
|
+
opacity: 1,
|
|
13
|
+
},
|
|
14
|
+
'@media (hover: none)': {
|
|
15
|
+
'& .PsLegend-value': { opacity: 1 },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
item: {
|
|
19
|
+
display: 'flex',
|
|
20
|
+
alignItems: 'center',
|
|
21
|
+
gap: 1,
|
|
22
|
+
},
|
|
23
|
+
icon: {
|
|
24
|
+
flexShrink: 0,
|
|
25
|
+
width: 16,
|
|
26
|
+
height: 16,
|
|
27
|
+
objectFit: 'contain',
|
|
28
|
+
},
|
|
29
|
+
label: {
|
|
30
|
+
flex: 1,
|
|
31
|
+
minWidth: 0,
|
|
32
|
+
overflow: 'hidden',
|
|
33
|
+
textOverflow: 'ellipsis',
|
|
34
|
+
whiteSpace: 'nowrap',
|
|
35
|
+
},
|
|
36
|
+
value: {
|
|
37
|
+
flexShrink: 0,
|
|
38
|
+
opacity: 0,
|
|
39
|
+
transition: 'opacity 120ms',
|
|
40
|
+
},
|
|
41
|
+
// ─── Horizontal orientation modifiers (spread over the vertical base) ───
|
|
42
|
+
listHorizontal: {
|
|
43
|
+
flexDirection: 'row',
|
|
44
|
+
flexWrap: 'wrap',
|
|
45
|
+
columnGap: 2,
|
|
46
|
+
rowGap: 0.5,
|
|
47
|
+
},
|
|
48
|
+
itemHorizontal: {
|
|
49
|
+
gap: 0.5,
|
|
50
|
+
},
|
|
51
|
+
labelHorizontal: {
|
|
52
|
+
flex: '0 0 auto',
|
|
53
|
+
overflow: 'visible',
|
|
54
|
+
textOverflow: 'clip',
|
|
55
|
+
},
|
|
56
|
+
} satisfies Record<string, SxProps<Theme>>
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { formatNumber } from '@carto/ps-utils'
|
|
4
|
+
import { LegendItemUI } from './legend-item-ui'
|
|
5
|
+
import { LegendRampUI } from '../legend-ramp/legend-ramp-ui'
|
|
6
|
+
import { LegendProportionUI } from '../legend-proportion/legend-proportion-ui'
|
|
7
|
+
import type {
|
|
8
|
+
LegendCategoryVariable,
|
|
9
|
+
LegendIconVariable,
|
|
10
|
+
LegendProportionVariable,
|
|
11
|
+
LegendRampVariable,
|
|
12
|
+
} from '../../stores'
|
|
13
|
+
|
|
14
|
+
describe('<LegendItemUI> dispatch', () => {
|
|
15
|
+
it('renders the category renderer', () => {
|
|
16
|
+
const data: LegendCategoryVariable = {
|
|
17
|
+
type: 'category',
|
|
18
|
+
items: [{ label: 'A', color: '#000' }],
|
|
19
|
+
}
|
|
20
|
+
render(<LegendItemUI data={data} />)
|
|
21
|
+
expect(screen.getByText('A')).toBeTruthy()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders the icon renderer', () => {
|
|
25
|
+
const data: LegendIconVariable = {
|
|
26
|
+
type: 'icon',
|
|
27
|
+
items: [{ label: 'Store', icon: 'x.svg' }],
|
|
28
|
+
}
|
|
29
|
+
const { container } = render(<LegendItemUI data={data} />)
|
|
30
|
+
expect(screen.getByText('Store')).toBeTruthy()
|
|
31
|
+
expect(container.querySelector('img')).toBeTruthy()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('exposes the orientation on the list (default vertical, opt-in horizontal)', () => {
|
|
35
|
+
const category: LegendCategoryVariable = {
|
|
36
|
+
type: 'category',
|
|
37
|
+
items: [
|
|
38
|
+
{ label: 'A', color: '#111' },
|
|
39
|
+
{ label: 'B', color: '#222' },
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
const { container, rerender } = render(<LegendItemUI data={category} />)
|
|
43
|
+
expect(
|
|
44
|
+
container.querySelector('[data-orientation="vertical"]'),
|
|
45
|
+
).toBeTruthy()
|
|
46
|
+
|
|
47
|
+
rerender(<LegendItemUI data={{ ...category, orientation: 'horizontal' }} />)
|
|
48
|
+
expect(
|
|
49
|
+
container.querySelector('[data-orientation="horizontal"]'),
|
|
50
|
+
).toBeTruthy()
|
|
51
|
+
// Both items still render in horizontal mode.
|
|
52
|
+
expect(screen.getByText('A')).toBeTruthy()
|
|
53
|
+
expect(screen.getByText('B')).toBeTruthy()
|
|
54
|
+
|
|
55
|
+
// Icon legend honors the same flag.
|
|
56
|
+
const icon: LegendIconVariable = {
|
|
57
|
+
type: 'icon',
|
|
58
|
+
orientation: 'horizontal',
|
|
59
|
+
items: [{ label: 'Store', icon: 'x.svg' }],
|
|
60
|
+
}
|
|
61
|
+
rerender(<LegendItemUI data={icon} />)
|
|
62
|
+
expect(
|
|
63
|
+
container.querySelector('[data-orientation="horizontal"]'),
|
|
64
|
+
).toBeTruthy()
|
|
65
|
+
expect(screen.getByText('Store')).toBeTruthy()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('renders category and icon values verbatim — the consumer pre-formats', () => {
|
|
69
|
+
// A plain pre-formatted string (e.g. a percent) renders as-is.
|
|
70
|
+
const category: LegendCategoryVariable = {
|
|
71
|
+
type: 'category',
|
|
72
|
+
items: [{ label: 'A', color: '#000', value: '12%' }],
|
|
73
|
+
}
|
|
74
|
+
const { rerender } = render(<LegendItemUI data={category} />)
|
|
75
|
+
expect(screen.getByText('12%')).toBeTruthy()
|
|
76
|
+
|
|
77
|
+
// An arbitrary node is allowed too.
|
|
78
|
+
rerender(
|
|
79
|
+
<LegendItemUI
|
|
80
|
+
data={{
|
|
81
|
+
type: 'category',
|
|
82
|
+
items: [{ label: 'A', color: '#000', value: <strong>1.2K</strong> }],
|
|
83
|
+
}}
|
|
84
|
+
/>,
|
|
85
|
+
)
|
|
86
|
+
expect(screen.getByText('1.2K').tagName).toBe('STRONG')
|
|
87
|
+
|
|
88
|
+
const icon: LegendIconVariable = {
|
|
89
|
+
type: 'icon',
|
|
90
|
+
items: [{ label: 'Store', icon: 'x.svg', value: '5.6M' }],
|
|
91
|
+
}
|
|
92
|
+
rerender(<LegendItemUI data={icon} />)
|
|
93
|
+
expect(screen.getByText('5.6M')).toBeTruthy()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('passes the variable shape + pattern through to the swatch', () => {
|
|
97
|
+
const data: LegendCategoryVariable = {
|
|
98
|
+
type: 'category',
|
|
99
|
+
shape: 'square',
|
|
100
|
+
fillPattern: 'dots',
|
|
101
|
+
items: [
|
|
102
|
+
{ label: 'A', color: '#111' },
|
|
103
|
+
{ label: 'B', color: '#222' },
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
const { container } = render(<LegendItemUI data={data} />)
|
|
107
|
+
const swatches = container.querySelectorAll('svg')
|
|
108
|
+
expect(swatches).toHaveLength(2)
|
|
109
|
+
expect(swatches[0]!.getAttribute('data-shape')).toBe('square')
|
|
110
|
+
expect(container.querySelector('pattern')).toBeTruthy()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('items override the variable-level swatch style (mixed list)', () => {
|
|
114
|
+
const data: LegendCategoryVariable = {
|
|
115
|
+
type: 'category',
|
|
116
|
+
shape: 'square',
|
|
117
|
+
fillPattern: 'solid', // variable default
|
|
118
|
+
items: [
|
|
119
|
+
{ label: 'Solid', color: '#111' },
|
|
120
|
+
{ label: 'Dotted', color: '#222', fillPattern: 'dots' },
|
|
121
|
+
{ label: 'Line', color: '#333', shape: 'line' },
|
|
122
|
+
{ label: 'Outline', color: '#444', isStrokeColor: true },
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
const { container } = render(<LegendItemUI data={data} />)
|
|
126
|
+
const swatches = Array.from(container.querySelectorAll('svg'))
|
|
127
|
+
expect(swatches.map((s) => s.getAttribute('data-shape'))).toEqual([
|
|
128
|
+
'square',
|
|
129
|
+
'square',
|
|
130
|
+
'line',
|
|
131
|
+
'square',
|
|
132
|
+
])
|
|
133
|
+
// Only the dots-override item emits a fill <pattern>.
|
|
134
|
+
expect(container.querySelectorAll('pattern')).toHaveLength(1)
|
|
135
|
+
expect(swatches[1]!.querySelector('pattern')).toBeTruthy()
|
|
136
|
+
// The isStrokeColor-override item strokes instead of fills.
|
|
137
|
+
const outlineRect = swatches[3]!.querySelector('rect')!
|
|
138
|
+
expect(outlineRect.getAttribute('fill')).toBe('none')
|
|
139
|
+
expect(outlineRect.getAttribute('stroke')).toBe('#444')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('<LegendRampUI> ordering (regression)', () => {
|
|
144
|
+
it('boundary contract: N+1 stops label the lines between N colors, in order', () => {
|
|
145
|
+
const data: LegendRampVariable = {
|
|
146
|
+
type: 'ramp',
|
|
147
|
+
isContinuous: false,
|
|
148
|
+
colors: ['#aaa', '#bbb', '#ccc'],
|
|
149
|
+
stops: [
|
|
150
|
+
{ label: 'a', value: 0 },
|
|
151
|
+
{ label: 'b', value: 1 },
|
|
152
|
+
{ label: 'c', value: 2 },
|
|
153
|
+
{ label: 'd', value: 3 },
|
|
154
|
+
],
|
|
155
|
+
}
|
|
156
|
+
render(<LegendRampUI data={data} />)
|
|
157
|
+
// One step per color; each step's accessible name is its boundary range,
|
|
158
|
+
// in low→high order — colors and labels never reversed.
|
|
159
|
+
const steps = screen.getAllByRole('img')
|
|
160
|
+
expect(steps.map((s) => s.getAttribute('aria-label'))).toEqual([
|
|
161
|
+
'a – b',
|
|
162
|
+
'b – c',
|
|
163
|
+
'c – d',
|
|
164
|
+
])
|
|
165
|
+
// Every boundary label renders; the outer two clamp inside the bar
|
|
166
|
+
// (no transform / right-aligned) while interior ones stay centered.
|
|
167
|
+
for (const label of ['a', 'b', 'c', 'd']) {
|
|
168
|
+
expect(screen.getByText(label)).toBeTruthy()
|
|
169
|
+
}
|
|
170
|
+
expect(screen.getByText('a').style.transform).toBe('')
|
|
171
|
+
expect(screen.getByText('b').style.transform).toBe('translateX(-50%)')
|
|
172
|
+
expect(screen.getByText('c').style.transform).toBe('translateX(-50%)')
|
|
173
|
+
expect(screen.getByText('d').style.transform).toBe('translateX(-100%)')
|
|
174
|
+
expect(screen.getByText('d').style.left).toBe('100%')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('invalid discrete (stops ≠ colors + 1) renders an error, not a bar', () => {
|
|
178
|
+
const data: LegendRampVariable = {
|
|
179
|
+
type: 'ramp',
|
|
180
|
+
isContinuous: false,
|
|
181
|
+
colors: ['#aaa', '#bbb', '#ccc'], // 3 colors → needs 4 stops
|
|
182
|
+
stops: [
|
|
183
|
+
{ label: 'low', value: 0 },
|
|
184
|
+
{ label: 'mid', value: 1 },
|
|
185
|
+
{ label: 'high', value: 2 },
|
|
186
|
+
],
|
|
187
|
+
attribute: 'income',
|
|
188
|
+
}
|
|
189
|
+
render(<LegendRampUI data={data} />)
|
|
190
|
+
// The incoherent state shows an error message with the expected counts…
|
|
191
|
+
expect(screen.getByText(/needs 4 stops .* but got 3/)).toBeTruthy()
|
|
192
|
+
// …the attribute header stays for context…
|
|
193
|
+
expect(screen.getByText('income')).toBeTruthy()
|
|
194
|
+
// …and no bar/steps render.
|
|
195
|
+
expect(screen.queryAllByRole('img')).toHaveLength(0)
|
|
196
|
+
expect(screen.queryByText('low')).toBeNull()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('renders the avg marker at its linear position with a formatted tooltip', () => {
|
|
200
|
+
const data: LegendRampVariable = {
|
|
201
|
+
type: 'ramp',
|
|
202
|
+
isContinuous: true,
|
|
203
|
+
colors: ['#aaa', '#ccc'],
|
|
204
|
+
stops: [
|
|
205
|
+
{ label: '0', value: 0 },
|
|
206
|
+
{ label: '100', value: 100 },
|
|
207
|
+
],
|
|
208
|
+
avg: 25,
|
|
209
|
+
}
|
|
210
|
+
render(<LegendRampUI data={data} />)
|
|
211
|
+
// MUI Tooltip exposes its (string) title as the child's accessible name.
|
|
212
|
+
const line = screen.getByLabelText('25')
|
|
213
|
+
expect(line.style.left).toBe('25%')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('clamps an out-of-range avg to the bar edge; hides it without a scale', () => {
|
|
217
|
+
const base: LegendRampVariable = {
|
|
218
|
+
type: 'ramp',
|
|
219
|
+
isContinuous: true,
|
|
220
|
+
colors: ['#aaa', '#ccc'],
|
|
221
|
+
stops: [
|
|
222
|
+
{ label: '0', value: 0 },
|
|
223
|
+
{ label: '100', value: 100 },
|
|
224
|
+
],
|
|
225
|
+
avg: 500,
|
|
226
|
+
}
|
|
227
|
+
const { rerender } = render(<LegendRampUI data={base} />)
|
|
228
|
+
expect(screen.getByLabelText('500').style.left).toBe('100%')
|
|
229
|
+
|
|
230
|
+
// Zero-width range → no scale → no marker.
|
|
231
|
+
rerender(
|
|
232
|
+
<LegendRampUI
|
|
233
|
+
data={{
|
|
234
|
+
...base,
|
|
235
|
+
stops: [
|
|
236
|
+
{ label: 'x', value: 5 },
|
|
237
|
+
{ label: 'x', value: 5 },
|
|
238
|
+
],
|
|
239
|
+
}}
|
|
240
|
+
/>,
|
|
241
|
+
)
|
|
242
|
+
expect(screen.queryByLabelText('500')).toBeNull()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('renders a continuous gradient bar instead of steps', () => {
|
|
246
|
+
const data: LegendRampVariable = {
|
|
247
|
+
type: 'ramp',
|
|
248
|
+
isContinuous: true,
|
|
249
|
+
colors: ['#aaa', '#ccc'],
|
|
250
|
+
stops: [
|
|
251
|
+
{ label: '0', value: 0 },
|
|
252
|
+
{ label: '10', value: 10 },
|
|
253
|
+
],
|
|
254
|
+
}
|
|
255
|
+
render(<LegendRampUI data={data} />)
|
|
256
|
+
// The gradient bar is the only role=img element; no discrete steps.
|
|
257
|
+
const imgs = screen.getAllByRole('img')
|
|
258
|
+
expect(imgs).toHaveLength(1)
|
|
259
|
+
expect(imgs[0]!.getAttribute('aria-label')).toBe('Color gradient')
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('<LegendProportionUI>', () => {
|
|
264
|
+
// The MAX/MIN labels render the prefix and the formatted value as separate
|
|
265
|
+
// text nodes, so match on the element's full textContent.
|
|
266
|
+
const fullText =
|
|
267
|
+
(text: string) =>
|
|
268
|
+
(_content: string, el: Element | null): boolean =>
|
|
269
|
+
el?.textContent === text
|
|
270
|
+
const compact = (value: number): string => formatNumber(value, 'en-US')
|
|
271
|
+
|
|
272
|
+
it('labels the breaks with MAX/MIN prefixes via the supplied formatValue', () => {
|
|
273
|
+
const data: LegendProportionVariable = {
|
|
274
|
+
type: 'proportion',
|
|
275
|
+
min: 13_000,
|
|
276
|
+
max: 36_000_000,
|
|
277
|
+
stops: [36_000_000, 310_000, 110_000, 13_000],
|
|
278
|
+
formatValue: compact,
|
|
279
|
+
}
|
|
280
|
+
const { container } = render(<LegendProportionUI data={data} />)
|
|
281
|
+
expect(screen.getByText(fullText('MAX: 36M'))).toBeTruthy()
|
|
282
|
+
expect(screen.getByText(fullText('MIN: 13K'))).toBeTruthy()
|
|
283
|
+
// Middle breaks are bare (single text node).
|
|
284
|
+
expect(screen.getByText('310K')).toBeTruthy()
|
|
285
|
+
expect(screen.getByText('110K')).toBeTruthy()
|
|
286
|
+
// One decorative circle per break; no dashed tick connectors.
|
|
287
|
+
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(4)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('defaults to String(value) when no formatValue is given', () => {
|
|
291
|
+
const data: LegendProportionVariable = {
|
|
292
|
+
type: 'proportion',
|
|
293
|
+
min: 0,
|
|
294
|
+
max: 36_000_000,
|
|
295
|
+
stops: [36_000_000, 13_000],
|
|
296
|
+
}
|
|
297
|
+
render(<LegendProportionUI data={data} />)
|
|
298
|
+
expect(screen.getByText(fullText('MAX: 36000000'))).toBeTruthy()
|
|
299
|
+
expect(screen.getByText(fullText('MIN: 13000'))).toBeTruthy()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('clamps `steps` to 2–4 and regenerates the values dynamically', () => {
|
|
303
|
+
const data: LegendProportionVariable = {
|
|
304
|
+
type: 'proportion',
|
|
305
|
+
min: 0,
|
|
306
|
+
max: 100,
|
|
307
|
+
steps: 6, // clamped to 4
|
|
308
|
+
}
|
|
309
|
+
const { container, rerender } = render(<LegendProportionUI data={data} />)
|
|
310
|
+
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(4)
|
|
311
|
+
|
|
312
|
+
rerender(<LegendProportionUI data={{ ...data, steps: 1 }} />) // clamped to 2
|
|
313
|
+
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2)
|
|
314
|
+
// Values follow the step count: only the endpoints remain.
|
|
315
|
+
expect(screen.getByText(fullText('MAX: 100'))).toBeTruthy()
|
|
316
|
+
expect(screen.getByText(fullText('MIN: 0'))).toBeTruthy()
|
|
317
|
+
|
|
318
|
+
rerender(<LegendProportionUI data={{ ...data, steps: 3 }} />)
|
|
319
|
+
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(3)
|
|
320
|
+
expect(screen.getByText('50')).toBeTruthy() // dynamic middle break
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('caps explicit stops at the 4 largest; circles match labels 1:1', () => {
|
|
324
|
+
const data: LegendProportionVariable = {
|
|
325
|
+
type: 'proportion',
|
|
326
|
+
min: 0,
|
|
327
|
+
max: 500,
|
|
328
|
+
stops: [500, 400, 300, 200, 100], // 5 stops → 4 largest kept
|
|
329
|
+
steps: 2, // ignored when stops are provided
|
|
330
|
+
}
|
|
331
|
+
const { container } = render(<LegendProportionUI data={data} />)
|
|
332
|
+
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(4)
|
|
333
|
+
expect(screen.getByText(fullText('MAX: 500'))).toBeTruthy()
|
|
334
|
+
expect(screen.getByText(fullText('MIN: 200'))).toBeTruthy() // smallest shown
|
|
335
|
+
expect(screen.queryByText('100')).toBeNull() // dropped
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('falls back to evenly-spaced breaks from min/max when stops omitted', () => {
|
|
339
|
+
const data: LegendProportionVariable = {
|
|
340
|
+
type: 'proportion',
|
|
341
|
+
min: 0,
|
|
342
|
+
max: 1000,
|
|
343
|
+
steps: 3,
|
|
344
|
+
formatValue: compact,
|
|
345
|
+
}
|
|
346
|
+
render(<LegendProportionUI data={data} />)
|
|
347
|
+
expect(screen.getByText(fullText('MAX: 1K'))).toBeTruthy()
|
|
348
|
+
expect(screen.getByText(fullText('MIN: 0'))).toBeTruthy()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('renders the "Radius range by" attribute header when set', () => {
|
|
352
|
+
const data: LegendProportionVariable = {
|
|
353
|
+
type: 'proportion',
|
|
354
|
+
min: 1,
|
|
355
|
+
max: 10,
|
|
356
|
+
attribute: 'revenue',
|
|
357
|
+
}
|
|
358
|
+
render(<LegendProportionUI data={data} />)
|
|
359
|
+
expect(screen.getByText('Radius range by')).toBeTruthy()
|
|
360
|
+
expect(screen.getByText('revenue')).toBeTruthy()
|
|
361
|
+
})
|
|
362
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { LegendCategoryUI } from '../legend-category/legend-category-ui'
|
|
2
|
+
import { LegendRampUI } from '../legend-ramp/legend-ramp-ui'
|
|
3
|
+
import { LegendProportionUI } from '../legend-proportion/legend-proportion-ui'
|
|
4
|
+
import { LegendIconUI } from '../legend-icon/legend-icon-ui'
|
|
5
|
+
import type { LegendLabels } from '../../provider/labels'
|
|
6
|
+
import type { LegendVariable } from '../../stores'
|
|
7
|
+
|
|
8
|
+
export interface LegendItemUIProps {
|
|
9
|
+
data: LegendVariable
|
|
10
|
+
/** Forwarded to the renderers that show text (ramp header, proportion labels). */
|
|
11
|
+
labels?: Pick<
|
|
12
|
+
LegendLabels,
|
|
13
|
+
| 'colorBasedOn'
|
|
14
|
+
| 'colorGradient'
|
|
15
|
+
| 'radiusRangeBy'
|
|
16
|
+
| 'maxPrefix'
|
|
17
|
+
| 'minPrefix'
|
|
18
|
+
>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Dispatches to the matching pure renderer based on the discriminated
|
|
23
|
+
* `variables.type`. Adding a new legend type is localized to this switch.
|
|
24
|
+
*
|
|
25
|
+
* @experimental This API is new and may change in a future release.
|
|
26
|
+
*/
|
|
27
|
+
export function LegendItemUI({ data, labels }: LegendItemUIProps) {
|
|
28
|
+
switch (data.type) {
|
|
29
|
+
case 'category':
|
|
30
|
+
return <LegendCategoryUI data={data} />
|
|
31
|
+
case 'ramp':
|
|
32
|
+
return <LegendRampUI data={data} labels={labels} />
|
|
33
|
+
case 'proportion':
|
|
34
|
+
return <LegendProportionUI data={data} labels={labels} />
|
|
35
|
+
case 'icon':
|
|
36
|
+
return <LegendIconUI data={data} />
|
|
37
|
+
default: {
|
|
38
|
+
// Exhaustiveness guard — a new variant must be handled above.
|
|
39
|
+
const _exhaustive: never = data
|
|
40
|
+
return _exhaustive
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Box } from '@mui/material'
|
|
2
|
+
import { useLegendId, useLegendShallow } from '../../stores'
|
|
3
|
+
import { useLegendConfig } from '../../provider'
|
|
4
|
+
import { useLegendRow } from '../contexts'
|
|
5
|
+
import { LegendItemUI } from './legend-item-ui'
|
|
6
|
+
import { styles } from './styles'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Store-connected item renderer — reads the layer's `variables` from the
|
|
10
|
+
* enclosing `Legend.Row` context (plus the Provider's labels) and stacks one
|
|
11
|
+
* pure renderer per variable, top-to-bottom with the design's 16px section
|
|
12
|
+
* gap. Use `Legend.ItemUI` directly for store-free, single-variable
|
|
13
|
+
* composition.
|
|
14
|
+
*
|
|
15
|
+
* @experimental This API is new and may change in a future release.
|
|
16
|
+
*/
|
|
17
|
+
export function LegendItem() {
|
|
18
|
+
const id = useLegendId()
|
|
19
|
+
const row = useLegendRow()
|
|
20
|
+
const variables = useLegendShallow(id, (s) =>
|
|
21
|
+
row ? s.layers[row.layerId]?.variables : undefined,
|
|
22
|
+
)
|
|
23
|
+
const { labels } = useLegendConfig()
|
|
24
|
+
|
|
25
|
+
if (!variables || variables.length === 0) return null
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Box sx={styles.stack}>
|
|
29
|
+
{variables.map((variable, index) => (
|
|
30
|
+
<LegendItemUI key={index} data={variable} labels={labels} />
|
|
31
|
+
))}
|
|
32
|
+
</Box>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SxProps, Theme } from '@mui/material'
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
// Figma: stacked variable sections sit ~16px apart (wider than the row
|
|
5
|
+
// content's 8px block gap).
|
|
6
|
+
stack: {
|
|
7
|
+
display: 'flex',
|
|
8
|
+
flexDirection: 'column',
|
|
9
|
+
gap: 2,
|
|
10
|
+
},
|
|
11
|
+
} satisfies Record<string, SxProps<Theme>>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
IconButton,
|
|
5
|
+
InputAdornment,
|
|
6
|
+
OutlinedInput,
|
|
7
|
+
Popover,
|
|
8
|
+
Slider,
|
|
9
|
+
Tooltip,
|
|
10
|
+
} from '@mui/material'
|
|
11
|
+
import { Opacity as OpacityIcon } from '@carto/meridian-ds/custom-icons'
|
|
12
|
+
import { useLegendId, useLegendStore } from '../../stores'
|
|
13
|
+
import { useLegendConfig } from '../../provider'
|
|
14
|
+
import { useLegendRow } from '../contexts'
|
|
15
|
+
import { styles } from './styles'
|
|
16
|
+
|
|
17
|
+
const clampPercent = (value: number): number =>
|
|
18
|
+
Math.min(100, Math.max(0, Math.round(value)))
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Opacity header action for a layer row: a checkerboard icon button, pinned
|
|
22
|
+
* `.active` in the hover-fade group while opacity ≠ 1 or the popover is open,
|
|
23
|
+
* disabled while the layer is hidden. Opens `Legend.Opacity.Popover` through
|
|
24
|
+
* the row context. Place it inside `<Legend.Actions>` of a `Legend.Row`.
|
|
25
|
+
*
|
|
26
|
+
* @experimental This API is new and may change in a future release.
|
|
27
|
+
*/
|
|
28
|
+
function OpacityTrigger() {
|
|
29
|
+
const id = useLegendId()
|
|
30
|
+
const row = useLegendRow()
|
|
31
|
+
const layerId = row?.layerId
|
|
32
|
+
const opacity = useLegendStore(id, (s) =>
|
|
33
|
+
layerId ? (s.layers[layerId]?.opacity ?? 1) : 1,
|
|
34
|
+
)
|
|
35
|
+
const visible = useLegendStore(id, (s) =>
|
|
36
|
+
layerId ? (s.layers[layerId]?.visible ?? true) : true,
|
|
37
|
+
)
|
|
38
|
+
const { labels } = useLegendConfig()
|
|
39
|
+
|
|
40
|
+
// A hidden layer shows only its visibility eye + collapse chevron — the
|
|
41
|
+
// opacity action disappears entirely until the layer is shown again.
|
|
42
|
+
if (!row || !visible) return null
|
|
43
|
+
const dirty = opacity !== 1
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
// The wrapper keeps the Tooltip working on the button; as the fade
|
|
47
|
+
// group's direct child it also carries the `.active` pin.
|
|
48
|
+
<Tooltip title={labels.opacity}>
|
|
49
|
+
<Box
|
|
50
|
+
component='span'
|
|
51
|
+
className={dirty || row.opacityOpen ? 'active' : undefined}
|
|
52
|
+
sx={styles.triggerWrap}
|
|
53
|
+
>
|
|
54
|
+
<IconButton
|
|
55
|
+
size='small'
|
|
56
|
+
onClick={() => row.setOpacityOpen(true)}
|
|
57
|
+
aria-label={labels.opacity}
|
|
58
|
+
>
|
|
59
|
+
<OpacityIcon fontSize='small' />
|
|
60
|
+
</IconButton>
|
|
61
|
+
</Box>
|
|
62
|
+
</Tooltip>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The opacity editing surface — a popover **anchored to the row's sticky
|
|
68
|
+
* header, spanning its full width**, holding a slider (writes `setOpacity`
|
|
69
|
+
* only on release, widgets-v2 parity) and a percent input (commits live as
|
|
70
|
+
* soon as the draft parses, clamped 0–100; never ellipsizes "100 %").
|
|
71
|
+
* Declare it anywhere inside the `Legend.Row` (it renders in a portal);
|
|
72
|
+
* open it with `Legend.Opacity.Trigger`.
|
|
73
|
+
*
|
|
74
|
+
* @experimental This API is new and may change in a future release.
|
|
75
|
+
*/
|
|
76
|
+
function OpacityPopover() {
|
|
77
|
+
const id = useLegendId()
|
|
78
|
+
const row = useLegendRow()
|
|
79
|
+
const layerId = row?.layerId
|
|
80
|
+
const opacity = useLegendStore(id, (s) =>
|
|
81
|
+
layerId ? (s.layers[layerId]?.opacity ?? 1) : 1,
|
|
82
|
+
)
|
|
83
|
+
const visible = useLegendStore(id, (s) =>
|
|
84
|
+
layerId ? (s.layers[layerId]?.visible ?? true) : true,
|
|
85
|
+
)
|
|
86
|
+
const setOpacity = useLegendStore(id, (s) => s.setOpacity)
|
|
87
|
+
const { labels } = useLegendConfig()
|
|
88
|
+
|
|
89
|
+
// Drag and typing drafts: the thumb and the input track each other through
|
|
90
|
+
// `percent` while only commits reach the store (and the map).
|
|
91
|
+
const [dragPercent, setDragPercent] = useState<number | null>(null)
|
|
92
|
+
const [draft, setDraft] = useState<string | null>(null)
|
|
93
|
+
|
|
94
|
+
// The trigger is gone while hidden; also gate `open` so a popover left open
|
|
95
|
+
// can't survive the layer being hidden elsewhere.
|
|
96
|
+
if (!row || !layerId || !visible) return null
|
|
97
|
+
|
|
98
|
+
const percent = dragPercent ?? clampPercent(opacity * 100)
|
|
99
|
+
|
|
100
|
+
const commitPercent = (value: number): number => {
|
|
101
|
+
const clamped = clampPercent(value)
|
|
102
|
+
setOpacity(layerId, clamped / 100)
|
|
103
|
+
return clamped
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const handleInputChange = (raw: string) => {
|
|
107
|
+
setDraft(raw)
|
|
108
|
+
if (raw.trim() === '') return
|
|
109
|
+
const parsed = Number(raw)
|
|
110
|
+
if (!Number.isFinite(parsed)) return
|
|
111
|
+
const clamped = commitPercent(parsed)
|
|
112
|
+
// Out-of-range entries snap the visible draft to the committed value.
|
|
113
|
+
if (clamped !== parsed) setDraft(String(clamped))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const handleClose = () => {
|
|
117
|
+
row.setOpacityOpen(false)
|
|
118
|
+
setDraft(null)
|
|
119
|
+
setDragPercent(null)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Popover
|
|
124
|
+
open={row.opacityOpen && Boolean(row.headerEl)}
|
|
125
|
+
anchorEl={row.headerEl}
|
|
126
|
+
onClose={handleClose}
|
|
127
|
+
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
|
128
|
+
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
|
129
|
+
// Full row width: the paper matches the sticky header it anchors to.
|
|
130
|
+
slotProps={{ paper: { sx: { width: row.headerEl?.clientWidth } } }}
|
|
131
|
+
>
|
|
132
|
+
<Box sx={styles.popover}>
|
|
133
|
+
<Slider
|
|
134
|
+
size='small'
|
|
135
|
+
min={0}
|
|
136
|
+
max={100}
|
|
137
|
+
step={1}
|
|
138
|
+
value={percent}
|
|
139
|
+
onChange={(_, value) => {
|
|
140
|
+
setDragPercent(value as number)
|
|
141
|
+
setDraft(null)
|
|
142
|
+
}}
|
|
143
|
+
onChangeCommitted={(_, value) => {
|
|
144
|
+
commitPercent(value as number)
|
|
145
|
+
setDragPercent(null)
|
|
146
|
+
}}
|
|
147
|
+
aria-label={labels.opacity}
|
|
148
|
+
sx={styles.slider}
|
|
149
|
+
/>
|
|
150
|
+
<OutlinedInput
|
|
151
|
+
size='small'
|
|
152
|
+
value={draft ?? String(percent)}
|
|
153
|
+
onChange={(event) => handleInputChange(event.target.value)}
|
|
154
|
+
// An abandoned draft (empty / non-numeric) snaps back to the
|
|
155
|
+
// store value on blur.
|
|
156
|
+
onBlur={() => setDraft(null)}
|
|
157
|
+
onKeyDown={(event) => {
|
|
158
|
+
if (event.key === 'Enter') (event.target as HTMLElement).blur()
|
|
159
|
+
}}
|
|
160
|
+
endAdornment={<InputAdornment position='end'>%</InputAdornment>}
|
|
161
|
+
inputProps={{ inputMode: 'numeric', 'aria-label': labels.opacity }}
|
|
162
|
+
sx={styles.input}
|
|
163
|
+
/>
|
|
164
|
+
</Box>
|
|
165
|
+
</Popover>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compound opacity control for layer rows (widgets-v2 `FullScreen` parity):
|
|
171
|
+
* `Trigger` lives in the header `<Legend.Actions>`, `Popover` anywhere in the
|
|
172
|
+
* row body — they share state through the row context.
|
|
173
|
+
*
|
|
174
|
+
* @experimental This API is new and may change in a future release.
|
|
175
|
+
*/
|
|
176
|
+
export const LegendOpacity = {
|
|
177
|
+
Trigger: OpacityTrigger,
|
|
178
|
+
Popover: OpacityPopover,
|
|
179
|
+
}
|