@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.
@@ -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 `name` field. Keeps the dataset shape (one inner array per series).
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(input: unknown, search: string): unknown {
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
- const name = rawName == null ? '' : stringifyPrimitive(rawName)
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
- // The transform fn closes over `text`. Memoize via useCallback so identity
50
- // only changes when text changes, allowing useTransform to re-register.
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 interacting with an action
18
- * doesn't toggle the wrapper's collapse.
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 inside the AccordionSummary, the body inside AccordionDetails.
116
- expect(actionBtn.closest('.MuiAccordionSummary-root')).not.toBeNull()
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 summary row. Accordion rotates it
58
- * 180° automatically when the wrapper expands. Default: `ExpandMoreIcon`.
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={!effectiveCollapsed}
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
- {isFetching ? (
135
- <LinearProgress sx={styles.loading} color='primary' />
136
- ) : null}
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
- <SmartTooltip title={title}>
150
- {({ ref }) => (
151
- <Box sx={styles.titleCell}>
152
- <Typography
153
- ref={ref}
154
- variant='subtitle1'
155
- sx={styles.titleText}
156
- textTransform='none'
157
- >
158
- {title}
159
- </Typography>
160
- </Box>
161
- )}
162
- </SmartTooltip>
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
- </AccordionSummary>
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
  )