diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index e3f5668..05f2318 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -26,7 +26,7 @@ | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | -| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | +| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, en el tablero las flechas recorren las tarjetas y Shift+←/→ las cambia de columna, `e` edita, `?` los lista | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | | 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | | 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 15ec6d6..53c4c2b 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -307,7 +307,7 @@ visualización de la plataforma: | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/explore` | Proyectos públicos (sin sesión) | -| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | +| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy | | `/p/:key/releases` | Releases | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | diff --git a/libs/web/tracker/domain/src/lib/models/shortcuts.ts b/libs/web/tracker/domain/src/lib/models/shortcuts.ts index 36ea464..cb08475 100644 --- a/libs/web/tracker/domain/src/lib/models/shortcuts.ts +++ b/libs/web/tracker/domain/src/lib/models/shortcuts.ts @@ -13,6 +13,22 @@ export const SHORTCUTS: { keys: string; what: string; where: string }[] = [ { keys: 'g s', what: 'Ir a los sprints', where: 'Dentro de un proyecto' }, { keys: 'g r', what: 'Ir a la hoja de ruta', where: 'Dentro de un proyecto' }, { keys: 'c', what: 'Crear un ticket', where: 'Tablero' }, + { + keys: '← ↑ → ↓', + what: 'Moverse entre tarjetas (Tab entra en el tablero)', + where: 'Tablero', + }, + { + keys: 'Shift ← →', + what: 'Pasar la tarjeta a la columna de al lado', + where: 'Tablero', + }, + { + keys: 'p · e · a', + what: 'Prioridad, estimación o persona de la tarjeta', + where: 'Tablero', + }, + { keys: 'Enter', what: 'Abrir la tarjeta', where: 'Tablero' }, { keys: 'e', what: 'Editar el ticket', where: 'Ficha de un ticket' }, { keys: 'Esc', what: 'Cerrar', where: 'Búsqueda y ayuda' }, ]; diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index 5e892f0..a12c711 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -310,6 +310,7 @@ const NOBODY = '__nobody__'; + Array.from(el.querySelectorAll(':scope > ts-issue-card')); + const lists = Array.from(row.querySelectorAll('.ts-column__list')); + const here = cards(list); + const index = here.indexOf(card); + const column = lists.indexOf(list); + const key = event.key; + if (key === 'ArrowDown' || key === 'ArrowUp') { + const next = here[index + (key === 'ArrowDown' ? 1 : -1)]; + if (next) focusCard(next); + return true; + } + if ((key === 'ArrowLeft' || key === 'ArrowRight') && event.shiftKey) { + const target = lists[column + (key === 'ArrowRight' ? 1 : -1)]; + if (target) this.moveByKey(issueId, target.id.split('|')[1]); + return true; + } + if (key === 'ArrowLeft' || key === 'ArrowRight') { + // The nearest column with cards that way, at the same height or the last. + const step = key === 'ArrowRight' ? 1 : -1; + for (let i = column + step; i >= 0 && i < lists.length; i += step) { + const there = cards(lists[i]); + if (there.length) { + focusCard(there[Math.min(index, there.length - 1)]); + break; + } + } + return true; + } + const field = { p: 'Prioridad', e: 'Estimación', a: 'Persona' }[key]; + if (field && !event.shiftKey) { + const select = card.querySelector( + `select[aria-label^="${field}"]`, + ); + if (!select) return false; + select.focus(); + try { + select.showPicker(); + } catch { + // Older browsers: focused, the arrows change it. + } + return true; + } + return false; + } + + /** Shift+←/→ on a card: the same as dropping it on that column. */ + private moveByKey(issueId: string, to: string): void { + const issue = this.board + .columns() + .flatMap((c) => c.issues) + .find((i) => i.id === issueId); + if (!issue || !to || issue.status === to) return; + if (!this.project.workflow().allowedFrom(issue.status).includes(to)) { + this.toast.info( + `De «${this.statusLabel(issue.status)}» no se puede pasar a «${this.statusLabel(to)}»`, + ); + return; + } + const target = this.board.columns().find((c) => c.status === to); + const limit = this.project.workflow().wipLimit(to); + if (target && overWip(target.total + 1, limit)) + this.toast.info( + `«${this.statusLabel(to)}» pasa de su límite WIP (${target.total + 1}/${limit}): mejor terminar algo antes de empezar más`, + ); + if (this.project.workflow().requiresReason(to)) { + this.pendingMove = { issueId, to }; + this.reason().reset({ text: '' }); + this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm()); + return; + } + this.board.moveIssue(issueId, to); + // The card is drawn again in its new column: the focus follows it. + setTimeout(() => { + const moved = document.querySelector( + `ts-issue-card[data-issue="${issueId}"]`, + ); + if (moved) focusCard(moved); + }); + } + protected openCreate(): void { const types = this.typeOptions(); if (!types.some((t) => t.value === this.newType())) @@ -937,3 +1045,10 @@ export class BoardPage { this.drawer.closeAll(); } } + +/** Puts the focus on a card's link (its title), into view. */ +function focusCard(card: HTMLElement): void { + const link = card.querySelector('.ts-card__title'); + link?.focus(); + link?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); +}