@carto/ps-react-ui 4.23.0 → 4.24.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/{actions-D7LB9DvT.js → actions-B1ss030_.js} +187 -181
- package/dist/actions-B1ss030_.js.map +1 -0
- package/dist/legend.js +34 -21
- package/dist/legend.js.map +1 -1
- package/dist/types/legend/stores/types.d.ts +4 -2
- package/dist/types/widgets-v2/actions/searcher/filter.d.ts +3 -2
- package/dist/types/widgets-v2/wrapper/style.d.ts +31 -2
- package/dist/types/widgets-v2/wrapper/widget-actions.d.ts +2 -2
- package/dist/types/widgets-v2/wrapper/widget-wrapper.d.ts +2 -2
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2.js +342 -295
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +1 -1
- package/src/legend/components/legend-item/legend-item-ui.test.tsx +22 -1
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +22 -6
- package/src/legend/stores/types.ts +6 -11
- package/src/widgets-v2/actions/searcher/filter.test.ts +28 -0
- package/src/widgets-v2/actions/searcher/filter.ts +22 -4
- package/src/widgets-v2/actions/searcher/searcher-toggle.tsx +9 -4
- package/src/widgets-v2/actions/searcher/searcher.test.tsx +22 -0
- package/src/widgets-v2/wrapper/style.ts +38 -2
- package/src/widgets-v2/wrapper/widget-actions.tsx +2 -2
- package/src/widgets-v2/wrapper/widget-wrapper.test.tsx +32 -2
- package/src/widgets-v2/wrapper/widget-wrapper.tsx +58 -32
- package/dist/actions-D7LB9DvT.js.map +0 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterBySearchText } from './filter'
|
|
3
|
+
|
|
4
|
+
const DATA = [
|
|
5
|
+
[
|
|
6
|
+
{ name: 'ES', value: 1 },
|
|
7
|
+
{ name: 'FR', value: 2 },
|
|
8
|
+
],
|
|
9
|
+
]
|
|
10
|
+
const COUNTRIES: Record<string, string> = { ES: 'Spain', FR: 'France' }
|
|
11
|
+
const label = (v: string | number) => COUNTRIES[v] ?? v
|
|
12
|
+
|
|
13
|
+
describe('filterBySearchText', () => {
|
|
14
|
+
it('matches the raw name when there is no label formatter', () => {
|
|
15
|
+
expect(filterBySearchText(DATA, 'es')).toEqual([[{ name: 'ES', value: 1 }]])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('matches the formatted label, which is what the user sees', () => {
|
|
19
|
+
expect(filterBySearchText(DATA, 'spa', label)).toEqual([
|
|
20
|
+
[{ name: 'ES', value: 1 }],
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('no longer matches a raw name the label does not show', () => {
|
|
25
|
+
// "es" is in the raw code "ES" but in neither "Spain" nor "France".
|
|
26
|
+
expect(filterBySearchText(DATA, 'es', label)).toEqual([[]])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filters a 2D series of `{name, ...}` records by case-insensitive substring on
|
|
3
|
-
* the
|
|
3
|
+
* the label the user sees: `labelFormatter(name)` when a formatter is given,
|
|
4
|
+
* `name` otherwise. Keeps the dataset shape (one inner array per series).
|
|
4
5
|
* Used by the Searcher action's data transform.
|
|
5
6
|
*/
|
|
6
|
-
export function filterBySearchText(
|
|
7
|
+
export function filterBySearchText(
|
|
8
|
+
input: unknown,
|
|
9
|
+
search: string,
|
|
10
|
+
labelFormatter?: (value: string | number) => string | number,
|
|
11
|
+
): unknown {
|
|
7
12
|
if (!search) return input
|
|
8
13
|
const needle = search.toLowerCase()
|
|
9
14
|
if (!Array.isArray(input)) return input
|
|
@@ -13,14 +18,27 @@ export function filterBySearchText(input: unknown, search: string): unknown {
|
|
|
13
18
|
if (item == null) return false
|
|
14
19
|
if (typeof item === 'object' && 'name' in item) {
|
|
15
20
|
const rawName = (item as { name: unknown }).name
|
|
16
|
-
|
|
17
|
-
return name.toLowerCase().includes(needle)
|
|
21
|
+
return labelOf(rawName, labelFormatter).toLowerCase().includes(needle)
|
|
18
22
|
}
|
|
19
23
|
return stringifyPrimitive(item).toLowerCase().includes(needle)
|
|
20
24
|
})
|
|
21
25
|
})
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
function labelOf(
|
|
29
|
+
name: unknown,
|
|
30
|
+
labelFormatter?: (value: string | number) => string | number,
|
|
31
|
+
): string {
|
|
32
|
+
if (name == null) return ''
|
|
33
|
+
if (
|
|
34
|
+
labelFormatter &&
|
|
35
|
+
(typeof name === 'string' || typeof name === 'number')
|
|
36
|
+
) {
|
|
37
|
+
return stringifyPrimitive(labelFormatter(name))
|
|
38
|
+
}
|
|
39
|
+
return stringifyPrimitive(name)
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
function stringifyPrimitive(v: unknown): string {
|
|
25
43
|
if (typeof v === 'string') return v
|
|
26
44
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
@@ -46,11 +46,16 @@ export function SearcherToggle({
|
|
|
46
46
|
(s) => (s.transformStates.searcher?.searchText as string | undefined) ?? '',
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
49
|
+
// Matched against the label the user sees, so a formatted name is found
|
|
50
|
+
// by what's on screen rather than by its raw value.
|
|
51
|
+
const labelFormatter = useWidget(id, (s) => s.labelFormatter)
|
|
52
|
+
|
|
53
|
+
// The transform fn closes over `text` and `labelFormatter`. Memoize via
|
|
54
|
+
// useCallback so identity only changes when either does, allowing
|
|
55
|
+
// useTransform to re-register.
|
|
51
56
|
const fn = useCallback(
|
|
52
|
-
(data: unknown) => filterBySearchText(data, text),
|
|
53
|
-
[text],
|
|
57
|
+
(data: unknown) => filterBySearchText(data, text, labelFormatter),
|
|
58
|
+
[text, labelFormatter],
|
|
54
59
|
)
|
|
55
60
|
|
|
56
61
|
const { enabled, toggle } = useSingleTransform(id, SEARCHER_DESCRIPTOR, fn, {
|
|
@@ -89,4 +89,26 @@ describe('<Searcher> + <SearcherToggle>', () => {
|
|
|
89
89
|
vi.useRealTimers()
|
|
90
90
|
}
|
|
91
91
|
})
|
|
92
|
+
|
|
93
|
+
it('filters by the formatted label the widget shows', () => {
|
|
94
|
+
const labelFormatter = (v: string | number) =>
|
|
95
|
+
({ apple: 'Manzana', banana: 'Plátano' })[String(v)] ?? v
|
|
96
|
+
render(
|
|
97
|
+
<Provider id='sr-label' data={DATA} labelFormatter={labelFormatter}>
|
|
98
|
+
<SearcherToggle initialEnabled />
|
|
99
|
+
<Searcher />
|
|
100
|
+
</Provider>,
|
|
101
|
+
)
|
|
102
|
+
act(() =>
|
|
103
|
+
getWidgetStore('sr-label').setState((s) => ({
|
|
104
|
+
transformStates: {
|
|
105
|
+
...s.transformStates,
|
|
106
|
+
searcher: { ...s.transformStates.searcher!, searchText: 'manz' },
|
|
107
|
+
},
|
|
108
|
+
})),
|
|
109
|
+
)
|
|
110
|
+
expect(getWidgetStore('sr-label').getState().data).toEqual([
|
|
111
|
+
[{ name: 'apple', value: 1 }],
|
|
112
|
+
])
|
|
113
|
+
})
|
|
92
114
|
})
|
|
@@ -57,14 +57,50 @@ export const styles = {
|
|
|
57
57
|
height: ({ spacing }: Theme) => spacing(0.35),
|
|
58
58
|
zIndex: 1,
|
|
59
59
|
},
|
|
60
|
+
// Title button, then Actions / Options, then the chevron, as siblings.
|
|
61
|
+
// Same spacing as when they all sat inside the summary: its 16px end
|
|
62
|
+
// padding moves to the row, the content's 6px end padding sits before
|
|
63
|
+
// the chevron.
|
|
64
|
+
header: {
|
|
65
|
+
display: 'flex',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
gap: ({ spacing }: Theme) => spacing(0.5),
|
|
68
|
+
minHeight: ({ spacing }: Theme) => spacing(7),
|
|
69
|
+
paddingInlineEnd: ({ spacing }: Theme) => spacing(2),
|
|
70
|
+
},
|
|
60
71
|
summary: {
|
|
72
|
+
flex: 1,
|
|
73
|
+
minWidth: 0,
|
|
61
74
|
minHeight: ({ spacing }: Theme) => spacing(7),
|
|
75
|
+
paddingInlineEnd: 0,
|
|
62
76
|
'& .MuiAccordionSummary-content': {
|
|
63
|
-
gap: ({ spacing }: Theme) => spacing(0.5),
|
|
64
|
-
paddingInlineEnd: ({ spacing }: Theme) => spacing(0.75),
|
|
65
77
|
alignItems: 'center',
|
|
78
|
+
minWidth: 0,
|
|
79
|
+
marginInlineEnd: 0,
|
|
80
|
+
paddingInlineEnd: 0,
|
|
66
81
|
},
|
|
67
82
|
},
|
|
83
|
+
// No chevron (disabled): keep the actions where they sat when the
|
|
84
|
+
// chevron's slot was empty — 10px further from the edge.
|
|
85
|
+
headerNoIcon: {
|
|
86
|
+
paddingInlineEnd: ({ spacing }: Theme) => spacing(3.25),
|
|
87
|
+
},
|
|
88
|
+
// Looks like the icon the summary used to render in its
|
|
89
|
+
// `expandIconWrapper`: 18px, secondary text colour, rotated when open.
|
|
90
|
+
expandIcon: {
|
|
91
|
+
display: 'flex',
|
|
92
|
+
marginInlineStart: ({ spacing }: Theme) => spacing(0.75),
|
|
93
|
+
color: 'text.secondary',
|
|
94
|
+
cursor: 'pointer',
|
|
95
|
+
'& svg': { width: 18, height: 18, color: 'inherit' },
|
|
96
|
+
transition: ({ transitions }: Theme) =>
|
|
97
|
+
transitions.create('transform', {
|
|
98
|
+
duration: transitions.duration.shortest,
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
expanded: {
|
|
102
|
+
transform: 'rotate(180deg)',
|
|
103
|
+
},
|
|
68
104
|
// Disabled state — only the collapse toggle is inert (enforced by the
|
|
69
105
|
// guarded `onChange` handler, not `pointer-events`). Actions and Options in
|
|
70
106
|
// the summary row, and the details panel, all keep their events. We just
|
|
@@ -14,8 +14,8 @@ export interface ActionsProps {
|
|
|
14
14
|
* pointer devices), and stay full-opacity when a descendant is marked with
|
|
15
15
|
* `.active` — typically applied by toggled actions.
|
|
16
16
|
*
|
|
17
|
-
* The container also stops click propagation so
|
|
18
|
-
*
|
|
17
|
+
* The container also stops click propagation, so a click on an action never
|
|
18
|
+
* reaches handlers further up the widget.
|
|
19
19
|
*/
|
|
20
20
|
export function Actions({ sx, children }: ActionsProps) {
|
|
21
21
|
return (
|
|
@@ -112,11 +112,41 @@ describe('<Wrapper>', () => {
|
|
|
112
112
|
)
|
|
113
113
|
const actionBtn = screen.getByTestId('action-btn')
|
|
114
114
|
const bodyContent = screen.getByTestId('body-content')
|
|
115
|
-
// The action lives
|
|
116
|
-
|
|
115
|
+
// The action lives in the header row, beside the summary button rather
|
|
116
|
+
// than inside it (a <button> can't contain another); the body lives in
|
|
117
|
+
// AccordionDetails.
|
|
118
|
+
expect(actionBtn.closest('.widget-wrapper-header')).not.toBeNull()
|
|
119
|
+
expect(actionBtn.closest('.MuiAccordionSummary-root')).toBeNull()
|
|
117
120
|
expect(bodyContent.closest('.MuiAccordionDetails-root')).not.toBeNull()
|
|
118
121
|
})
|
|
119
122
|
|
|
123
|
+
it('never nests a button inside another', () => {
|
|
124
|
+
const { container } = render(
|
|
125
|
+
withProvider(
|
|
126
|
+
'wrap-6b',
|
|
127
|
+
<Wrapper title='t'>
|
|
128
|
+
<Actions>
|
|
129
|
+
<button type='button'>A</button>
|
|
130
|
+
</Actions>
|
|
131
|
+
</Wrapper>,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
expect(container.querySelector('button button')).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('clicking the chevron toggles the wrapper', () => {
|
|
138
|
+
const { container } = render(
|
|
139
|
+
withProvider('wrap-6c', <Wrapper title='t'>body</Wrapper>),
|
|
140
|
+
)
|
|
141
|
+
const summary = screen.getByRole('button', { name: 'Collapse' })
|
|
142
|
+
expect(summary.getAttribute('aria-expanded')).toBe('true')
|
|
143
|
+
const chevron = container.querySelector(
|
|
144
|
+
'.widget-wrapper-header [aria-hidden]',
|
|
145
|
+
)
|
|
146
|
+
fireEvent.click(chevron!)
|
|
147
|
+
expect(summary.getAttribute('aria-expanded')).toBe('false')
|
|
148
|
+
})
|
|
149
|
+
|
|
120
150
|
it('shows a LinearProgress at the top when the store reports isFetching', () => {
|
|
121
151
|
render(
|
|
122
152
|
withProvider(
|
|
@@ -54,8 +54,8 @@ export interface WrapperProps {
|
|
|
54
54
|
variant?: 'outlined' | 'elevation'
|
|
55
55
|
labels?: Partial<WrapperLabels>
|
|
56
56
|
/**
|
|
57
|
-
* Icon shown at the right edge of the
|
|
58
|
-
*
|
|
57
|
+
* Icon shown at the right edge of the header row, rotated 180° while the
|
|
58
|
+
* wrapper is expanded. Default: `ExpandMoreIcon`.
|
|
59
59
|
*/
|
|
60
60
|
expandIcon?: ComponentType<SvgIconProps>
|
|
61
61
|
iconProps?: SvgIconProps
|
|
@@ -117,10 +117,23 @@ export function Wrapper({
|
|
|
117
117
|
|
|
118
118
|
const ariaLabel = effectiveCollapsed ? _labels.expand : _labels.collapse
|
|
119
119
|
|
|
120
|
+
const expanded = !effectiveCollapsed
|
|
121
|
+
const toggle = useCallback(
|
|
122
|
+
() => handleAccordionToggle(null, !expanded),
|
|
123
|
+
[handleAccordionToggle, expanded],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// The header row is the Accordion's first child, which MUI treats as its
|
|
127
|
+
// summary slot. Only the title sits inside `<AccordionSummary>` — it
|
|
128
|
+
// renders as a `<button>`, and Actions / Options hold buttons of their
|
|
129
|
+
// own, which HTML doesn't allow inside another button. They sit next to
|
|
130
|
+
// it instead, followed by the chevron: a pointer shortcut for the same
|
|
131
|
+
// toggle, hidden from assistive tech since the summary button already
|
|
132
|
+
// carries it.
|
|
120
133
|
return (
|
|
121
134
|
<Accordion
|
|
122
135
|
data-collapsed={effectiveCollapsed ? 'true' : undefined}
|
|
123
|
-
expanded={
|
|
136
|
+
expanded={expanded}
|
|
124
137
|
onChange={handleAccordionToggle}
|
|
125
138
|
disableGutters
|
|
126
139
|
elevation={0}
|
|
@@ -131,37 +144,50 @@ export function Wrapper({
|
|
|
131
144
|
...sx,
|
|
132
145
|
}}
|
|
133
146
|
>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<AccordionSummary
|
|
139
|
-
expandIcon={
|
|
140
|
-
disabled ? null : <ExpandIcon fontSize='small' {...iconProps} />
|
|
141
|
-
}
|
|
142
|
-
aria-label={ariaLabel}
|
|
143
|
-
aria-disabled={disabled || undefined}
|
|
144
|
-
sx={{
|
|
145
|
-
...styles.summary,
|
|
146
|
-
...(disabled ? styles.summaryDisabled : null),
|
|
147
|
-
}}
|
|
147
|
+
<Box
|
|
148
|
+
className='widget-wrapper-header'
|
|
149
|
+
sx={{ ...styles.header, ...(disabled && styles.headerNoIcon) }}
|
|
148
150
|
>
|
|
149
|
-
|
|
150
|
-
{
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
151
|
+
{isFetching ? (
|
|
152
|
+
<LinearProgress sx={styles.loading} color='primary' />
|
|
153
|
+
) : null}
|
|
154
|
+
<AccordionSummary
|
|
155
|
+
// The chevron is rendered after Actions below; `null` also keeps
|
|
156
|
+
// a theme-level default icon out of the summary.
|
|
157
|
+
expandIcon={null}
|
|
158
|
+
aria-label={ariaLabel}
|
|
159
|
+
aria-disabled={disabled || undefined}
|
|
160
|
+
sx={{
|
|
161
|
+
...styles.summary,
|
|
162
|
+
...(disabled ? styles.summaryDisabled : null),
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<SmartTooltip title={title}>
|
|
166
|
+
{({ ref }) => (
|
|
167
|
+
<Box sx={styles.titleCell}>
|
|
168
|
+
<Typography
|
|
169
|
+
ref={ref}
|
|
170
|
+
variant='subtitle1'
|
|
171
|
+
sx={styles.titleText}
|
|
172
|
+
textTransform='none'
|
|
173
|
+
>
|
|
174
|
+
{title}
|
|
175
|
+
</Typography>
|
|
176
|
+
</Box>
|
|
177
|
+
)}
|
|
178
|
+
</SmartTooltip>
|
|
179
|
+
</AccordionSummary>
|
|
163
180
|
{summarySlots}
|
|
164
|
-
|
|
181
|
+
{disabled ? null : (
|
|
182
|
+
<Box
|
|
183
|
+
aria-hidden
|
|
184
|
+
onClick={toggle}
|
|
185
|
+
sx={{ ...styles.expandIcon, ...(expanded && styles.expanded) }}
|
|
186
|
+
>
|
|
187
|
+
<ExpandIcon fontSize='small' {...iconProps} />
|
|
188
|
+
</Box>
|
|
189
|
+
)}
|
|
190
|
+
</Box>
|
|
165
191
|
<AccordionDetails sx={styles.detail}>{detailsSlots}</AccordionDetails>
|
|
166
192
|
</Accordion>
|
|
167
193
|
)
|