Skip to content

Commit 1e794a2

Browse files
zamooreshleewhite
andauthored
AdvancedTable Column resize bug fix (#3081)
Co-authored-by: Lee White <lee.white@hashicorp.com>
1 parent 953cd78 commit 1e794a2

File tree

10 files changed

+477
-158
lines changed

10 files changed

+477
-158
lines changed

.changeset/tender-carrots-fix.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hashicorp/design-system-components": minor
3+
---
4+
5+
<!-- START components/table/advanced-table -->
6+
`AdvancedTable` - Fixed bug with automatic column resizing and scroll-shadow placement.
7+
<!-- END -->

packages/components/src/components/hds/advanced-table/index.hbs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
--hds-advanced-table-sticky-column-offset=this.stickyColumnOffset
2525
max-height=@maxHeight
2626
}}
27+
{{this._registerGridElement}}
2728
{{this._setUpScrollWrapper}}
2829
>
2930
{{! Header }}
@@ -54,7 +55,7 @@
5455
@tableHeight={{this._tableHeight}}
5556
@onColumnResize={{@onColumnResize}}
5657
@onPinFirstColumn={{this._onPinFirstColumn}}
57-
{{this._setColumnWidth column}}
58+
{{this._registerThElement column}}
5859
>
5960
{{column.label}}
6061
</Hds::AdvancedTable::ThSort>
@@ -74,7 +75,7 @@
7475
@onClickToggle={{this._tableModel.toggleAll}}
7576
@onColumnResize={{@onColumnResize}}
7677
@onPinFirstColumn={{this._onPinFirstColumn}}
77-
{{this._setColumnWidth column}}
78+
{{this._registerThElement column}}
7879
>
7980
{{column.label}}
8081
</Hds::AdvancedTable::Th>

packages/components/src/components/hds/advanced-table/index.ts

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import type { WithBoundArgs } from '@glint/template';
1111
import { guidFor } from '@ember/object/internals';
1212
import { modifier } from 'ember-modifier';
1313
import type Owner from '@ember/owner';
14-
import { schedule } from '@ember/runloop';
1514

1615
import HdsAdvancedTableTableModel from './models/table.ts';
1716

@@ -384,23 +383,11 @@ export default class HdsAdvancedTable extends Component<HdsAdvancedTableSignatur
384383
const { isSelectable } = this.args;
385384
const { columns } = this._tableModel;
386385

387-
const DEFAULT_COLUMN_WIDTH = '1fr';
388-
389386
// if there is a select checkbox, the first column has a 'min-content' width to hug the checkbox content
390387
let style = isSelectable ? 'min-content ' : '';
391388

392-
const hasCustomColumnWidths = columns.some(
393-
(column) => column.width !== undefined
394-
);
395-
396-
if (hasCustomColumnWidths) {
397-
// check the custom column widths, if the current column has a custom width use the custom width. otherwise take the available space.
398-
for (let i = 0; i < columns.length; i++) {
399-
style += ` ${columns[i]!.width ?? DEFAULT_COLUMN_WIDTH}`;
400-
}
401-
} else {
402-
// if there are no custom column widths, each column is the same width and they take up the available space
403-
style += `repeat(${columns.length}, ${DEFAULT_COLUMN_WIDTH})`;
389+
for (let i = 0; i < columns.length; i++) {
390+
style += ` ${columns[i]!.appliedWidth}`;
404391
}
405392

406393
return style;
@@ -446,23 +433,27 @@ export default class HdsAdvancedTable extends Component<HdsAdvancedTableSignatur
446433
return classes.join(' ');
447434
}
448435

449-
private _setColumnWidth = modifier(
436+
private _registerGridElement = modifier((element: HTMLDivElement) => {
437+
this._tableModel.gridElement = element;
438+
});
439+
440+
private _registerThElement = modifier(
450441
(element: HTMLDivElement, [column]: [HdsAdvancedTableColumnType]) => {
451-
// eslint-disable-next-line ember/no-runloop
452-
schedule('afterRender', () => {
453-
const width = element.offsetWidth;
442+
if (column === undefined) {
443+
return;
444+
}
454445

455-
if (column.width === undefined) {
456-
column.setPxWidth(width);
457-
column.originalWidth = `${width}px`;
458-
}
459-
});
446+
column.thElement = element;
460447
}
461448
);
462449

463450
private _setUpScrollWrapper = modifier((element: HTMLDivElement) => {
464451
this._scrollWrapperElement = element;
465452

453+
const updateHorizontalScrollIndicators = () => {
454+
this.showScrollIndicatorRight = element.clientWidth < element.scrollWidth;
455+
};
456+
466457
this._scrollHandler = () => {
467458
this._updateScrollIndicators(element);
468459
};
@@ -498,6 +489,7 @@ export default class HdsAdvancedTable extends Component<HdsAdvancedTableSignatur
498489
this._resizeObserver = new ResizeObserver((entries) => {
499490
entries.forEach(() => {
500491
updateMeasurements();
492+
updateHorizontalScrollIndicators();
501493
});
502494
});
503495

@@ -506,9 +498,7 @@ export default class HdsAdvancedTable extends Component<HdsAdvancedTableSignatur
506498
updateMeasurements();
507499

508500
// on render check if should show right scroll indicator
509-
if (element.clientWidth < element.scrollWidth) {
510-
this.showScrollIndicatorRight = true;
511-
}
501+
updateHorizontalScrollIndicators();
512502

513503
// on render check if should show bottom scroll indicator
514504
if (element.clientHeight < element.scrollHeight) {

packages/components/src/components/hds/advanced-table/models/column.ts

Lines changed: 147 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55

66
import { tracked } from '@glimmer/tracking';
77
import { action } from '@ember/object';
8+
import { guidFor } from '@ember/object/internals';
89

910
import type HdsAdvancedTableModel from './table.ts';
1011
import type {
1112
HdsAdvancedTableHorizontalAlignment,
1213
HdsAdvancedTableColumn as HdsAdvancedTableColumnType,
1314
} from '../types';
1415

16+
export const DEFAULT_WIDTH = '1fr'; // default to '1fr' to allow flexible width
1517
export const DEFAULT_MIN_WIDTH = '150px';
1618
export const DEFAULT_MAX_WIDTH = '800px';
1719

@@ -31,40 +33,63 @@ export default class HdsAdvancedTableColumn {
3133
@tracked label: string = '';
3234
@tracked align?: HdsAdvancedTableHorizontalAlignment = 'left';
3335
@tracked isExpandable?: boolean = false;
34-
@tracked isReorderable?: boolean = false;
3536
@tracked isSortable?: boolean = false;
3637
@tracked isVisuallyHidden?: boolean = false;
37-
@tracked key?: string = undefined;
38-
@tracked minWidth?: `${number}px` = DEFAULT_MIN_WIDTH;
39-
@tracked maxWidth?: `${number}px` = DEFAULT_MAX_WIDTH;
38+
@tracked key: string;
4039
@tracked tooltip?: string = undefined;
41-
@tracked width?: string = undefined;
42-
@tracked originalWidth?: string = undefined; // used to restore the width when resetting
43-
@tracked imposedWidthDelta: number = 0; // used to track the width change imposed by the previous column
40+
@tracked thElement?: HTMLDivElement = undefined;
41+
42+
// width properties
43+
@tracked transientWidth?: `${number}px` = undefined; // used for transient width changes
44+
@tracked width: string = DEFAULT_WIDTH;
45+
@tracked minWidth: `${number}px` = DEFAULT_MIN_WIDTH;
46+
@tracked maxWidth: `${number}px` = DEFAULT_MAX_WIDTH;
47+
@tracked originalWidth: string = this.width; // used to restore the width when resetting
48+
@tracked widthDebts: Record<string, number> = {}; // used to track width changes imposed by other columns
4449

4550
@tracked sortingFunction?: (a: unknown, b: unknown) => number = undefined;
4651

4752
table: HdsAdvancedTableModel;
4853

49-
get pxWidth(): number | undefined {
54+
get appliedWidth(): string {
55+
return this.transientWidth ?? this.width;
56+
}
57+
get pxAppliedWidth(): number | undefined {
58+
if (isPxSize(this.appliedWidth)) {
59+
return pxToNumber(this.appliedWidth);
60+
}
61+
}
62+
63+
get pxTransientWidth(): number | undefined {
64+
if (this.transientWidth !== undefined) {
65+
return pxToNumber(this.transientWidth);
66+
}
67+
}
68+
set pxTransientWidth(value: number | undefined) {
69+
if (value !== undefined && value >= 0) {
70+
this.transientWidth = `${value}px`;
71+
} else {
72+
this.transientWidth = undefined;
73+
}
74+
}
75+
76+
get pxWidth(): number {
5077
if (isPxSize(this.width)) {
51-
return pxToNumber(this.width!);
78+
return pxToNumber(this.width);
79+
} else {
80+
return this.thElement?.offsetWidth ?? 0;
5281
}
5382
}
5483
set pxWidth(value: number) {
5584
this.width = `${value}px`;
5685
}
5786

58-
get pxMinWidth(): number | undefined {
59-
if (isPxSize(this.minWidth)) {
60-
return pxToNumber(this.minWidth!);
61-
}
87+
get pxMinWidth(): number {
88+
return isPxSize(this.minWidth) ? pxToNumber(this.minWidth) : 0;
6289
}
6390

64-
get pxMaxWidth(): number | undefined {
65-
if (isPxSize(this.maxWidth)) {
66-
return pxToNumber(this.maxWidth!);
67-
}
91+
get pxMaxWidth(): number {
92+
return isPxSize(this.maxWidth) ? pxToNumber(this.maxWidth) : Infinity;
6893
}
6994

7095
get index(): number {
@@ -117,37 +142,130 @@ export default class HdsAdvancedTableColumn {
117142
this.isExpandable = 'isExpandable' in column ? column.isExpandable : false;
118143
this.isSortable = column.isSortable ?? false;
119144
this.isVisuallyHidden = column.isVisuallyHidden ?? false;
120-
this.key = column.key;
145+
this.key = column.key ?? guidFor(this);
121146
this.tooltip = column.tooltip;
122147
this._setWidthValues(column);
123148
this.sortingFunction = column.sortingFunction;
124149
}
125150

151+
// main collection function
152+
private collectWidthDebts(): void {
153+
this.table.columns.forEach((debtor) => {
154+
const debtToCollect = debtor.widthDebts[this.key] ?? 0;
155+
156+
if (debtToCollect <= 0) {
157+
return;
158+
}
159+
160+
const amountPaid = debtor._sourceFundsForPayment(debtToCollect);
161+
162+
if (amountPaid > 0) {
163+
this.pxWidth = (this.pxWidth ?? 0) + amountPaid;
164+
165+
const remainingDebt = debtToCollect - amountPaid;
166+
167+
if (remainingDebt > 0) {
168+
debtor.widthDebts[this.key] = remainingDebt;
169+
} else {
170+
delete debtor.widthDebts[this.key];
171+
}
172+
}
173+
});
174+
}
175+
176+
// function for recursively recovering width debts without ending up in a deficit
177+
private _sourceFundsForPayment(amountNeeded: number): number {
178+
let fundsSourced = 0;
179+
180+
// preferentially source width from our own surplus first
181+
const surplus = Math.max(0, (this.pxWidth ?? 0) - this.pxMinWidth);
182+
const paymentFromSurplus = Math.min(amountNeeded, surplus);
183+
184+
if (paymentFromSurplus > 0) {
185+
this.pxWidth = (this.pxWidth ?? 0) - paymentFromSurplus;
186+
187+
fundsSourced = fundsSourced + paymentFromSurplus;
188+
}
189+
190+
// if we dont have enough to cover, source from debtors recursively
191+
const shortfall = amountNeeded - fundsSourced;
192+
193+
if (shortfall > 0) {
194+
const ourDebtors = this.table.columns.filter(
195+
(column) => column.widthDebts[this.key]
196+
);
197+
198+
for (const subDebtor of ourDebtors) {
199+
const amountStillNeeded = amountNeeded - fundsSourced;
200+
201+
if (amountStillNeeded <= 0) {
202+
break;
203+
}
204+
205+
const subDebtOwed = subDebtor.widthDebts[this.key] ?? 0;
206+
const amountToRequest = Math.min(amountStillNeeded, subDebtOwed);
207+
208+
const collectedFromSubDebtor =
209+
subDebtor._sourceFundsForPayment(amountToRequest);
210+
211+
if (collectedFromSubDebtor > 0) {
212+
fundsSourced = fundsSourced + collectedFromSubDebtor;
213+
214+
// Update the sub-debtor's ledger.
215+
const remainingSubDebt = subDebtOwed - collectedFromSubDebtor;
216+
217+
if (remainingSubDebt > 0) {
218+
subDebtor.widthDebts[this.key] = remainingSubDebt;
219+
} else {
220+
delete subDebtor.widthDebts[this.key];
221+
}
222+
}
223+
}
224+
}
225+
226+
return fundsSourced;
227+
}
228+
229+
private payWidthDebts(): void {
230+
Object.entries(this.widthDebts).forEach(([lenderKey, amount]) => {
231+
const lender = this.table.getColumnByKey(lenderKey);
232+
233+
if (lender !== undefined) {
234+
// Give the width back to the column that lent it to us
235+
lender.pxWidth = (lender.pxWidth ?? 0) + amount;
236+
}
237+
});
238+
239+
// Clear our own debt ledger, as we've paid everyone back
240+
this.widthDebts = {};
241+
}
242+
243+
private settleWidthDebts(): void {
244+
this.collectWidthDebts();
245+
this.payWidthDebts();
246+
}
247+
248+
// set initial width values
126249
private _setWidthValues({
127250
width,
128251
minWidth,
129252
maxWidth,
130253
}: HdsAdvancedTableColumnType): void {
131-
if (width === undefined) {
132-
return;
133-
}
134-
135-
this.width = width;
254+
this.width = width ?? DEFAULT_WIDTH;
136255

137256
// capture the width at the time of instantiation so it can be restored
138-
this.originalWidth = width;
257+
this.originalWidth = this.width;
139258

140259
this.minWidth = minWidth ?? DEFAULT_MIN_WIDTH;
141260
this.maxWidth = maxWidth ?? DEFAULT_MAX_WIDTH;
142261
}
143262

144263
// Sets the column width in pixels, ensuring it respects the min and max width constraints.
145-
@action
146-
setPxWidth(newPxWidth: number): void {
264+
setPxTransientWidth(newPxWidth: number): void {
147265
const pxMinWidth = this.pxMinWidth ?? 1;
148266
const minLimitedPxWidth = Math.max(newPxWidth, pxMinWidth);
149267

150-
this.pxWidth =
268+
this.pxTransientWidth =
151269
this.pxMaxWidth !== undefined
152270
? Math.min(minLimitedPxWidth, this.pxMaxWidth)
153271
: minLimitedPxWidth;
@@ -157,29 +275,10 @@ export default class HdsAdvancedTableColumn {
157275
}
158276
}
159277

160-
// This method is called when the column width is changed by the previous column.
161-
@action
162-
onPreviousColumnWidthRestored(): void {
163-
const restoredWidth = (this.pxWidth ?? 0) + this.imposedWidthDelta;
164-
165-
this.setPxWidth(restoredWidth);
166-
167-
this.imposedWidthDelta = 0;
168-
}
169-
170-
// This method is called when the next column width is restored.
171-
@action
172-
onNextColumnWidthRestored(imposedWidthDelta: number): void {
173-
this.setPxWidth((this.pxWidth ?? 0) - imposedWidthDelta);
174-
}
175-
176278
@action
177279
restoreWidth(): void {
178-
this.width = this.originalWidth;
179-
this.imposedWidthDelta = 0;
280+
this.settleWidthDebts();
180281

181-
if (this.key === undefined) {
182-
return;
183-
}
282+
this.width = this.originalWidth ?? this.width;
184283
}
185284
}

0 commit comments

Comments
 (0)