feat(web): cambiar el ancho de las columnas de las tablas (arrastrar, doble clic ajusta, Mayús + doble clic ajusta todas)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 14:27:36 -05:00
1 parent 7842af0568
commit 1528f7cb96
7 files changed
+188 -3

No files matched your search

@@ -26,6 +26,7 @@
border-collapse: collapse; border-collapse: collapse;
} }
th { th {
position: relative;
padding: 4px 6px; padding: 4px 6px;
background: var(--grid-head); background: var(--grid-head);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
+1 -1
View File
@@ -15,7 +15,7 @@
<tr> <tr>
<th class="rn">#</th> <th class="rn">#</th>
@for (c of columns(); track c.key) { @for (c of columns(); track c.key) {
<th [style.width]="c.width" [class.num]="c.type === 'number'" [class.req]="c.required">{{ c.title }}</th> <th sboColResize [style.width]="c.width" [class.num]="c.type === 'number'" [class.req]="c.required">{{ c.title }}</th>
} }
@if (!readonly()) { @if (!readonly()) {
<th class="del"><span class="sr">Quitar</span></th> <th class="del"><span class="sr">Quitar</span></th>
+2 -1
View File
@@ -14,6 +14,7 @@ import {
import { formatNumber, parseDecimal } from '../../core/format'; import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup'; import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types'; import type { ListRow } from '../list/list.types';
import { ColResize } from '../table/col-resize';
import { ValueHelp } from '../value-help/value-help'; import { ValueHelp } from '../value-help/value-help';
import type { GridColumn, GridLine, GridMultiAdd } from './grid.types'; import type { GridColumn, GridLine, GridMultiAdd } from './grid.types';
@@ -30,7 +31,7 @@ interface LookupTarget {
@Component({ @Component({
selector: 'sbo-lines-grid', selector: 'sbo-lines-grid',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ValueHelp], imports: [ValueHelp, ColResize],
templateUrl: './lines-grid.html', templateUrl: './lines-grid.html',
styleUrl: './lines-grid.css', styleUrl: './lines-grid.css',
}) })
@@ -14,6 +14,7 @@
} }
@for (c of columns(); track c.key) { @for (c of columns(); track c.key) {
<th <th
sboColResize
[style.width]="c.width" [style.width]="c.width"
[style.text-align]="align(c)" [style.text-align]="align(c)"
[class.pick]="searchable(c)" [class.pick]="searchable(c)"
+2 -1
View File
@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ColResize } from '../table/col-resize';
import { Badge } from '../window/badge'; import { Badge } from '../window/badge';
import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types'; import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types';
@@ -9,7 +10,7 @@ import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type To
@Component({ @Component({
selector: 'sbo-data-table', selector: 'sbo-data-table',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Badge], imports: [Badge, ColResize],
templateUrl: './data-table.html', templateUrl: './data-table.html',
styleUrl: './data-table.css', styleUrl: './data-table.css',
}) })
+161
View File
@@ -0,0 +1,161 @@
import { Directive, ElementRef, OnDestroy, inject } from '@angular/core';
const MIN_WIDTH = 40;
const FILLER_MIN = 80;
const MAX_AUTOFIT = 600;
/** Encabezados de la tabla. */
const headsOf = (table: HTMLTableElement): HTMLTableCellElement[] =>
Array.from(table.querySelectorAll<HTMLTableCellElement>('thead th'));
/** La última columna redimensionable absorbe el espacio que sobra hasta el ancho del contenedor. */
const fillerOf = (heads: HTMLTableCellElement[]): HTMLTableCellElement =>
heads.filter((h) => h.classList.contains('resizable')).at(-1) ?? heads[heads.length - 1];
/** Ancho pedido por el usuario (se recuerda entre arrastres); el de `filler` es solo su mínimo. */
const wantedOf = (h: HTMLElement, filler: HTMLElement): number =>
Number(h.dataset['want']) || (h === filler ? FILLER_MIN : parseFloat(h.style.width) || h.getBoundingClientRect().width);
/**
* Pasa la tabla a ancho fijo: las columnas sin ancho propio (todas la primera vez, o las que se
* añadieron después) fijan el actual.
*/
function freeze(table: HTMLTableElement, heads: HTMLTableCellElement[]): void {
const measured = heads.map((h) => h.getBoundingClientRect().width);
heads.forEach((h, i) => {
if (!h.style.width) h.style.width = `${measured[i]}px`;
});
table.style.tableLayout = 'fixed';
table.style.minWidth = '0';
const filler = fillerOf(heads);
heads.forEach((h) => (h.dataset['want'] = String(wantedOf(h, filler))));
}
/**
* Reparte los anchos pedidos: si no llenan el contenedor, `filler` toma el resto (si el sobrante
* se repartiera entre todas, la columna arrastrada "rebotaría"); si lo superan, aparece el scroll.
*/
function fit(table: HTMLTableElement, heads: HTMLTableCellElement[]): void {
const filler = fillerOf(heads);
const others = heads.filter((h) => h !== filler).reduce((sum, h) => sum + wantedOf(h, filler), 0);
const fill = Math.max(wantedOf(filler, filler), (table.parentElement?.clientWidth ?? 0) - others);
heads.forEach((h) => {
if (h !== filler) h.style.width = `${wantedOf(h, filler)}px`;
});
filler.style.width = `${fill}px`;
table.style.width = `${others + fill}px`;
}
let canvas: CanvasRenderingContext2D | null | undefined;
function textWidth(text: string, font: string): number {
canvas ??= document.createElement('canvas').getContext('2d');
if (!canvas) return text.length * 7;
canvas.font = font;
return canvas.measureText(text).width;
}
/** Ancho que necesita una celda para mostrar su contenido completo. */
function contentWidth(cell: HTMLElement, isHead: boolean): number {
const cs = getComputedStyle(cell);
const pad = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) + parseFloat(cs.borderRightWidth);
const input = cell.querySelector<HTMLInputElement>('input.cell');
if (input) {
const ics = getComputedStyle(input);
const inputPad = parseFloat(ics.paddingLeft) + parseFloat(ics.paddingRight) + 2;
const button = cell.querySelector<HTMLElement>('.pick button')?.getBoundingClientRect().width ?? 0;
return pad + inputPad + button + textWidth(input.value, ics.font);
}
if (isHead) return pad + textWidth((cell.textContent ?? '').trim(), cs.font);
const range = document.createRange();
range.selectNodeContents(cell);
const badge = cell.querySelector('sbo-badge') ? 10 : 0; // margen de la insignia
return pad + range.getBoundingClientRect().width + badge;
}
/**
* Ajusta a su contenido (encabezado y filas cargadas) las columnas indicadas; `only` es el índice de
* una sola columna, o `undefined` para todas las redimensionables.
*/
function autofit(table: HTMLTableElement, only?: number): void {
const heads = headsOf(table);
freeze(table, heads);
const rows = Array.from(table.tBodies[0]?.rows ?? []).filter((r) => !r.classList.contains('none'));
heads.forEach((h, i) => {
if (!h.classList.contains('resizable') || (only !== undefined && only !== i)) return;
// Un pequeño margen por el encabezado en negrita y el redondeo de la medida.
let width = contentWidth(h, true) + 6;
for (const row of rows) {
const cell = row.cells[i];
if (cell) width = Math.max(width, contentWidth(cell, false) + 2);
}
h.dataset['want'] = String(Math.min(MAX_AUTOFIT, Math.max(MIN_WIDTH, Math.ceil(width))));
});
fit(table, heads);
}
/**
* Permite cambiar el ancho de una columna arrastrando el borde derecho de su encabezado (`<th>`).
* Al primer arrastre la tabla pasa a `table-layout: fixed` con los anchos actuales de todas las
* columnas, de modo que solo cambia la columna arrastrada. Si las columnas no llenan el contenedor,
* la última toma el resto; si lo superan, aparece el scroll horizontal.
* Doble clic en el borde: ajusta esa columna a su contenido; con Mayús, todas las columnas.
*/
@Directive({
selector: 'th[sboColResize]',
host: { class: 'resizable' },
})
export class ColResize implements OnDestroy {
private readonly th = inject<ElementRef<HTMLTableCellElement>>(ElementRef).nativeElement;
private readonly handle = document.createElement('span');
private cleanup: (() => void) | null = null;
constructor() {
const h = this.handle;
h.className = 'col-resizer';
h.setAttribute('aria-hidden', 'true');
h.title = 'Arrastrar: cambiar el ancho · Doble clic: ajustar al contenido · Mayús + doble clic: ajustar todas';
h.addEventListener('pointerdown', (e) => this.start(e));
h.addEventListener('click', (e) => e.stopPropagation());
h.addEventListener('dblclick', (e) => {
e.stopPropagation();
const table = this.th.closest('table');
if (table) autofit(table, e.shiftKey ? undefined : this.th.cellIndex);
});
this.th.append(h);
}
ngOnDestroy(): void {
this.cleanup?.();
this.handle.remove();
}
private start(event: PointerEvent): void {
event.preventDefault();
event.stopPropagation();
const table = this.th.closest('table');
if (!table) return;
const heads = headsOf(table);
freeze(table, heads);
const startX = event.clientX;
const startWidth = this.th.getBoundingClientRect().width;
const handle = this.handle;
handle.setPointerCapture(event.pointerId);
handle.classList.add('active');
const move = (e: PointerEvent) => {
this.th.dataset['want'] = String(Math.max(MIN_WIDTH, startWidth + e.clientX - startX));
fit(table, heads);
};
const stop = () => {
handle.classList.remove('active');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', stop);
handle.removeEventListener('pointercancel', stop);
this.cleanup = null;
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', stop);
handle.addEventListener('pointercancel', stop);
this.cleanup = stop;
}
}
+20
View File
@@ -2,3 +2,23 @@
@import './styles/themes/dark.css'; @import './styles/themes/dark.css';
@import './styles/base.css'; @import './styles/base.css';
@import './styles/forms.css'; @import './styles/forms.css';
/* Borde de arrastre de las columnas: lo crea la directiva sboColResize, fuera del alcance de los CSS de componente. */
th.resizable {
overflow: hidden;
text-overflow: ellipsis;
}
th .col-resizer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
touch-action: none;
}
th .col-resizer:hover,
th .col-resizer.active {
background: var(--focus);
opacity: 0.5;
}