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:
1 parent
7842af0568
commit
1528f7cb96
7 files changed
+188
-3
No files matched your search
@@ -26,6 +26,7 @@
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
position: relative;
|
||||
padding: 4px 6px;
|
||||
background: var(--grid-head);
|
||||
border-right: 1px solid var(--line);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<tr>
|
||||
<th class="rn">#</th>
|
||||
@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()) {
|
||||
<th class="del"><span class="sr">Quitar</span></th>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
}
|
||||
@for (c of columns(); track c.key) {
|
||||
<th
|
||||
sboColResize
|
||||
[style.width]="c.width"
|
||||
[style.text-align]="align(c)"
|
||||
[class.pick]="searchable(c)"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { ColResize } from '../table/col-resize';
|
||||
import { Badge } from '../window/badge';
|
||||
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({
|
||||
selector: 'sbo-data-table',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [Badge],
|
||||
imports: [Badge, ColResize],
|
||||
templateUrl: './data-table.html',
|
||||
styleUrl: './data-table.css',
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user