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; +}