frappe-ui 1.0.0-beta.64 → 1.0.0-beta.65

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 (126) hide show
  1. package/experimental.ts +23 -0
  2. package/package.json +1 -1
  3. package/src/charts/AreaChart.cy.ts +2 -4
  4. package/src/charts/AreaChart.vue +7 -8
  5. package/src/charts/BarChart.cy.ts +17 -9
  6. package/src/charts/BarChart.vue +9 -10
  7. package/src/charts/DonutChart.cy.ts +68 -6
  8. package/src/charts/DonutChart.vue +32 -25
  9. package/src/charts/FunnelChart.cy.ts +38 -10
  10. package/src/charts/FunnelChart.vue +39 -26
  11. package/src/charts/HeatmapChart.cy.ts +16 -2
  12. package/src/charts/HeatmapChart.vue +7 -3
  13. package/src/charts/LineChart.cy.ts +3 -5
  14. package/src/charts/LineChart.vue +7 -8
  15. package/src/charts/NumberCard.cy.ts +21 -5
  16. package/src/charts/NumberCard.vue +11 -7
  17. package/src/charts/SankeyChart.cy.ts +20 -1
  18. package/src/charts/SankeyChart.vue +29 -18
  19. package/src/charts/ScatterChart.vue +10 -6
  20. package/src/charts/areaChartOptions.test.ts +11 -16
  21. package/src/charts/axisChartCommon.ts +25 -17
  22. package/src/charts/axisChartOptions.ts +9 -23
  23. package/src/charts/axisFormat.test.ts +1 -1
  24. package/src/charts/barChartOptions.test.ts +3 -3
  25. package/src/charts/categoryAxisFit.test.ts +1 -1
  26. package/src/charts/comboChartOptions.test.ts +9 -22
  27. package/src/charts/components/ChartContainer.cy.ts +44 -4
  28. package/src/charts/components/ChartContainer.vue +22 -17
  29. package/src/charts/components/ChartTooltip.cy.ts +13 -2
  30. package/src/charts/components/ChartTooltip.vue +9 -11
  31. package/src/charts/core/useAxisChart.test.ts +127 -0
  32. package/src/charts/core/useAxisChart.ts +64 -37
  33. package/src/charts/core/usePlotKeyboard.test.ts +7 -3
  34. package/src/charts/docs/AreaChart.api.md +15 -15
  35. package/src/charts/docs/AreaChart.md +2 -2
  36. package/src/charts/docs/BarChart.api.md +15 -15
  37. package/src/charts/docs/BarChart.md +4 -2
  38. package/src/charts/docs/ChartCard.md +2 -2
  39. package/src/charts/docs/ChartContainer.api.md +5 -5
  40. package/src/charts/docs/ChartContainer.md +7 -5
  41. package/src/charts/docs/ChartTooltip.api.md +5 -5
  42. package/src/charts/docs/ChartTooltip.md +6 -2
  43. package/src/charts/docs/DonutChart.api.md +20 -8
  44. package/src/charts/docs/DonutChart.md +7 -1
  45. package/src/charts/docs/FunnelChart.api.md +4 -11
  46. package/src/charts/docs/FunnelChart.md +9 -5
  47. package/src/charts/docs/HeatmapChart.api.md +4 -4
  48. package/src/charts/docs/HeatmapChart.md +1 -1
  49. package/src/charts/docs/LineChart.api.md +15 -15
  50. package/src/charts/docs/LineChart.md +7 -7
  51. package/src/charts/docs/NumberCard.api.md +7 -7
  52. package/src/charts/docs/NumberCard.md +6 -5
  53. package/src/charts/docs/Overview.md +69 -10
  54. package/src/charts/docs/SankeyChart.api.md +6 -6
  55. package/src/charts/docs/SankeyChart.md +1 -1
  56. package/src/charts/docs/ScatterChart.api.md +5 -5
  57. package/src/charts/docs/States.md +3 -1
  58. package/src/charts/donutChartOptions.test.ts +4 -4
  59. package/src/charts/donutChartOptions.ts +11 -9
  60. package/src/charts/format.test.ts +3 -0
  61. package/src/charts/format.ts +3 -2
  62. package/src/charts/funnelGeometry.test.ts +33 -2
  63. package/src/charts/funnelGeometry.ts +32 -11
  64. package/src/charts/heatmapOptions.test.ts +7 -5
  65. package/src/charts/heatmapOptions.ts +12 -10
  66. package/src/charts/index.ts +6 -9
  67. package/src/charts/lineChartOptions.test.ts +15 -14
  68. package/src/charts/normalizedStackOptions.test.ts +6 -6
  69. package/src/charts/numberCardFormat.test.ts +21 -9
  70. package/src/charts/numberCardFormat.ts +8 -8
  71. package/src/charts/paletteColors.test.ts +123 -0
  72. package/src/charts/referenceLines.test.ts +25 -13
  73. package/src/charts/sankeyOptions.test.ts +2 -2
  74. package/src/charts/sankeyOptions.ts +8 -6
  75. package/src/charts/scatterOptions.test.ts +21 -5
  76. package/src/charts/scatterOptions.ts +7 -5
  77. package/src/charts/seriesData.test.ts +20 -1
  78. package/src/charts/seriesData.ts +1 -0
  79. package/src/charts/stories/AreaLatency.vue +0 -1
  80. package/src/charts/stories/BarSelect.vue +1 -1
  81. package/src/charts/stories/CustomRadar.vue +2 -2
  82. package/src/charts/stories/Dashboard.vue +10 -4
  83. package/src/charts/stories/DonutHalf.vue +1 -1
  84. package/src/charts/stories/DonutOthers.vue +1 -1
  85. package/src/charts/stories/FunnelDeals.vue +1 -2
  86. package/src/charts/stories/HeatmapSignups.vue +1 -1
  87. package/src/charts/stories/HeatmapTickets.vue +1 -1
  88. package/src/charts/stories/LineDualAxis.vue +1 -1
  89. package/src/charts/stories/LineFilledSeries.vue +1 -1
  90. package/src/charts/stories/LineTrend.vue +1 -1
  91. package/src/charts/stories/NumberKpis.vue +8 -2
  92. package/src/charts/stories/NumberSparklines.vue +7 -1
  93. package/src/charts/stories/SankeySpend.vue +1 -1
  94. package/src/charts/stories/TooltipCustom.vue +5 -0
  95. package/src/charts/tokens.ts +37 -76
  96. package/src/charts/tooltipItems.test.ts +3 -3
  97. package/src/charts/tooltipItems.ts +4 -4
  98. package/src/charts/types.ts +167 -115
  99. package/src/charts/utils.test.ts +50 -0
  100. package/src/charts/utils.ts +52 -1
  101. package/src/charts/valueAxisOptions.test.ts +36 -2
  102. package/src/components/DatePicker/CalendarPanel.vue +7 -55
  103. package/src/components/DatePicker/DateCalendar.cy.ts +149 -0
  104. package/src/components/DatePicker/DateCalendar.vue +114 -0
  105. package/src/components/DatePicker/DatePicker.cy.ts +42 -6
  106. package/src/components/DatePicker/DatePicker.md +3 -0
  107. package/src/components/DatePicker/DatePicker.vue +14 -104
  108. package/src/components/DatePicker/DateRangeCalendar.cy.ts +186 -0
  109. package/src/components/DatePicker/DateRangeCalendar.vue +211 -0
  110. package/src/components/DatePicker/DateRangePicker.vue +50 -265
  111. package/src/components/DatePicker/DateTimePicker.cy.ts +42 -4
  112. package/src/components/DatePicker/DateTimePicker.vue +29 -96
  113. package/src/components/DatePicker/calendarTypes.ts +143 -0
  114. package/src/components/DatePicker/composables.ts +64 -2
  115. package/src/components/DatePicker/stories/DateCalendar.vue +18 -0
  116. package/src/components/DatePicker/stories/DateRangeCalendar.vue +27 -0
  117. package/src/components/DatePicker/utils.ts +72 -1
  118. package/src/components/Popover/Popover.api.md +25 -5
  119. package/src/components/Popover/Popover.cy.ts +116 -2
  120. package/src/components/Popover/Popover.md +22 -3
  121. package/src/components/Popover/Popover.vue +43 -7
  122. package/src/components/Popover/stories/Typeahead.vue +66 -0
  123. package/src/components/Popover/types.ts +25 -1
  124. package/src/components/shared/picker/PickerShell.vue +38 -109
  125. package/src/components/shared/picker/types.ts +81 -0
  126. package/src/charts/chartColors.test.ts +0 -207
package/experimental.ts CHANGED
@@ -35,12 +35,35 @@ export * from './experimental/Charts'
35
35
  // rebuilt here as seven composable parts. Unstable: the parts stay here until
36
36
  // gameplan, helpdesk and this repo's docs site all run on them.
37
37
  export * from './experimental/CommandPalette'
38
+ // Date calendars. `DateCalendar` and `DateRangeCalendar` are the calendars
39
+ // inside the date pickers, as standalone components. Incubating while their
40
+ // props and events settle.
41
+ export { default as DateCalendar } from './src/components/DatePicker/DateCalendar.vue'
42
+ export { default as DateRangeCalendar } from './src/components/DatePicker/DateRangeCalendar.vue'
43
+ export type {
44
+ DateCalendarProps,
45
+ DateCalendarEmits,
46
+ DateCalendarExposed,
47
+ DateRangeCalendarProps,
48
+ DateRangeCalendarEmits,
49
+ DateRangeCalendarExposed,
50
+ } from './src/components/DatePicker/calendarTypes'
38
51
  // ListView family. Moved out of root (#985) — `frappe-ui/list` is
39
52
  // composition-based by design (P3) and doesn't replicate ListView's
40
53
  // config-driven columns (resizable widths, per-column getLabel/prefix
41
54
  // functions, tooltips, disabled-row exclusion, the select banner), so this
42
55
  // stays here, unstable, until `frappe-ui/list` reaches parity.
43
56
  export * from './experimental/ListView'
57
+ // PickerShell. The input half of the date pickers: a `TextInput`, a manual
58
+ // `Popover` and the focus wiring between them. Incubating while its props and
59
+ // slots settle.
60
+ export { default as PickerShell } from './src/components/shared/picker/PickerShell.vue'
61
+ export type {
62
+ PickerShellProps,
63
+ PickerShellTriggerSlotProps,
64
+ PickerShellSlots,
65
+ PickerShellExposed,
66
+ } from './src/components/shared/picker/types'
44
67
  // v0 TextEditor family. Moved out of root (#974) — `frappe-ui/editor` is the
45
68
  // composition-based replacement. Parked here (#1007), unstable, as an interim
46
69
  // import path while apps migrate. Deletion stays human-gated (spec/editor.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.64",
3
+ "version": "1.0.0-beta.65",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -210,8 +210,7 @@ describe('AreaChart', () => {
210
210
  reading().should('contain.text', 'Refunds')
211
211
  plot().type('{enter}')
212
212
  cy.get('@onSelect').should('have.been.calledWithMatch', {
213
- seriesName: 'refunds',
214
- dataIndex: 0,
213
+ name: 'refunds',
215
214
  value: 4,
216
215
  row: { month: 'Jan', refunds: 4 },
217
216
  })
@@ -224,8 +223,7 @@ describe('AreaChart', () => {
224
223
  plot().type('{rightarrow}')
225
224
  plot().type('{enter}')
226
225
  cy.get('@onSelect').should('have.been.calledWithMatch', {
227
- seriesName: 'sales',
228
- dataIndex: 1,
226
+ name: 'sales',
229
227
  value: 20,
230
228
  row: { month: 'Feb', sales: 20 },
231
229
  })
@@ -2,8 +2,8 @@
2
2
  <ChartContainer
3
3
  :title="title"
4
4
  :subtitle="subtitle"
5
- :plot-label="plotLabel"
6
- :plot-label-secondary="plotLabelSecondary"
5
+ :y-axis-title="yAxisTitle"
6
+ :y2-axis-title="y2AxisTitle"
7
7
  :loading="loading"
8
8
  :error="error || renderError"
9
9
  :empty="isEmpty"
@@ -40,7 +40,7 @@
40
40
  :y="tooltip.y"
41
41
  :label="tooltip.label"
42
42
  :items="tooltip.items"
43
- :row="tooltip.row"
43
+ :rows="tooltip.rows"
44
44
  :dir="dir"
45
45
  >
46
46
  <template v-if="$slots.tooltip" #default="slotProps">
@@ -79,7 +79,7 @@ import type {
79
79
  AreaChartProps,
80
80
  AreaChartSlots,
81
81
  AxisChartConfig,
82
- ChartExposed,
82
+ ChartExposedRefs,
83
83
  } from './types'
84
84
 
85
85
  registerChartModules([
@@ -107,7 +107,6 @@ const config = computed<AxisChartConfig>(() => ({
107
107
  type: 'area',
108
108
  stacked: props.stacked,
109
109
  connectNulls: props.connectNulls,
110
- fillOpacity: props.fillOpacity,
111
110
  }))
112
111
 
113
112
  const {
@@ -115,8 +114,8 @@ const {
115
114
  chart,
116
115
  dir,
117
116
  isEmpty,
118
- plotLabel,
119
- plotLabelSecondary,
117
+ yAxisTitle,
118
+ y2AxisTitle,
120
119
  renderError,
121
120
  tooltip,
122
121
  legendItems,
@@ -133,5 +132,5 @@ const {
133
132
  onSelect: (event) => emit('select', event),
134
133
  })
135
134
 
136
- defineExpose<ChartExposed>({ chart: computed(() => chart.value) })
135
+ defineExpose<ChartExposedRefs>({ chart: computed(() => chart.value) })
137
136
  </script>
@@ -81,8 +81,7 @@ describe('BarChart', () => {
81
81
  mountChart({ onSelect: cy.spy().as('onSelect') })
82
82
  bars().first().click()
83
83
  cy.get('@onSelect').should('have.been.calledWithMatch', {
84
- seriesName: 'sales',
85
- dataIndex: 0,
84
+ name: 'sales',
86
85
  value: 10,
87
86
  row: { month: 'Jan', sales: 10 },
88
87
  })
@@ -476,6 +475,18 @@ describe('BarChart', () => {
476
475
  container().should('have.attr', 'data-state', 'empty')
477
476
  })
478
477
 
478
+ it('says so when no row carries a number for a series', () => {
479
+ mountChart({ y: ['typo'] })
480
+ cy.contains('No data to show').should('be.visible')
481
+ container().should('have.attr', 'data-state', 'empty')
482
+ })
483
+
484
+ it('says so once the legend has switched every series off', () => {
485
+ mountChart({ hiddenSeries: ['sales', 'refunds'] })
486
+ cy.contains('No data to show').should('be.visible')
487
+ container().should('have.attr', 'data-state', 'empty')
488
+ })
489
+
479
490
  it('reads as ready once the bars are drawn', () => {
480
491
  mountChart()
481
492
  bars().should('have.length', data.length * 2)
@@ -621,7 +632,7 @@ describe('BarChart', () => {
621
632
  // The row carries every column, `tooltipColumns` or not: a tooltip the
622
633
  // app draws itself needs no prop to reach one.
623
634
  mountChart({ data: data.map((row) => ({ ...row, orders: 1000 })) }, {
624
- tooltip: ({ row }: any) => h('span', `${row.orders} orders`),
635
+ tooltip: ({ rows }: any) => h('span', `${rows[0].orders} orders`),
625
636
  } as any)
626
637
  bars().should('have.length', data.length * 2)
627
638
  plot().focus()
@@ -658,8 +669,7 @@ describe('BarChart', () => {
658
669
  plot().focus()
659
670
  plot().type('{rightarrow}{enter}')
660
671
  cy.get('@onSelect').should('have.been.calledWithMatch', {
661
- seriesName: 'sales',
662
- dataIndex: 1,
672
+ name: 'sales',
663
673
  value: 20,
664
674
  row: { month: 'Feb', sales: 20 },
665
675
  })
@@ -673,8 +683,7 @@ describe('BarChart', () => {
673
683
  plot().focus()
674
684
  plot().type('{downarrow}{enter}')
675
685
  cy.get('@onSelect').should('have.been.calledWithMatch', {
676
- seriesName: 'refunds',
677
- dataIndex: 0,
686
+ name: 'refunds',
678
687
  value: 4,
679
688
  })
680
689
  })
@@ -685,8 +694,7 @@ describe('BarChart', () => {
685
694
  plot().focus()
686
695
  plot().type('{downarrow}{uparrow}{enter}')
687
696
  cy.get('@onSelect').should('have.been.calledWithMatch', {
688
- seriesName: 'sales',
689
- dataIndex: 0,
697
+ name: 'sales',
690
698
  value: 10,
691
699
  })
692
700
  })
@@ -2,9 +2,9 @@
2
2
  <ChartContainer
3
3
  :title="title"
4
4
  :subtitle="subtitle"
5
- :plot-label="plotLabel"
6
- :plot-label-secondary="plotLabelSecondary"
7
- :plot-label-placement="plotLabelPlacement"
5
+ :y-axis-title="yAxisTitle"
6
+ :y2-axis-title="y2AxisTitle"
7
+ :axis-title-placement="axisTitlePlacement"
8
8
  :loading="loading"
9
9
  :error="error || renderError"
10
10
  :empty="isEmpty"
@@ -41,7 +41,7 @@
41
41
  :y="tooltip.y"
42
42
  :label="tooltip.label"
43
43
  :items="tooltip.items"
44
- :row="tooltip.row"
44
+ :rows="tooltip.rows"
45
45
  :dir="dir"
46
46
  >
47
47
  <template v-if="$slots.tooltip" #default="slotProps">
@@ -80,7 +80,7 @@ import type {
80
80
  BarChartEmits,
81
81
  BarChartProps,
82
82
  BarChartSlots,
83
- ChartExposed,
83
+ ChartExposedRefs,
84
84
  } from './types'
85
85
 
86
86
  registerChartModules([
@@ -109,7 +109,6 @@ const config = computed<AxisChartConfig>(() => ({
109
109
  stacked: props.stacked,
110
110
  horizontal: props.horizontal,
111
111
  connectNulls: props.connectNulls,
112
- fillOpacity: props.fillOpacity,
113
112
  }))
114
113
 
115
114
  const {
@@ -117,9 +116,9 @@ const {
117
116
  chart,
118
117
  dir,
119
118
  isEmpty,
120
- plotLabel,
121
- plotLabelSecondary,
122
- plotLabelPlacement,
119
+ yAxisTitle,
120
+ y2AxisTitle,
121
+ axisTitlePlacement,
123
122
  renderError,
124
123
  tooltip,
125
124
  legendItems,
@@ -137,5 +136,5 @@ const {
137
136
  onSelect: (event) => emit('select', event),
138
137
  })
139
138
 
140
- defineExpose<ChartExposed>({ chart: computed(() => chart.value) })
139
+ defineExpose<ChartExposedRefs>({ chart: computed(() => chart.value) })
141
140
  </script>
@@ -1,5 +1,6 @@
1
- import { defineComponent, h } from 'vue'
1
+ import { defineComponent, h, ref } from 'vue'
2
2
  import DonutChart from './DonutChart.vue'
3
+ import { OTHERS_KEY } from './utils'
3
4
  import './style.css'
4
5
 
5
6
  const data = [
@@ -73,6 +74,7 @@ describe('DonutChart', () => {
73
74
  cy.get('@onSelect')
74
75
  .should('have.been.calledWithMatch', {
75
76
  name: 'Search',
77
+ label: 'Search',
76
78
  value: 50,
77
79
  percent: 50,
78
80
  })
@@ -96,8 +98,47 @@ describe('DonutChart', () => {
96
98
  cy.get('[data-slot="chart-legend"]').should('contain.text', '60%')
97
99
  })
98
100
 
101
+ it('round-trips v-model:hiddenSeries', () => {
102
+ const hidden = ref<string[]>([])
103
+ cy.mount(
104
+ defineComponent({
105
+ setup() {
106
+ return () =>
107
+ h('div', { style: 'width: 480px; height: 300px' }, [
108
+ h(DonutChart, {
109
+ data,
110
+ category: 'source',
111
+ value: 'visits',
112
+ echartOptions: { animation: false },
113
+ hiddenSeries: hidden.value,
114
+ 'onUpdate:hiddenSeries': (v: string[]) => (hidden.value = v),
115
+ }),
116
+ ])
117
+ },
118
+ }),
119
+ )
120
+
121
+ cy.get('[aria-label="Hide Search"]')
122
+ .click()
123
+ .then(() => expect(hidden.value).to.deep.equal(['Search']))
124
+ slices().should('have.length', 2)
125
+ })
126
+
127
+ // An empty ring reads as a bug, so the last entry left showing does nothing.
128
+ it('refuses to hide the last slice left on the ring', () => {
129
+ mountChart()
130
+ cy.get('[aria-label="Hide Search"]').click()
131
+ cy.get('[aria-label="Hide Direct"]').click()
132
+ cy.get('[aria-label="Hide Email"]').click()
133
+ cy.get('[aria-label="Hide Email"]').should(
134
+ 'have.attr',
135
+ 'aria-pressed',
136
+ 'true',
137
+ )
138
+ })
139
+
99
140
  it('gives up the readout when the labels move onto the ring', () => {
100
- mountChart({ showInlineLabels: true })
141
+ mountChart({ showDataLabels: true })
101
142
  cy.get('[data-slot="chart-plot"]').should('not.contain.text', 'Visits')
102
143
  cy.get('[data-slot="chart-plot"] svg text').should('contain.text', 'Search')
103
144
  })
@@ -114,6 +155,25 @@ describe('DonutChart', () => {
114
155
  cy.get('[data-slot="chart-legend"]').should('contain.text', 'Others')
115
156
  })
116
157
 
158
+ // The tail is identified by the reserved key, not by what it prints, so a
159
+ // caller can tell it apart from a category that happens to read "Others".
160
+ it('names the tail slice by the reserved key', () => {
161
+ mountChart({ maxSlices: 2, onSelect: cy.spy().as('onSelect') })
162
+ slices().should('have.length', 2)
163
+ cy.get('[data-slot="chart-plot"] [role="img"]')
164
+ .focus()
165
+ .type('{rightarrow}{enter}')
166
+ cy.get('@onSelect').should('have.been.calledWithMatch', {
167
+ name: OTHERS_KEY,
168
+ label: 'Others',
169
+ value: 50,
170
+ rows: [
171
+ { source: 'Direct', visits: 30 },
172
+ { source: 'Email', visits: 20 },
173
+ ],
174
+ })
175
+ })
176
+
117
177
  describe('states', () => {
118
178
  /** The state an app styles the card from, without tracking it itself. */
119
179
  const container = () => cy.get('[data-slot="chart-container"]')
@@ -172,7 +232,8 @@ describe('DonutChart', () => {
172
232
  mountChart(
173
233
  {},
174
234
  {
175
- center: ({ value, label }: any) => h('span', `${value} of ${label}`),
235
+ center: ({ formattedValue, label }: any) =>
236
+ h('span', `${formattedValue} of ${label}`),
176
237
  },
177
238
  )
178
239
  cy.get('[data-slot="chart-plot"]').should('contain.text', '100 of Visits')
@@ -182,8 +243,8 @@ describe('DonutChart', () => {
182
243
  mountChart(
183
244
  {},
184
245
  {
185
- center: ({ label, percent }: any) =>
186
- h('span', `${label} ${percent ?? ''}`),
246
+ center: ({ label, value, percent }: any) =>
247
+ h('span', `${label} ${value} ${Math.round(percent)}%`),
187
248
  },
188
249
  )
189
250
  slices().should('have.length', data.length)
@@ -194,7 +255,7 @@ describe('DonutChart', () => {
194
255
  350,
195
256
  135,
196
257
  )
197
- cy.get('[data-slot="chart-plot"]').should('contain.text', 'Search 50%')
258
+ cy.get('[data-slot="chart-plot"]').should('contain.text', 'Search 50 50%')
198
259
  })
199
260
 
200
261
  it('takes an app’s own tooltip body', () => {
@@ -244,6 +305,7 @@ describe('DonutChart', () => {
244
305
  plot().type('{enter}')
245
306
  cy.get('@onSelect').should('have.been.calledWithMatch', {
246
307
  name: 'Direct',
308
+ label: 'Direct',
247
309
  value: 30,
248
310
  percent: 30,
249
311
  rows: [{ source: 'Direct', visits: 30 }],
@@ -36,7 +36,7 @@
36
36
  <!-- HTML rather than an echarts graphic: swapping the readout on hover
37
37
  costs two spans instead of a `setOption` on the ring. -->
38
38
  <div
39
- v-if="!showInlineLabels"
39
+ v-if="!showDataLabels"
40
40
  class="pointer-events-none absolute inset-0 flex items-center justify-center"
41
41
  :dir="dir"
42
42
  >
@@ -60,7 +60,7 @@
60
60
  <div
61
61
  class="truncate text-center text-xl font-semibold tabular-nums text-ink-gray-8"
62
62
  >
63
- {{ center.value }}
63
+ {{ center.formattedValue }}
64
64
  </div>
65
65
  <!-- The share is the point of the readout, so only the name
66
66
  gives way when the hole is too narrow for both. -->
@@ -68,8 +68,8 @@
68
68
  class="flex items-baseline justify-center gap-1 text-xs text-ink-gray-5"
69
69
  >
70
70
  <span class="min-w-0 truncate">{{ center.label }}</span>
71
- <span v-if="center.percent" class="shrink-0">
72
- {{ center.percent }}
71
+ <span v-if="center.percent !== undefined" class="shrink-0">
72
+ {{ formatPercent(center.percent) }}
73
73
  </span>
74
74
  </div>
75
75
  </slot>
@@ -83,6 +83,7 @@
83
83
  :x="tooltip.x"
84
84
  :y="tooltip.y"
85
85
  :items="tooltip.items"
86
+ :rows="tooltip.rows"
86
87
  :dir="dir"
87
88
  >
88
89
  <template v-if="$slots.tooltip" #default="slotProps">
@@ -105,6 +106,7 @@
105
106
  import { computed, reactive, ref, watch } from 'vue'
106
107
  import { PieChart as PieSeries } from 'echarts/charts'
107
108
  import { registerChartModules, useChart } from './core/useChart'
109
+ import { pruneHiddenSeries, toggleHiddenSeries } from './hiddenSeries'
108
110
  import { useTooltipDismiss } from './core/useTooltipDismiss'
109
111
  import { usePlotKeyboard } from './core/usePlotKeyboard'
110
112
  import {
@@ -124,7 +126,7 @@ import ChartContainer from './components/ChartContainer.vue'
124
126
  import ChartLegend from './components/ChartLegend.vue'
125
127
  import ChartTooltip from './components/ChartTooltip.vue'
126
128
  import type {
127
- ChartExposed,
129
+ ChartExposedRefs,
128
130
  ChartLegendItem,
129
131
  ChartTooltipItem,
130
132
  DonutChartConfig,
@@ -139,12 +141,15 @@ registerChartModules([PieSeries])
139
141
 
140
142
  const props = defineProps<DonutChartProps>()
141
143
 
144
+ const hiddenSlices = defineModel<string[]>('hiddenSeries', {
145
+ default: () => [],
146
+ })
147
+
142
148
  const emit = defineEmits<DonutChartEmits>()
143
149
 
144
150
  defineSlots<DonutChartSlots>()
145
151
 
146
152
  const plotEl = ref<HTMLElement>()
147
- const hiddenSlices = ref<string[]>([])
148
153
 
149
154
  const dir = computed(() => props.dir ?? documentDir())
150
155
  const isHalf = computed(() => props.variant === 'half')
@@ -154,7 +159,7 @@ const config = computed<DonutChartConfig>(() => ({
154
159
  categoryColumn: props.category,
155
160
  valueColumn: props.value,
156
161
  maxSlices: props.maxSlices,
157
- showInlineLabels: props.showInlineLabels,
162
+ showDataLabels: props.showDataLabels,
158
163
  centerLabel: props.centerLabel,
159
164
  variant: props.variant,
160
165
  palette: props.palette,
@@ -226,6 +231,8 @@ const tooltip = reactive({
226
231
  x: 0,
227
232
  y: 0,
228
233
  items: [] as ChartTooltipItem[],
234
+ /** Plural: a slice groups, and "Others" groups the whole tail. */
235
+ rows: [] as Record<string, any>[],
229
236
  })
230
237
 
231
238
  useTooltipDismiss({
@@ -255,7 +262,8 @@ const { chart, dispatch } = useChart({
255
262
  const slice = sliceByName.value.get(params.name)
256
263
  if (!slice) return
257
264
  emit('select', {
258
- name: slice.label,
265
+ name: slice.name,
266
+ label: slice.label,
259
267
  value: slice.value,
260
268
  percent: slice.percent,
261
269
  rows: slice.rows,
@@ -302,8 +310,10 @@ function showTooltip(name: string) {
302
310
  value: slice.value,
303
311
  formattedValue: formatMeasure(slice.value),
304
312
  percent: slice.percent,
313
+ kind: 'series',
305
314
  },
306
315
  ]
316
+ tooltip.rows = slice.rows
307
317
  tooltip.x = pointer.x
308
318
  tooltip.y = pointer.y
309
319
  tooltip.open = true
@@ -351,7 +361,8 @@ const { attrs: plotAttrs } = usePlotKeyboard({
351
361
  const slice = visibleSlices.value[index]
352
362
  if (!slice) return
353
363
  emit('select', {
354
- name: slice.label,
364
+ name: slice.name,
365
+ label: slice.label,
355
366
  value: slice.value,
356
367
  percent: slice.percent,
357
368
  rows: slice.rows,
@@ -365,14 +376,11 @@ const { attrs: plotAttrs } = usePlotKeyboard({
365
376
  })
366
377
 
367
378
  function toggleSlice(name: string) {
368
- const hidden = hiddenSlices.value
369
- // Refuse to hide the last visible slice — an empty ring reads as a bug.
370
- if (!hidden.includes(name)) {
371
- if (visibleSlices.value.length === 1) return
372
- hiddenSlices.value = [...hidden, name]
373
- } else {
374
- hiddenSlices.value = hidden.filter((n) => n !== name)
375
- }
379
+ hiddenSlices.value = toggleHiddenSeries(
380
+ hiddenSlices.value,
381
+ name,
382
+ slices.value.length,
383
+ )
376
384
  }
377
385
 
378
386
  function hoverSlice(name: string | null) {
@@ -387,14 +395,16 @@ const center = computed(() => {
387
395
  const slice = hovered.value ? sliceByName.value.get(hovered.value) : undefined
388
396
  if (slice && !slice.hidden) {
389
397
  return {
390
- value: shorten(slice.value),
391
398
  label: slice.label,
392
- percent: formatPercent(slice.percent),
399
+ value: slice.value,
400
+ formattedValue: shorten(slice.value),
401
+ percent: slice.percent,
393
402
  }
394
403
  }
395
404
  return {
396
- value: shorten(visibleTotal.value),
397
405
  label: props.centerLabel ?? formatLabel(props.value),
406
+ value: visibleTotal.value,
407
+ formattedValue: shorten(visibleTotal.value),
398
408
  percent: undefined,
399
409
  }
400
410
  })
@@ -414,12 +424,9 @@ function shorten(value: number) {
414
424
  watch(
415
425
  () => slices.value.map((slice) => slice.name),
416
426
  (names) => {
417
- if (hiddenSlices.value.every((name) => names.includes(name))) return
418
- hiddenSlices.value = hiddenSlices.value.filter((name) =>
419
- names.includes(name),
420
- )
427
+ hiddenSlices.value = pruneHiddenSeries(hiddenSlices.value, names)
421
428
  },
422
429
  )
423
430
 
424
- defineExpose<ChartExposed>({ chart: computed(() => chart.value) })
431
+ defineExpose<ChartExposedRefs>({ chart: computed(() => chart.value) })
425
432
  </script>
@@ -50,6 +50,18 @@ describe('FunnelChart', () => {
50
50
  columns().should('have.length', data.length)
51
51
  })
52
52
 
53
+ it('leaves out a stage whose count it cannot read', () => {
54
+ mountChart({
55
+ data: [
56
+ { stage: 'Visited', deals: 50 },
57
+ { stage: 'Demoed', deals: 'n/a' },
58
+ { stage: 'Won', deals: 20 },
59
+ ],
60
+ })
61
+ columns().should('have.length', 2)
62
+ cy.get('[data-slot="chart-container"]').should('not.contain.text', 'Demoed')
63
+ })
64
+
53
65
  it('prints each stage against the first, and nothing against the first', () => {
54
66
  mountChart()
55
67
  cy.get('[data-slot="chart-container"]')
@@ -58,11 +70,6 @@ describe('FunnelChart', () => {
58
70
  .and('not.contain.text', '100%')
59
71
  })
60
72
 
61
- it('drops the percentages on request', () => {
62
- mountChart({ showPercentages: false })
63
- cy.get('[data-slot="chart-container"]').should('not.contain.text', '60%')
64
- })
65
-
66
73
  it('gives every stage a hit area that names it', () => {
67
74
  mountChart()
68
75
  hitAreas().should('have.length', data.length)
@@ -73,9 +80,9 @@ describe('FunnelChart', () => {
73
80
  mountChart({ onSelect: cy.spy().as('onSelect') })
74
81
  cy.get('[aria-label="Demoed, 30"]').click()
75
82
  cy.get('@onSelect').should('have.been.calledWithMatch', {
83
+ name: 'Demoed',
76
84
  label: 'Demoed',
77
85
  value: 30,
78
- index: 1,
79
86
  row: { stage: 'Demoed', deals: 30 },
80
87
  })
81
88
  })
@@ -88,6 +95,16 @@ describe('FunnelChart', () => {
88
95
  .and('contain.text', 'Won')
89
96
  })
90
97
 
98
+ it('reads both conversion rates in the tooltip, against the first stage and the one before', () => {
99
+ mountChart()
100
+ cy.get('[aria-label="Won, 20"]').trigger('mouseenter')
101
+ cy.get('[data-slot="chart-tooltip"]')
102
+ .should('contain.text', 'of Visited')
103
+ .and('contain.text', '40%')
104
+ .and('contain.text', 'of Demoed')
105
+ .and('contain.text', '67%')
106
+ })
107
+
91
108
  describe('keyboard', () => {
92
109
  // The funnel draws its own HTML, so each stage is a tab stop of its own —
93
110
  // no arrow-key cursor, unlike the echarts plots.
@@ -122,7 +139,6 @@ describe('FunnelChart', () => {
122
139
  cy.get('@onSelect').should('have.been.calledWithMatch', {
123
140
  label: 'Won',
124
141
  value: 20,
125
- index: 2,
126
142
  row: { stage: 'Won', deals: 20 },
127
143
  })
128
144
  })
@@ -160,6 +176,12 @@ describe('FunnelChart', () => {
160
176
  cy.contains('No data to show').should('be.visible')
161
177
  })
162
178
 
179
+ it('says so when no row carries a count', () => {
180
+ mountChart({ data: [{ stage: 'Visited', deals: null }] })
181
+ container().should('have.attr', 'data-state', 'empty')
182
+ cy.contains('No data to show').should('be.visible')
183
+ })
184
+
163
185
  it('takes an app’s own placeholder in place of the skeleton', () => {
164
186
  mountChart({ loading: true }, { loading: () => h('div', { id: 'own' }) })
165
187
  cy.get('#own').should('exist')
@@ -191,12 +213,18 @@ describe('FunnelChart', () => {
191
213
  cy.get('[data-slot="chart-header"]').should('contain.text', 'Export')
192
214
  })
193
215
 
194
- it('takes an app’s own tooltip body, with the stage behind it', () => {
216
+ it('takes an app’s own tooltip body, with the rates among the items', () => {
195
217
  mountChart(
196
218
  {},
197
219
  {
198
- tooltip: ({ label, stage }: any) =>
199
- h('span', `${label} at ${Math.round(stage.percentOfPrevious)}%`),
220
+ tooltip: ({ items, rows }: any) =>
221
+ h(
222
+ 'span',
223
+ `${rows[0].stage} at ${
224
+ items.find((item: any) => item.name === 'ofPrevious')
225
+ .formattedValue
226
+ }`,
227
+ ),
200
228
  },
201
229
  )
202
230
  cy.get('[aria-label="Won, 20"]').focus()