@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.
Files changed (98) hide show
  1. package/dist/components.js +706 -658
  2. package/dist/components.js.map +1 -1
  3. package/dist/legend/stores.js +20 -0
  4. package/dist/legend/stores.js.map +1 -0
  5. package/dist/legend.js +1536 -0
  6. package/dist/legend.js.map +1 -0
  7. package/dist/selectors-DwHj6mE2.js +388 -0
  8. package/dist/selectors-DwHj6mE2.js.map +1 -0
  9. package/dist/types/components/index.d.ts +1 -0
  10. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
  11. package/dist/types/legend/components/contexts.d.ts +20 -0
  12. package/dist/types/legend/components/icons.d.ts +12 -0
  13. package/dist/types/legend/components/index.d.ts +26 -0
  14. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
  15. package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
  16. package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
  17. package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
  18. package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
  19. package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
  20. package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
  21. package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
  22. package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
  23. package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
  24. package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
  25. package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
  26. package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
  27. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
  28. package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
  29. package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
  30. package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
  31. package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
  32. package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
  33. package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
  34. package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
  35. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
  36. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
  37. package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
  38. package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
  39. package/dist/types/legend/index.d.ts +88 -0
  40. package/dist/types/legend/provider/index.d.ts +6 -0
  41. package/dist/types/legend/provider/labels.d.ts +27 -0
  42. package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
  43. package/dist/types/legend/provider/legend-provider.d.ts +38 -0
  44. package/dist/types/legend/stores/index.d.ts +5 -0
  45. package/dist/types/legend/stores/legend-context.d.ts +4 -0
  46. package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
  47. package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
  48. package/dist/types/legend/stores/selectors.d.ts +33 -0
  49. package/dist/types/legend/stores/types.d.ts +275 -0
  50. package/package.json +11 -3
  51. package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
  52. package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
  53. package/src/components/index.ts +2 -0
  54. package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
  55. package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
  56. package/src/legend/components/contexts.ts +32 -0
  57. package/src/legend/components/icons.tsx +22 -0
  58. package/src/legend/components/index.ts +39 -0
  59. package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
  60. package/src/legend/components/legend-actions/styles.ts +9 -0
  61. package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
  62. package/src/legend/components/legend-category/styles.ts +61 -0
  63. package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
  64. package/src/legend/components/legend-config-select/styles.ts +48 -0
  65. package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
  66. package/src/legend/components/legend-group/legend-group.tsx +88 -0
  67. package/src/legend/components/legend-group/styles.ts +82 -0
  68. package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
  69. package/src/legend/components/legend-icon/styles.ts +56 -0
  70. package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
  71. package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
  72. package/src/legend/components/legend-item/legend-item.tsx +34 -0
  73. package/src/legend/components/legend-item/styles.ts +11 -0
  74. package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
  75. package/src/legend/components/legend-opacity/styles.ts +28 -0
  76. package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
  77. package/src/legend/components/legend-proportion/styles.ts +43 -0
  78. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
  79. package/src/legend/components/legend-ramp/styles.ts +62 -0
  80. package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
  81. package/src/legend/components/legend-row/legend-row.tsx +129 -0
  82. package/src/legend/components/legend-row/styles.ts +111 -0
  83. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
  84. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
  85. package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
  86. package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
  87. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
  88. package/src/legend/index.ts +121 -0
  89. package/src/legend/provider/index.ts +6 -0
  90. package/src/legend/provider/labels.ts +59 -0
  91. package/src/legend/provider/legend-config-context.ts +15 -0
  92. package/src/legend/provider/legend-provider.tsx +102 -0
  93. package/src/legend/stores/index.ts +46 -0
  94. package/src/legend/stores/legend-context.ts +17 -0
  95. package/src/legend/stores/legend-store-registry.ts +639 -0
  96. package/src/legend/stores/legend-store.test.ts +457 -0
  97. package/src/legend/stores/selectors.ts +65 -0
  98. 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
+ }