frappe-ui 1.0.0-beta.61 → 1.0.0-beta.63

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 (163) hide show
  1. package/experimental/CodeEditor/CodeEditor.api.md +1 -1
  2. package/experimental/CodeEditor/CodeEditor.md +1 -1
  3. package/experimental/CodeEditor/CodeEditor.playground.vue +1 -1
  4. package/experimental/CodeEditor/CodeEditor.vue +2 -2
  5. package/experimental/CodeEditor/stories/Sizes.vue +1 -1
  6. package/experimental/CodeEditor/types.ts +1 -1
  7. package/experimental/MultiEmailInput/MultiEmailInput.cy.ts +27 -0
  8. package/experimental/MultiEmailInput/MultiEmailInput.vue +9 -1
  9. package/package.json +1 -1
  10. package/src/charts/AreaChart.vue +3 -4
  11. package/src/charts/BarChart.cy.ts +138 -0
  12. package/src/charts/BarChart.vue +3 -4
  13. package/src/charts/DonutChart.vue +9 -0
  14. package/src/charts/FunnelChart.vue +12 -3
  15. package/src/charts/HeatmapChart.vue +13 -2
  16. package/src/charts/LineChart.vue +3 -4
  17. package/src/charts/SankeyChart.vue +9 -0
  18. package/src/charts/ScatterChart.vue +9 -0
  19. package/src/charts/areaChartOptions.test.ts +1 -1
  20. package/src/charts/axisChartCommon.ts +242 -30
  21. package/src/charts/axisChartOptions.ts +3 -44
  22. package/src/charts/axisFormat.test.ts +1 -1
  23. package/src/charts/barChartOptions.test.ts +83 -2
  24. package/src/charts/categoryAxisFit.test.ts +1 -1
  25. package/src/charts/chartColors.test.ts +1 -1
  26. package/src/charts/comboChartOptions.test.ts +75 -7
  27. package/src/charts/components/ChartTooltip.vue +33 -3
  28. package/src/charts/core/useAxisChart.ts +43 -26
  29. package/src/charts/core/useChart.test.ts +32 -0
  30. package/src/charts/core/useChart.ts +28 -1
  31. package/src/charts/core/useTooltipDismiss.ts +48 -0
  32. package/src/charts/docs/AreaChart.api.md +13 -7
  33. package/src/charts/docs/AreaChart.md +8 -2
  34. package/src/charts/docs/BarChart.api.md +13 -7
  35. package/src/charts/docs/BarChart.md +18 -0
  36. package/src/charts/docs/ChartTooltip.api.md +7 -1
  37. package/src/charts/docs/LineChart.api.md +13 -7
  38. package/src/charts/docs/LineChart.md +46 -11
  39. package/src/charts/docs/Overview.md +1 -1
  40. package/src/charts/docs/ScatterChart.md +3 -0
  41. package/src/charts/donutChartOptions.test.ts +1 -1
  42. package/src/charts/heatmapOptions.test.ts +3 -3
  43. package/src/charts/heatmapOptions.ts +2 -2
  44. package/src/charts/index.ts +2 -0
  45. package/src/charts/lineChartOptions.test.ts +1 -1
  46. package/src/charts/normalizedStackOptions.test.ts +1 -1
  47. package/src/charts/referenceLines.test.ts +62 -14
  48. package/src/charts/referenceLines.ts +72 -24
  49. package/src/charts/sankeyOptions.test.ts +1 -1
  50. package/src/charts/scatterOptions.test.ts +7 -7
  51. package/src/charts/seriesData.test.ts +2 -2
  52. package/src/charts/seriesData.ts +52 -1
  53. package/src/charts/stories/BarCombo.vue +0 -1
  54. package/src/charts/stories/LineTooltipColumns.vue +44 -0
  55. package/src/charts/style.css +16 -12
  56. package/src/charts/tokens.test.ts +56 -0
  57. package/src/charts/tokens.ts +56 -12
  58. package/src/charts/tooltipItems.test.ts +261 -0
  59. package/src/charts/tooltipItems.ts +70 -0
  60. package/src/charts/types.ts +63 -7
  61. package/src/charts/valueAxisOptions.test.ts +1 -1
  62. package/src/components/Checkbox/Checkbox.cy.ts +3 -3
  63. package/src/components/Combobox/Combobox.api.md +2 -2
  64. package/src/components/Combobox/Combobox.playground.vue +1 -1
  65. package/src/components/Combobox/Combobox.vue +5 -0
  66. package/src/components/Combobox/stories/Clearable.vue +5 -5
  67. package/src/components/Combobox/types.ts +9 -8
  68. package/src/components/ContextMenu/index.ts +1 -0
  69. package/src/components/ContextMenu/types.ts +16 -0
  70. package/src/components/DesktopShell/DesktopShell.api.md +1 -1
  71. package/src/components/DesktopShell/DesktopShell.md +6 -5
  72. package/src/components/DesktopShell/DesktopShell.vue +1 -1
  73. package/src/components/DesktopShell/stories/Default.vue +8 -8
  74. package/src/components/Divider/Divider.api.md +4 -4
  75. package/src/components/Divider/index.ts +1 -1
  76. package/src/components/Divider/types.ts +24 -1
  77. package/src/components/Duration/Duration.playground.vue +1 -1
  78. package/src/components/Duration/stories/Sizes.vue +1 -1
  79. package/src/components/FormControl/FormControl.api.md +2 -2
  80. package/src/components/FormControl/FormControl.cy.ts +40 -0
  81. package/src/components/FormControl/FormControl.playground.vue +2 -0
  82. package/src/components/FormControl/FormControl.vue +6 -1
  83. package/src/components/FormControl/types.ts +7 -2
  84. package/src/components/FormLabel/FormLabel.api.md +0 -7
  85. package/src/components/FormLabel/FormLabel.cy.ts +24 -5
  86. package/src/components/FormLabel/FormLabel.playground.vue +1 -11
  87. package/src/components/FormLabel/FormLabel.vue +7 -12
  88. package/src/components/FormLabel/types.ts +0 -3
  89. package/src/components/HoverCard/HoverCard.api.md +1 -1
  90. package/src/components/HoverCard/types.ts +3 -0
  91. package/src/components/InputLabeling/InputDescription.vue +1 -1
  92. package/src/components/InputLabeling/InputLabel.vue +9 -4
  93. package/src/components/ItemListRow/ItemListRow.api.md +1 -1
  94. package/src/components/ItemListRow/ItemListRow.vue +13 -7
  95. package/src/components/ItemListRow/types.ts +7 -1
  96. package/src/components/KeyboardShortcutsDialog/KeyboardShortcutsDialog.api.md +1 -1
  97. package/src/components/KeyboardShortcutsDialog/types.ts +3 -0
  98. package/src/components/MultiSelect/MultiSelect.api.md +3 -3
  99. package/src/components/MultiSelect/MultiSelect.playground.vue +1 -1
  100. package/src/components/MultiSelect/MultiSelect.vue +5 -2
  101. package/src/components/MultiSelect/types.ts +4 -8
  102. package/src/components/Password/Password.playground.vue +1 -1
  103. package/src/components/Password/stories/Sizes.vue +1 -1
  104. package/src/components/Rating/Rating.cy.ts +1 -1
  105. package/src/components/Rating/Rating.playground.vue +1 -1
  106. package/src/components/Rating/Rating.vue +14 -8
  107. package/src/components/Rating/stories/Sizes.vue +1 -1
  108. package/src/components/Select/Select.api.md +2 -2
  109. package/src/components/Select/Select.cy.ts +19 -1
  110. package/src/components/Select/Select.playground.vue +1 -1
  111. package/src/components/Select/types.ts +3 -2
  112. package/src/components/Select/utils.ts +12 -6
  113. package/src/components/{Rail/Rail.api.md → SidebarRail/SidebarRail.api.md} +11 -11
  114. package/src/components/{Rail/Rail.cy.ts → SidebarRail/SidebarRail.cy.ts} +31 -31
  115. package/src/components/SidebarRail/SidebarRail.md +36 -0
  116. package/src/components/{Rail/Rail.vue → SidebarRail/SidebarRail.vue} +2 -2
  117. package/src/components/{Rail/RailItem.vue → SidebarRail/SidebarRailItem.vue} +12 -12
  118. package/src/components/{Rail/RailItemBadge.vue → SidebarRail/SidebarRailItemBadge.vue} +1 -1
  119. package/src/components/SidebarRail/index.ts +4 -0
  120. package/src/components/{Rail → SidebarRail}/stories/Default.vue +10 -10
  121. package/src/components/{Rail → SidebarRail}/types.ts +1 -1
  122. package/src/components/Switch/Switch.cy.ts +2 -2
  123. package/src/components/TabButtons/TabButtons.api.md +4 -4
  124. package/src/components/TabButtons/TabButtons.vue +3 -0
  125. package/src/components/TabButtons/index.ts +1 -0
  126. package/src/components/TabButtons/types.ts +7 -0
  127. package/src/components/TextInput/TextInput.cy.ts +108 -4
  128. package/src/components/TextInput/TextInput.playground.vue +1 -1
  129. package/src/components/TextInput/TextInput.vue +60 -26
  130. package/src/components/TextInput/stories/Sizes.vue +1 -1
  131. package/src/components/Textarea/Textarea.api.md +1 -1
  132. package/src/components/Textarea/Textarea.cy.ts +65 -34
  133. package/src/components/Textarea/Textarea.playground.vue +1 -1
  134. package/src/components/Textarea/Textarea.vue +39 -14
  135. package/src/components/Textarea/stories/Sizes.vue +1 -1
  136. package/src/components/Textarea/types.ts +5 -1
  137. package/src/components/TimePicker/stories/SizesAndVariants.vue +1 -1
  138. package/src/components/Tree/Tree.vue +2 -6
  139. package/src/components/Tree/types.ts +8 -0
  140. package/src/components/shared/picker/PickerShell.vue +34 -37
  141. package/src/components/shared/selection/utils.test.ts +54 -2
  142. package/src/components/shared/selection/utils.ts +64 -21
  143. package/src/components/shared/tabs/styles.ts +1 -1
  144. package/src/composables/inputTypes.ts +14 -2
  145. package/src/composables/usePortalTarget.spec.ts +6 -5
  146. package/src/index.ts +12 -1
  147. package/src/molecules/list/List.cy.ts +426 -0
  148. package/src/molecules/list/List.ssr.test.ts +59 -0
  149. package/src/molecules/list/List.vue +92 -7
  150. package/src/molecules/list/index.ts +2 -0
  151. package/src/molecules/list/list-context.ts +2 -1
  152. package/src/molecules/list/list.api.md +3 -3
  153. package/src/molecules/list/list.md +152 -33
  154. package/src/molecules/list/stories/Columns.vue +15 -9
  155. package/src/molecules/list/stories/Responsive.vue +56 -0
  156. package/src/molecules/list/stories/RowActions.vue +123 -0
  157. package/src/molecules/list/style.css +67 -26
  158. package/src/molecules/list/types.ts +51 -9
  159. package/tailwind/listColumns.js +191 -0
  160. package/tailwind/listColumns.test.js +358 -0
  161. package/tailwind/plugin.js +5 -6
  162. package/src/components/Rail/Rail.md +0 -30
  163. package/src/components/Rail/index.ts +0 -2
@@ -41,7 +41,7 @@
41
41
  },
42
42
  {
43
43
  name: 'size',
44
- description: 'Size token, mirroring frappe-ui inputs. Scales the editor\'s font size and\nminimum height (`sm | md | lg | xl`). Defaults to `md`.',
44
+ description: 'Size token, mirroring frappe-ui inputs. Scales the editor\'s font size and\nminimum height (`xs | sm | md | lg`). Defaults to `md`.',
45
45
  required: false,
46
46
  type: 'InputSize',
47
47
  default: '"md"'
@@ -40,7 +40,7 @@ and loses the affordance that it's an editable field.
40
40
 
41
41
  ## Sizes
42
42
 
43
- `size` mirrors the frappe-ui input sizes (`sm | md | lg | xl`, default `md`),
43
+ `size` mirrors the frappe-ui input sizes (`xs | sm | md | lg`, default `md`),
44
44
  scaling the editor's font size and minimum height.
45
45
 
46
46
  <ComponentPreview name="CodeEditor-Sizes" />
@@ -43,10 +43,10 @@ const knobs: Knob[] = [
43
43
  type: 'tabs',
44
44
  default: 'md',
45
45
  options: [
46
+ { label: 'xs', value: 'xs' },
46
47
  { label: 'sm', value: 'sm' },
47
48
  { label: 'md', value: 'md' },
48
49
  { label: 'lg', value: 'lg' },
49
- { label: 'xl', value: 'xl' },
50
50
  ],
51
51
  },
52
52
  { name: 'placeholder', type: 'text', default: '', width: '12rem' },
@@ -288,17 +288,17 @@ function buildVariant(v?: string, disabled?: boolean): Extension {
288
288
  return cmView.EditorView.theme(fragments[v ?? 'subtle'] ?? fragments.subtle)
289
289
  }
290
290
 
291
- // Per-size font scale + content min-height, mirroring frappe-ui's `sm|md|lg|xl`
291
+ // Per-size font scale + content min-height, mirroring frappe-ui's `xs|sm|md|lg`
292
292
  // input sizes. This compartment is the SOLE source of `&` font size and
293
293
  // `.cm-content` min-height — the base `theme` deliberately omits both so there's
294
294
  // no equal-specificity fight whose winner depends on StyleModule order.
295
295
  function buildSize(s?: string): Extension {
296
296
  if (!cmView) return []
297
297
  const sizes: Record<string, { fontSize: string; minHeight: string }> = {
298
+ xs: { fontSize: '11px', minHeight: '2.5rem' },
298
299
  sm: { fontSize: '12px', minHeight: '3.5rem' },
299
300
  md: { fontSize: '13px', minHeight: '4.5rem' },
300
301
  lg: { fontSize: '14px', minHeight: '6rem' },
301
- xl: { fontSize: '16px', minHeight: '7.5rem' },
302
302
  }
303
303
  const cfg = sizes[s ?? 'md'] ?? sizes.md
304
304
  return cmView.EditorView.theme({
@@ -7,9 +7,9 @@ const code = ref(`const sum = (a, b) => a + b`)
7
7
 
8
8
  <template>
9
9
  <div class="w-full max-w-xl space-y-4">
10
+ <CodeEditor v-model="code" language="javascript" size="xs" />
10
11
  <CodeEditor v-model="code" language="javascript" size="sm" />
11
12
  <CodeEditor v-model="code" language="javascript" size="md" />
12
13
  <CodeEditor v-model="code" language="javascript" size="lg" />
13
- <CodeEditor v-model="code" language="javascript" size="xl" />
14
14
  </div>
15
15
  </template>
@@ -42,7 +42,7 @@ export interface CodeEditorProps extends InputLabelingProps {
42
42
  variant?: Exclude<InputVariant, 'ghost'>
43
43
  /**
44
44
  * Size token, mirroring frappe-ui inputs. Scales the editor's font size and
45
- * minimum height (`sm | md | lg | xl`). Defaults to `md`.
45
+ * minimum height (`xs | sm | md | lg`). Defaults to `md`.
46
46
  */
47
47
  size?: InputSize
48
48
  }
@@ -17,6 +17,7 @@ function host(
17
17
  required?: boolean
18
18
  error?: string
19
19
  disabled?: boolean
20
+ size?: 'xs' | 'sm' | 'md' | 'lg'
20
21
  } = {},
21
22
  ) {
22
23
  return defineComponent({
@@ -46,6 +47,7 @@ function host(
46
47
  required: options.required,
47
48
  error: options.error,
48
49
  disabled: options.disabled,
50
+ size: options.size,
49
51
  placeholder: 'Add email…',
50
52
  }),
51
53
  ]
@@ -152,4 +154,29 @@ describe('MultiEmailInput', () => {
152
154
  cy.mount(host({ disabled: true }))
153
155
  cy.get('[data-slot="input"]').should('have.attr', 'disabled')
154
156
  })
157
+
158
+ // The option-row avatar has to shrink with the row. `md` (24px) is the whole
159
+ // height of an `xs` row before its padding, so a size-invariant avatar makes
160
+ // the smallest control draw the biggest picture.
161
+ const rowAvatarHeights: Array<[size: 'xs' | 'sm' | 'md' | 'lg', px: number]> =
162
+ [
163
+ ['xs', 16],
164
+ ['sm', 20],
165
+ ['md', 24],
166
+ ['lg', 24],
167
+ ]
168
+
169
+ for (const [size, px] of rowAvatarHeights) {
170
+ it(`draws a ${px}px option-row avatar at size="${size}"`, () => {
171
+ cy.mount(host({ size }))
172
+ cy.get('[data-slot="input"]').click()
173
+ cy.get('[data-slot="item"]')
174
+ .first()
175
+ .find('[data-slot="item-prefix"] > div')
176
+ .first()
177
+ .then(($avatar) => {
178
+ expect($avatar[0].getBoundingClientRect().height).to.equal(px)
179
+ })
180
+ })
181
+ }
155
182
  })
@@ -166,7 +166,15 @@ const showEmpty = computed(
166
166
  () => !props.loading && !suggestions.value.length && !showCreateOption.value,
167
167
  )
168
168
 
169
- const rowAvatarSize = computed(() => (props.size === 'sm' ? 'sm' : 'md'))
169
+ // Avatar has its own scale (`xs` 16px, `sm` 20px, `md` 24px), and the option
170
+ // row is `min-h-6`/`min-h-7`/`min-h-8`/`min-h-10` plus `ItemListRow`'s own
171
+ // padding. Step the avatar down with the row for the two small sizes rather
172
+ // than forwarding a size the row cannot draw — a `md` avatar is 24px, the
173
+ // whole height of an `xs` row before its `py-1`. `md` and `lg` rows both fit
174
+ // `md`; a 28px `lg` avatar would leave no room in a 32px `md` row.
175
+ const rowAvatarSize = computed(() =>
176
+ props.size === 'xs' ? 'xs' : props.size === 'sm' ? 'sm' : 'md',
177
+ )
170
178
 
171
179
  const boxClasses = computed(() => [
172
180
  'flex flex-wrap items-center gap-1.5 rounded-4 bg-surface-gray-2 px-1.5 py-1 transition-colors focus-within:ring-2 focus-within:ring-outline-gray-3',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.61",
3
+ "version": "1.0.0-beta.63",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -40,6 +40,7 @@
40
40
  :y="tooltip.y"
41
41
  :label="tooltip.label"
42
42
  :items="tooltip.items"
43
+ :row="tooltip.row"
43
44
  :dir="dir"
44
45
  >
45
46
  <template v-if="$slots.tooltip" #default="slotProps">
@@ -49,10 +50,7 @@
49
50
  </template>
50
51
 
51
52
  <template v-if="legendItems.length > 1" #legend>
52
- <ChartLegend
53
- :items="legendItems"
54
- @change="toggleSeries"
55
- />
53
+ <ChartLegend :items="legendItems" @change="toggleSeries" />
56
54
  </template>
57
55
  </ChartContainer>
58
56
  </template>
@@ -130,6 +128,7 @@ const {
130
128
  format: () => normalized.value.format,
131
129
  buildOption: buildAxisChartOption,
132
130
  stackShares: () => buildStackShares(config.value, hiddenSeries.value),
131
+ tooltipColumns: () => normalized.value.tooltipColumns,
133
132
  hiddenSeries,
134
133
  onSelect: (event) => emit('select', event),
135
134
  })
@@ -98,6 +98,56 @@ describe('BarChart', () => {
98
98
  .and('contain.text', 'Sales')
99
99
  .and('contain.text', '10')
100
100
  })
101
+
102
+ // `tooltipColumns` is the context in another unit — the count behind a
103
+ // rate. It reaches the tooltip and nothing else.
104
+ describe('a tooltip-only column', () => {
105
+ const withOrders = data.map((row, index) => ({
106
+ ...row,
107
+ orders: (index + 1) * 1000,
108
+ }))
109
+
110
+ const mountWithExtra = (props: Record<string, any> = {}) =>
111
+ mountChart({
112
+ data: withOrders,
113
+ tooltipColumns: [{ name: 'orders', label: 'Orders' }],
114
+ ...props,
115
+ })
116
+
117
+ it('prints in the tooltip', () => {
118
+ mountWithExtra()
119
+ cy.get('[data-slot="chart-plot"]').trigger('mousemove', 100, 150)
120
+ cy.get('[data-slot="chart-tooltip"]')
121
+ .should('contain.text', 'Orders')
122
+ .and('contain.text', '1,000')
123
+ })
124
+
125
+ it('carries no swatch, which would claim a mark on the plot', () => {
126
+ mountWithExtra()
127
+ cy.get('[data-slot="chart-plot"]').trigger('mousemove', 100, 150)
128
+ // Two series, and the column adds no third.
129
+ cy.get('[data-slot="chart-tooltip"] .size-2').should('have.length', 2)
130
+ })
131
+
132
+ it('draws no mark and takes no legend entry', () => {
133
+ mountWithExtra()
134
+ bars().should('have.length', data.length * 2)
135
+ cy.get('[data-slot="chart-legend"] button').should('have.length', 2)
136
+ cy.get('[data-slot="chart-legend"]').should(
137
+ 'not.contain.text',
138
+ 'Orders',
139
+ )
140
+ })
141
+
142
+ it('stays in the tooltip when the legend hides a series', () => {
143
+ mountWithExtra()
144
+ cy.get('[aria-label="Hide Sales"]').click()
145
+ cy.get('[data-slot="chart-plot"]').trigger('mousemove', 100, 150)
146
+ cy.get('[data-slot="chart-tooltip"]')
147
+ .should('not.contain.text', 'Sales')
148
+ .and('contain.text', 'Orders')
149
+ })
150
+ })
101
151
  })
102
152
 
103
153
  describe('legend', () => {
@@ -492,6 +542,94 @@ describe('BarChart', () => {
492
542
  .should('contain.text', 'at Jan: 2')
493
543
  .and('not.contain.text', 'Sales')
494
544
  })
545
+
546
+ describe('dismissal', () => {
547
+ function openTooltip() {
548
+ mountChart()
549
+ bars().should('have.length', data.length * 2)
550
+ plot().trigger('mousemove', 100, 150)
551
+ cy.get('[data-slot="chart-tooltip"]').should('exist')
552
+ }
553
+
554
+ it('closes when the page scrolls', () => {
555
+ openTooltip()
556
+ // Dispatched on the plot, not the window: a scroll event does not
557
+ // bubble, so this reaches the listener only through the capture phase.
558
+ // That is how a scroll inside a nested container arrives.
559
+ plot().then(($el) => $el[0].dispatchEvent(new Event('scroll')))
560
+ cy.get('[data-slot="chart-tooltip"]').should('not.exist')
561
+ })
562
+
563
+ it('closes when the window resizes', () => {
564
+ openTooltip()
565
+ cy.window().then((win) => win.dispatchEvent(new Event('resize')))
566
+ cy.get('[data-slot="chart-tooltip"]').should('not.exist')
567
+ })
568
+
569
+ it('closes when the pointer leaves the plot', () => {
570
+ openTooltip()
571
+ plot().trigger('pointerleave')
572
+ cy.get('[data-slot="chart-tooltip"]').should('not.exist')
573
+ })
574
+
575
+ it('closes when the window goes away', () => {
576
+ openTooltip()
577
+ cy.window().then((win) => win.dispatchEvent(new Event('blur')))
578
+ cy.get('[data-slot="chart-tooltip"]').should('not.exist')
579
+ })
580
+
581
+ it('closes when the tab is hidden', () => {
582
+ openTooltip()
583
+ cy.document().then((doc) => {
584
+ Object.defineProperty(doc, 'hidden', {
585
+ value: true,
586
+ configurable: true,
587
+ })
588
+ doc.dispatchEvent(new Event('visibilitychange'))
589
+ })
590
+ cy.get('[data-slot="chart-tooltip"]').should('not.exist')
591
+ })
592
+
593
+ it('closes when the rows behind it change', () => {
594
+ const rows = ref(data)
595
+ cy.mount(
596
+ defineComponent({
597
+ setup() {
598
+ return () =>
599
+ h('div', { style: 'width: 480px; height: 300px' }, [
600
+ h(BarChart, {
601
+ data: rows.value,
602
+ x: 'month',
603
+ y: ['sales', 'refunds'],
604
+ echartOptions: { animation: false },
605
+ }),
606
+ ])
607
+ },
608
+ }),
609
+ )
610
+ bars().should('have.length', data.length * 2)
611
+ plot().trigger('mousemove', 100, 150)
612
+ cy.get('[data-slot="chart-tooltip"]').should('exist')
613
+ cy.then(() => {
614
+ rows.value = data.map((row) => ({ ...row, sales: row.sales * 2 }))
615
+ })
616
+ cy.get('[data-slot="chart-tooltip"]').should('not.exist')
617
+ })
618
+ })
619
+
620
+ it('hands the plotted row to the tooltip slot', () => {
621
+ // The row carries every column, `tooltipColumns` or not: a tooltip the
622
+ // app draws itself needs no prop to reach one.
623
+ mountChart({ data: data.map((row) => ({ ...row, orders: 1000 })) }, {
624
+ tooltip: ({ row }: any) => h('span', `${row.orders} orders`),
625
+ } as any)
626
+ bars().should('have.length', data.length * 2)
627
+ plot().focus()
628
+ cy.get('[data-slot="chart-tooltip"]').should(
629
+ 'contain.text',
630
+ '1000 orders',
631
+ )
632
+ })
495
633
  })
496
634
 
497
635
  // echarts draws into one element, so the plot takes a single tab stop and the
@@ -41,6 +41,7 @@
41
41
  :y="tooltip.y"
42
42
  :label="tooltip.label"
43
43
  :items="tooltip.items"
44
+ :row="tooltip.row"
44
45
  :dir="dir"
45
46
  >
46
47
  <template v-if="$slots.tooltip" #default="slotProps">
@@ -50,10 +51,7 @@
50
51
  </template>
51
52
 
52
53
  <template v-if="legendItems.length > 1" #legend>
53
- <ChartLegend
54
- :items="legendItems"
55
- @change="toggleSeries"
56
- />
54
+ <ChartLegend :items="legendItems" @change="toggleSeries" />
57
55
  </template>
58
56
  </ChartContainer>
59
57
  </template>
@@ -133,6 +131,7 @@ const {
133
131
  format: () => normalized.value.format,
134
132
  buildOption: buildAxisChartOption,
135
133
  stackShares: () => buildStackShares(config.value, hiddenSeries.value),
134
+ tooltipColumns: () => normalized.value.tooltipColumns,
136
135
  horizontal: () => Boolean(props.horizontal),
137
136
  hiddenSeries,
138
137
  onSelect: (event) => emit('select', event),
@@ -105,6 +105,7 @@
105
105
  import { computed, reactive, ref, watch } from 'vue'
106
106
  import { PieChart as PieSeries } from 'echarts/charts'
107
107
  import { registerChartModules, useChart } from './core/useChart'
108
+ import { useTooltipDismiss } from './core/useTooltipDismiss'
108
109
  import { usePlotKeyboard } from './core/usePlotKeyboard'
109
110
  import {
110
111
  buildDonutChartOption,
@@ -227,6 +228,14 @@ const tooltip = reactive({
227
228
  items: [] as ChartTooltipItem[],
228
229
  })
229
230
 
231
+ useTooltipDismiss({
232
+ plot: plotEl,
233
+ data: () => visibleSlices.value,
234
+ close: () => {
235
+ tooltip.open = false
236
+ },
237
+ })
238
+
230
239
  /** Slice under the pointer, or under the hovered legend entry. */
231
240
  const hovered = ref<string | null>(null)
232
241
 
@@ -38,14 +38,14 @@
38
38
  />
39
39
  </div>
40
40
 
41
- <div class="relative mb-4 grid" :style="gridStyle">
41
+ <div class="relative mb-6 grid" :style="gridStyle">
42
42
  <div
43
43
  v-for="stage in stages"
44
44
  :key="stage.index"
45
- class="min-w-0"
45
+ class="flex min-w-0 flex-col gap-1"
46
46
  :class="stage.index === 0 ? 'pe-3' : 'px-3'"
47
47
  >
48
- <div class="truncate text-p-sm text-ink-gray-5">
48
+ <div class="truncate text-sm text-ink-gray-5">
49
49
  {{ stage.label }}
50
50
  </div>
51
51
  <div
@@ -164,6 +164,7 @@
164
164
  import { computed, reactive, ref } from 'vue'
165
165
  import { formatLabel, formatPercent, formatValue } from './format'
166
166
  import { buildFunnelStages, funnelShapes } from './funnelGeometry'
167
+ import { useTooltipDismiss } from './core/useTooltipDismiss'
167
168
  import { chartColors, useChartTokens } from './tokens'
168
169
  import { documentDir } from './utils'
169
170
  import ChartContainer from './components/ChartContainer.vue'
@@ -248,6 +249,14 @@ const tooltip = reactive({
248
249
  rates: [] as { label: string; value: string }[],
249
250
  })
250
251
 
252
+ useTooltipDismiss({
253
+ plot: root,
254
+ data: () => stages.value,
255
+ close: () => {
256
+ tooltip.open = false
257
+ },
258
+ })
259
+
251
260
  function hover(stage: FunnelStage, event: MouseEvent) {
252
261
  readStage(stage)
253
262
  track(event)
@@ -93,6 +93,7 @@ import { GridComponent, VisualMapContinuousComponent } from 'echarts/components'
93
93
  import { LabelLayout } from 'echarts/features'
94
94
  import { registerChartModules, useChart } from './core/useChart'
95
95
  import { usePlotKeyboard } from './core/usePlotKeyboard'
96
+ import { useTooltipDismiss } from './core/useTooltipDismiss'
96
97
  import { AXIS_LABEL_MARGIN } from './axisChartCommon'
97
98
  import {
98
99
  buildHeatmapMatrix,
@@ -188,6 +189,14 @@ const tooltip = reactive({
188
189
  items: [] as ChartTooltipItem[],
189
190
  })
190
191
 
192
+ useTooltipDismiss({
193
+ plot: plotEl,
194
+ data: () => matrix.value.cells,
195
+ close: () => {
196
+ tooltip.open = false
197
+ },
198
+ })
199
+
191
200
  /**
192
201
  * How much of the plot box sits below the axis line: the row echarts reserved
193
202
  * for the x-axis labels, in px.
@@ -335,8 +344,10 @@ function downplayCell(index: number | null) {
335
344
  const keyboard = usePlotKeyboard({
336
345
  marks: () => matrix.value.cells,
337
346
  // The pair of categories names the cell, whatever order the grid ends up in
338
- // and whichever refetch built the rows.
339
- key: (cell) => `${cell.y}${cell.x}`,
347
+ // and whichever refetch built the rows. NUL joins them because no category
348
+ // can hold one, so no pair of values can collide on the seam. Written as an
349
+ // escape: a literal NUL makes every text tool read this file as binary.
350
+ key: (cell) => `${cell.y}\u0000${cell.x}`,
340
351
  move: (index, previous) => {
341
352
  downplayCell(previous)
342
353
  readCell(index)
@@ -40,6 +40,7 @@
40
40
  :y="tooltip.y"
41
41
  :label="tooltip.label"
42
42
  :items="tooltip.items"
43
+ :row="tooltip.row"
43
44
  :dir="dir"
44
45
  >
45
46
  <template v-if="$slots.tooltip" #default="slotProps">
@@ -49,10 +50,7 @@
49
50
  </template>
50
51
 
51
52
  <template v-if="legendItems.length > 1" #legend>
52
- <ChartLegend
53
- :items="legendItems"
54
- @change="toggleSeries"
55
- />
53
+ <ChartLegend :items="legendItems" @change="toggleSeries" />
56
54
  </template>
57
55
  </ChartContainer>
58
56
  </template>
@@ -130,6 +128,7 @@ const {
130
128
  format: () => normalized.value.format,
131
129
  buildOption: buildAxisChartOption,
132
130
  stackShares: () => buildStackShares(config.value, hiddenSeries.value),
131
+ tooltipColumns: () => normalized.value.tooltipColumns,
133
132
  hiddenSeries,
134
133
  onSelect: (event) => emit('select', event),
135
134
  })
@@ -53,6 +53,7 @@ import { computed, reactive, ref } from 'vue'
53
53
  import { SankeyChart as SankeySeries } from 'echarts/charts'
54
54
  import { registerChartModules, useChart } from './core/useChart'
55
55
  import { usePlotKeyboard } from './core/usePlotKeyboard'
56
+ import { useTooltipDismiss } from './core/useTooltipDismiss'
56
57
  import { buildSankeyGraph, buildSankeyOption } from './sankeyOptions'
57
58
  import { formatLabel, formatValue } from './format'
58
59
  import { useChartTokens } from './tokens'
@@ -143,6 +144,14 @@ const tooltip = reactive({
143
144
  items: [] as ChartTooltipItem[],
144
145
  })
145
146
 
147
+ useTooltipDismiss({
148
+ plot: plotEl,
149
+ data: () => graph.value.links,
150
+ close: () => {
151
+ tooltip.open = false
152
+ },
153
+ })
154
+
146
155
  const { chart, dispatch } = useChart({
147
156
  container: plotEl,
148
157
  option: () => built.value.option,
@@ -64,6 +64,7 @@ import { GridComponent, MarkLineComponent } from 'echarts/components'
64
64
  import { LabelLayout } from 'echarts/features'
65
65
  import { registerChartModules, useChart } from './core/useChart'
66
66
  import { usePlotKeyboard } from './core/usePlotKeyboard'
67
+ import { useTooltipDismiss } from './core/useTooltipDismiss'
67
68
  import { buildScatterOption, buildScatterSeries } from './scatterOptions'
68
69
  import { formatLabel, formatValue } from './format'
69
70
  import { pruneHiddenSeries, toggleHiddenSeries } from './hiddenSeries'
@@ -382,6 +383,14 @@ function downplayPoint(index: number | null) {
382
383
  })
383
384
  }
384
385
 
386
+ useTooltipDismiss({
387
+ plot: plotEl,
388
+ data: () => walk.value,
389
+ close: () => {
390
+ tooltip.open = false
391
+ },
392
+ })
393
+
385
394
  const keyboard = usePlotKeyboard({
386
395
  marks: () => walk.value,
387
396
  // A point is its group and its place on the axes. Two points that sit on top
@@ -17,7 +17,7 @@ const tokens: ChartTokens = {
17
17
  splitLine: 'outline-1',
18
18
  dataLabel: 'ink-6',
19
19
  insideLabel: 'ink-8',
20
- cellGap: '#ffffff',
20
+ backdrop: '#ffffff',
21
21
  }
22
22
 
23
23
  /** What `AreaChart` hands the builder: the shared config, marked `'area'`. */