Skip to content

Commit c745de1

Browse files
committed
Fix: wrap vertical legends in scrollable container for long lists
1 parent 72c6742 commit c745de1

File tree

1 file changed

+139
-57
lines changed

1 file changed

+139
-57
lines changed

src/plugins/plugin.legend.js

Lines changed: 139 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
import {_alignStartEnd, _textX, _toLeftRightCenter} from '../helpers/helpers.extras.js';
1818
import {toTRBLCorners} from '../helpers/helpers.options.js';
1919

20+
21+
// @ts-ignore
22+
2023
/**
2124
* @typedef { import('../types/index.js').ChartEvent } ChartEvent
2225
*/
@@ -119,40 +122,74 @@ export class Legend extends Element {
119122

120123
this.legendItems = legendItems;
121124
}
125+
fit() {
126+
const { options, ctx } = this;
127+
if (!options.display) {
128+
this.width = this.height = 0;
129+
return;
130+
}
122131

123-
fit() {
124-
const {options, ctx} = this;
125-
126-
// The legend may not be displayed for a variety of reasons including
127-
// the fact that the defaults got set to `false`.
128-
// When the legend is not displayed, there are no guarantees that the options
129-
// are correctly formatted so we need to bail out as early as possible.
130-
if (!options.display) {
131-
this.width = this.height = 0;
132-
return;
133-
}
132+
const labelOpts = options.labels;
133+
const labelFont = toFont(labelOpts.font);
134+
const fontSize = labelFont.size;
135+
const titleHeight = this._computeTitleHeight();
136+
const { boxWidth, itemHeight } = getBoxSize(labelOpts, fontSize);
137+
138+
let width, height;
139+
ctx.font = labelFont.string;
140+
141+
if (this.isHorizontal()) {
142+
width = this.maxWidth;
143+
height = this._fitRows(titleHeight, fontSize, boxWidth, itemHeight) + 10;
144+
} else {
145+
height = this.maxHeight;
146+
width = this._fitCols(titleHeight, labelFont, boxWidth, itemHeight) + 10;
147+
}
134148

135-
const labelOpts = options.labels;
136-
const labelFont = toFont(labelOpts.font);
137-
const fontSize = labelFont.size;
138-
const titleHeight = this._computeTitleHeight();
139-
const {boxWidth, itemHeight} = getBoxSize(labelOpts, fontSize);
149+
// --- handle scroll height limit ---
150+
const scrollOpts = options.scroll || {};
151+
if (scrollOpts.enabled && scrollOpts.maxItems) {
152+
const singleItemHeight = itemHeight + labelOpts.padding;
153+
const visibleHeight = singleItemHeight * scrollOpts.maxItems + titleHeight + labelOpts.padding * 2;
154+
this.height = Math.min(this.height, visibleHeight);
140155

141-
let width, height;
156+
// wrap legend in scroll container
157+
this._wrapLegendScroll(visibleHeight);
158+
}
142159

143-
ctx.font = labelFont.string;
160+
this.width = Math.min(width, options.maxWidth || this.maxWidth);
161+
this.height = Math.min(height, options.maxHeight || this.maxHeight);
162+
}
144163

145-
if (this.isHorizontal()) {
146-
width = this.maxWidth; // fill all the width
147-
height = this._fitRows(titleHeight, fontSize, boxWidth, itemHeight) + 10;
148-
} else {
149-
height = this.maxHeight; // fill all the height
150-
width = this._fitCols(titleHeight, labelFont, boxWidth, itemHeight) + 10;
151-
}
164+
/**
165+
* Private helper to wrap the <ul> legend in a scrollable container
166+
*/
167+
_wrapLegendScroll(visibleHeight) {
168+
const canvasContainer = this.chart.canvas.parentNode;
169+
if (!canvasContainer) return;
170+
171+
const legendUL = canvasContainer.querySelector('ul.chartjs-legend');
172+
if (!legendUL) return;
173+
174+
// Skip if already wrapped
175+
if (canvasContainer.querySelector('.legend-scroll-wrapper')) return;
176+
177+
const legendWrapper = document.createElement('div');
178+
legendWrapper.className = 'legend-scroll-wrapper';
179+
legendWrapper.style.overflowY = 'auto';
180+
legendWrapper.style.maxHeight = `${visibleHeight}px`;
181+
legendWrapper.style.display = 'inline-block';
182+
183+
// Force single-column layout
184+
legendUL.style.display = 'flex';
185+
legendUL.style.flexDirection = 'column';
186+
legendUL.style.margin = '0';
187+
legendUL.style.padding = '0';
188+
189+
legendUL.parentNode.insertBefore(legendWrapper, legendUL);
190+
legendWrapper.appendChild(legendUL);
191+
}
152192

153-
this.width = Math.min(width, options.maxWidth || this.maxWidth);
154-
this.height = Math.min(height, options.maxHeight || this.maxHeight);
155-
}
156193

157194
/**
158195
* @private
@@ -188,45 +225,62 @@ export class Legend extends Element {
188225
return totalHeight;
189226
}
190227

191-
_fitCols(titleHeight, labelFont, boxWidth, _itemHeight) {
192-
const {ctx, maxHeight, options: {labels: {padding}}} = this;
193-
const hitboxes = this.legendHitBoxes = [];
194-
const columnSizes = this.columnSizes = [];
195-
const heightLimit = maxHeight - titleHeight;
228+
_fitCols(titleHeight, labelFont, boxWidth, _itemHeight) {
229+
const {ctx, maxHeight, options: {labels: {padding}}} = this;
230+
const scrollOpts = this.options.scroll || {};
231+
const hitboxes = this.legendHitBoxes = [];
232+
const columnSizes = this.columnSizes = [];
196233

197-
let totalWidth = padding;
198-
let currentColWidth = 0;
199-
let currentColHeight = 0;
234+
const heightLimit = maxHeight - titleHeight;
200235

201-
let left = 0;
202-
let col = 0;
236+
// --- SCROLL ENABLED: force single column ---
237+
if (scrollOpts.enabled && scrollOpts.maxItems) {
238+
let totalWidth = 0;
239+
let top = 0;
240+
let maxItemWidth = 0;
203241

204242
this.legendItems.forEach((legendItem, i) => {
205243
const {itemWidth, itemHeight} = calculateItemSize(boxWidth, labelFont, ctx, legendItem, _itemHeight);
206-
207-
// If too tall, go to new column
208-
if (i > 0 && currentColHeight + itemHeight + 2 * padding > heightLimit) {
209-
totalWidth += currentColWidth + padding;
210-
columnSizes.push({width: currentColWidth, height: currentColHeight}); // previous column size
211-
left += currentColWidth + padding;
212-
col++;
213-
currentColWidth = currentColHeight = 0;
214-
}
215-
216-
// Store the hitbox width and height here. Final position will be updated in `draw`
217-
hitboxes[i] = {left, top: currentColHeight, col, width: itemWidth, height: itemHeight};
218-
219-
// Get max width
220-
currentColWidth = Math.max(currentColWidth, itemWidth);
221-
currentColHeight += itemHeight + padding;
244+
hitboxes[i] = {left: 0, top, col: 0, width: itemWidth, height: itemHeight};
245+
top += itemHeight + padding;
246+
maxItemWidth = Math.max(maxItemWidth, itemWidth);
222247
});
223248

224-
totalWidth += currentColWidth;
225-
columnSizes.push({width: currentColWidth, height: currentColHeight}); // previous column size
249+
columnSizes.push({width: maxItemWidth, height: top});
250+
totalWidth = maxItemWidth + 2 * padding;
226251

227252
return totalWidth;
228253
}
229254

255+
// --- SCROLL DISABLED: original multi-column logic ---
256+
let totalWidth = padding;
257+
let currentColWidth = 0;
258+
let currentColHeight = 0;
259+
let left = 0;
260+
let col = 0;
261+
262+
this.legendItems.forEach((legendItem, i) => {
263+
const {itemWidth, itemHeight} = calculateItemSize(boxWidth, labelFont, ctx, legendItem, _itemHeight);
264+
265+
if (i > 0 && currentColHeight + itemHeight + 2 * padding > heightLimit) {
266+
totalWidth += currentColWidth + padding;
267+
columnSizes.push({width: currentColWidth, height: currentColHeight});
268+
left += currentColWidth + padding;
269+
col++;
270+
currentColWidth = currentColHeight = 0;
271+
}
272+
273+
hitboxes[i] = {left, top: currentColHeight, col, width: itemWidth, height: itemHeight};
274+
275+
currentColWidth = Math.max(currentColWidth, itemWidth);
276+
currentColHeight += itemHeight + padding;
277+
});
278+
279+
totalWidth += currentColWidth;
280+
columnSizes.push({width: currentColWidth, height: currentColHeight});
281+
282+
return totalWidth;
283+
}
230284
adjustHitBoxes() {
231285
if (!this.options.display) {
232286
return;
@@ -391,7 +445,12 @@ export class Legend extends Element {
391445
overrideTextDirection(this.ctx, opts.textDirection);
392446

393447
const lineHeight = itemHeight + padding;
394-
this.legendItems.forEach((legendItem, i) => {
448+
const scrollOpts = this.options.scroll || {};
449+
let legendItems = this.legendItems;
450+
if (scrollOpts.enabled && scrollOpts.maxItems) {
451+
legendItems = legendItems.slice(0, scrollOpts.maxItems);
452+
}
453+
legendItems.forEach((legendItem, i) => {
395454
ctx.strokeStyle = legendItem.fontColor; // for strikethrough effect
396455
ctx.fillStyle = legendItem.fontColor; // render in correct colour
397456

@@ -552,6 +611,15 @@ export class Legend extends Element {
552611
}
553612
}
554613

614+
615+
616+
617+
618+
619+
620+
621+
622+
555623
function calculateItemSize(boxWidth, labelFont, ctx, legendItem, _itemHeight) {
556624
const itemWidth = calculateItemWidth(legendItem, boxWidth, labelFont, ctx);
557625
const itemHeight = calculateItemHeight(_itemHeight, legendItem, labelFont.lineHeight);
@@ -624,6 +692,20 @@ export default {
624692
const legend = chart.legend;
625693
legend.buildLabels();
626694
legend.adjustHitBoxes();
695+
const scrollOpts = legend.options.scroll || {};
696+
if (scrollOpts.enabled && scrollOpts.maxItems) {
697+
const canvasContainer = chart.canvas.parentNode;
698+
if (canvasContainer) {
699+
const legendUL = canvasContainer.querySelector('ul.chartjs-legend');
700+
if (legendUL && !canvasContainer.querySelector('.legend-scroll-wrapper')) {
701+
const singleItemHeight = legend.legendHitBoxes[0].height + legend.options.labels.padding;
702+
const titleHeight = legend._computeTitleHeight();
703+
const visibleHeight = singleItemHeight * scrollOpts.maxItems + titleHeight + legend.options.labels.padding * 2;
704+
legend._wrapLegendScroll(visibleHeight);
705+
console.log('Legend wrapper created inside afterUpdate!');
706+
}
707+
}
708+
}
627709
},
628710

629711

0 commit comments

Comments
 (0)