diff --git a/apps/web/src/app/shared/grid/lines-grid.css b/apps/web/src/app/shared/grid/lines-grid.css
index 9f0b36b..b3a6368 100644
--- a/apps/web/src/app/shared/grid/lines-grid.css
+++ b/apps/web/src/app/shared/grid/lines-grid.css
@@ -26,6 +26,7 @@
border-collapse: collapse;
}
th {
+ position: relative;
padding: 4px 6px;
background: var(--grid-head);
border-right: 1px solid var(--line);
diff --git a/apps/web/src/app/shared/grid/lines-grid.html b/apps/web/src/app/shared/grid/lines-grid.html
index 4b757c0..e7bd051 100644
--- a/apps/web/src/app/shared/grid/lines-grid.html
+++ b/apps/web/src/app/shared/grid/lines-grid.html
@@ -15,7 +15,7 @@
| # |
@for (c of columns(); track c.key) {
- {{ c.title }} |
+ {{ c.title }} |
}
@if (!readonly()) {
Quitar |
diff --git a/apps/web/src/app/shared/grid/lines-grid.ts b/apps/web/src/app/shared/grid/lines-grid.ts
index 16ce0a7..4207955 100644
--- a/apps/web/src/app/shared/grid/lines-grid.ts
+++ b/apps/web/src/app/shared/grid/lines-grid.ts
@@ -14,6 +14,7 @@ import {
import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types';
+import { ColResize } from '../table/col-resize';
import { ValueHelp } from '../value-help/value-help';
import type { GridColumn, GridLine, GridMultiAdd } from './grid.types';
@@ -30,7 +31,7 @@ interface LookupTarget {
@Component({
selector: 'sbo-lines-grid',
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [ValueHelp],
+ imports: [ValueHelp, ColResize],
templateUrl: './lines-grid.html',
styleUrl: './lines-grid.css',
})
diff --git a/apps/web/src/app/shared/list/data-table.html b/apps/web/src/app/shared/list/data-table.html
index 4299007..6ff8dcc 100644
--- a/apps/web/src/app/shared/list/data-table.html
+++ b/apps/web/src/app/shared/list/data-table.html
@@ -14,6 +14,7 @@
}
@for (c of columns(); track c.key) {
+ Array.from(table.querySelectorAll('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('input.cell');
+ if (input) {
+ const ics = getComputedStyle(input);
+ const inputPad = parseFloat(ics.paddingLeft) + parseFloat(ics.paddingRight) + 2;
+ const button = cell.querySelector('.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 (`| `).
+ * 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).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;
+ }
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 0706dec..c36a314 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -2,3 +2,23 @@
@import './styles/themes/dark.css';
@import './styles/base.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;
+}
| |