Añadir estilos de enlace comunes y mejorar la navegación en componentes de la aplicación

This commit is contained in:
David Arranz 2026-10-09 21:32:41 +02:00
parent d7eb91ee68
commit 4c2aa11c03
27 changed files with 70 additions and 32 deletions

View File

@ -58,3 +58,28 @@
--spacing-shell-avatar: 35px; --spacing-shell-avatar: 35px;
--shadow-shell-popover: 0 3px 12px rgb(12 40 80 / 8%); --shadow-shell-popover: 0 3px 12px rgb(12 40 80 / 8%);
} }
/* Enlaces de contenido y controles que navegan: criterio común en toda la aplicación. */
@layer components {
.app-link {
color: var(--primary);
cursor: pointer;
text-underline-offset: 4px;
}
.app-link:hover {
text-decoration-line: underline;
}
.app-link:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
.app-link:disabled,
.app-link[data-disabled],
.app-link[aria-disabled="true"] {
cursor: default;
text-decoration-line: none;
}
}

View File

@ -309,3 +309,9 @@ Las secciones de consulta evitan recuadros anidados y rellenos laterales: usan t
`DetailSection` ofrece agrupación plegable nativa (`details/summary`), abierta inicialmente, accesible por teclado. `DetailField` devuelve una fila semántica `dt/dd` para incluir en un `dl`: etiqueta y valor se alinean horizontalmente cuando el contenedor dispone de al menos 240 px y se apilan en espacios menores. El consumidor aporta los enlaces; el componente común les aplica cursor de enlace, color semántico y subrayado al pasar el puntero. Los valores ausentes permanecen como texto. `DetailSection` ofrece agrupación plegable nativa (`details/summary`), abierta inicialmente, accesible por teclado. `DetailField` devuelve una fila semántica `dt/dd` para incluir en un `dl`: etiqueta y valor se alinean horizontalmente cuando el contenedor dispone de al menos 240 px y se apilan en espacios menores. El consumidor aporta los enlaces; el componente común les aplica cursor de enlace, color semántico y subrayado al pasar el puntero. Los valores ausentes permanecen como texto.
La ficha lateral de activos utiliza un ancho adaptable entre 320 y 400 px, acciones debajo del título y bloques de identificación, ubicación/asignación, características, observaciones, relaciones y asistencia. Conserva las secciones capturadas de la plantilla, incluyendo la compatibilidad con especificaciones antiguas sin agrupación; no modifica valores ni instantáneas. Las relaciones llevan a destinos existentes: persona y activo relacionado a su ficha; sede, puesto y plantilla a sus listados/catálogos actuales. La vista completa e historial mantienen sus funciones. Las fichas de recursos reutilizan las filas de características. No se crean nuevas fichas laterales ni nuevas rutas. La ficha lateral de activos utiliza un ancho adaptable entre 320 y 400 px, acciones debajo del título y bloques de identificación, ubicación/asignación, características, observaciones, relaciones y asistencia. Conserva las secciones capturadas de la plantilla, incluyendo la compatibilidad con especificaciones antiguas sin agrupación; no modifica valores ni instantáneas. Las relaciones llevan a destinos existentes: persona y activo relacionado a su ficha; sede, puesto y plantilla a sus listados/catálogos actuales. La vista completa e historial mantienen sus funciones. Las fichas de recursos reutilizan las filas de características. No se crean nuevas fichas laterales ni nuevas rutas.
## Enlaces y controles de navegación
Los enlaces de contenido usan `app-link`: color semántico `primary`, cursor de mano, subrayado al pasar el puntero y foco visible por teclado. `BrowserLink` lo incorpora de forma predeterminada, conservando la navegación interna, los clics modificados, destinos externos y descargas. Los botones que presentan nombres navegables o «Ver todos» comparten este estilo sin alterar sus callbacks. Los controles deshabilitados no muestran cursor de enlace ni subrayado. La navegación lateral y los enlaces presentados como botones conservan los colores de su contexto para mantener contraste; las acciones de guardar, editar o cancelar mantienen su presentación de botón.
En el menú de secciones de una compañía, los elementos utilizan el color normal de texto y solo destacan su texto al pasar el puntero. Se mantienen el cursor de mano, el foco por teclado y el indicador de la sección actual. Esta excepción no afecta a enlaces dentro de listados, fichas o bloques del dashboard.

View File

@ -22,7 +22,7 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
<p className="font-medium"> <p className="font-medium">
{source ? link.values.name : link.values.inverseName} ·{" "} {source ? link.values.name : link.values.inverseName} ·{" "}
<BrowserLink <BrowserLink
className="cursor-pointer text-primary underline-offset-4 hover:underline" className="cursor-pointer text-primary underline-offset-4 app-link text-primary hover:text-primary"
href={`/companies/${companyId}/assets/${source ? link.values.targetId : link.values.sourceId}`} href={`/companies/${companyId}/assets/${source ? link.values.targetId : link.values.sourceId}`}
> >
{nameOf(source ? link.values.targetId : link.values.sourceId)} {nameOf(source ? link.values.targetId : link.values.sourceId)}

View File

@ -100,7 +100,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
render: (asset) => ( render: (asset) => (
<BrowserLink <BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen} aria-pressed={selected?.id === asset.id && detailOpen}
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] hover:underline focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring" className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
href={entityHref(asset.id)} href={entityHref(asset.id)}
onClick={() => setDetailOpen(true)} onClick={() => setDetailOpen(true)}
> >

View File

@ -21,7 +21,7 @@ export function CatalogName({
return ( return (
<> <>
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={onView} onClick={onView}
type="button" type="button"
> >

View File

@ -68,7 +68,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)} onClick={() => setView(item.id)}
type="button" type="button"
> >

View File

@ -80,7 +80,7 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)} onClick={() => setView(item.id)}
type="button" type="button"
> >

View File

@ -80,7 +80,7 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)} onClick={() => setView(item.id)}
type="button" type="button"
> >

View File

@ -158,8 +158,8 @@ export function CompanyDetail(props: CompanyDetailProps) {
aria-pressed={tab === section} aria-pressed={tab === section}
className={ className={
embedded embedded
? "h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm" ? "cursor-pointer text-foreground hover:text-primary h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
: undefined : "cursor-pointer text-foreground hover:text-primary"
} }
{...(props.onSectionChange {...(props.onSectionChange
? { ? {

View File

@ -85,7 +85,7 @@ export function CompaniesPage(props: CompaniesPageProps) {
hideable: false, hideable: false,
render: (company) => ( render: (company) => (
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={companySectionHref(company.id, "overview")} href={companySectionHref(company.id, "overview")}
> >
<CompanyIdentity <CompanyIdentity

View File

@ -9,7 +9,7 @@ export function DashboardNavigationButton({
<button <button
{...props} {...props}
className={cn( className={cn(
"text-left font-semibold text-primary hover:underline focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-3 focus-visible:outline-ring", "text-left font-semibold app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-3 focus-visible:outline-ring",
className className
)} )}
type="button" type="button"

View File

@ -30,7 +30,12 @@ export function DashboardPanel({
{title} {title}
</h2> </h2>
{action && ( {action && (
<Button className="text-primary shrink-0" onClick={action} size="sm" variant="ghost"> <Button
className="app-link text-primary hover:text-primary shrink-0"
onClick={action}
size="sm"
variant="ghost"
>
{t("dashboard.viewAll")} {t("dashboard.viewAll")}
<ChevronRightIcon aria-hidden="true" className="size-4" /> <ChevronRightIcon aria-hidden="true" className="size-4" />
</Button> </Button>

View File

@ -73,7 +73,7 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)} onClick={() => setView(item.id)}
type="button" type="button"
> >

View File

@ -84,7 +84,7 @@ export function InterventionsPage(props: InterventionsPageProps) {
hideable: false, hideable: false,
render: (intervention) => ( render: (intervention) => (
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={`/companies/${intervention.companyId}/interventions/${intervention.id}`} href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
> >
{intervention.reference} {intervention.reference}
@ -98,7 +98,7 @@ export function InterventionsPage(props: InterventionsPageProps) {
hideable: false, hideable: false,
render: (intervention) => ( render: (intervention) => (
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={`/companies/${intervention.companyId}/interventions/${intervention.id}`} href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
> >
{intervention.values.subject} {intervention.values.subject}

View File

@ -79,7 +79,7 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)} onClick={() => setView(item.id)}
type="button" type="button"
> >

View File

@ -104,7 +104,7 @@ export function PeopleSection({
render: (person) => ( render: (person) => (
<> <>
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={entityHref(person.id)} href={entityHref(person.id)}
> >
{person.name} {person.name}

View File

@ -74,7 +74,7 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)} onClick={() => setView(item.id)}
type="button" type="button"
> >

View File

@ -96,7 +96,7 @@ export function ProcessesPage(props: ProcessesPageProps) {
hideable: false, hideable: false,
render: (p) => ( render: (p) => (
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={`/companies/${company.id}/processes/${p.id}`} href={`/companies/${company.id}/processes/${p.id}`}
> >
{p.values.name} {p.values.name}

View File

@ -77,7 +77,7 @@ export function RemoteConnectionsPage(props: RemoteConnectionsPageProps) {
hideable: false, hideable: false,
render: (c) => ( render: (c) => (
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(c.id)} onClick={() => setView(c.id)}
type="button" type="button"
> >

View File

@ -77,7 +77,7 @@ export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProp
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)} onClick={() => setView(item.id)}
type="button" type="button"
> >

View File

@ -87,7 +87,7 @@ export function TicketsPage(props: TicketsPageProps) {
hideable: false, hideable: false,
render: (ticket) => ( render: (ticket) => (
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={`/companies/${ticket.companyId}/tickets/${ticket.id}`} href={`/companies/${ticket.companyId}/tickets/${ticket.id}`}
> >
{ticket.reference} {ticket.reference}
@ -101,7 +101,7 @@ export function TicketsPage(props: TicketsPageProps) {
hideable: false, hideable: false,
render: (ticket) => ( render: (ticket) => (
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={`/companies/${ticket.companyId}/tickets/${ticket.id}`} href={`/companies/${ticket.companyId}/tickets/${ticket.id}`}
> >
{ticket.values.subject} {ticket.values.subject}

View File

@ -98,7 +98,7 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={`/companies/${company.id}/work-profiles/${item.id}`} href={`/companies/${company.id}/work-profiles/${item.id}`}
> >
{item.values.name} {item.values.name}

View File

@ -9,9 +9,7 @@ export function RouteFeedback({ invalid = false }: { invalid?: boolean }) {
{t(invalid ? "navigation.invalidRoute" : "navigation.notFound")} {t(invalid ? "navigation.invalidRoute" : "navigation.notFound")}
</h1> </h1>
<p className="text-muted-foreground">{t("navigation.routeHelp")}</p> <p className="text-muted-foreground">{t("navigation.routeHelp")}</p>
<BrowserLink className="text-primary underline underline-offset-4" href="/dashboard"> <BrowserLink href="/dashboard">{t("dashboard.title")}</BrowserLink>
{t("dashboard.title")}
</BrowserLink>
</section> </section>
); );
} }

View File

@ -2,10 +2,16 @@ import type { ComponentProps } from "react";
import { isInternalPath, navigateTo } from "../helpers/browser-navigation"; import { isInternalPath, navigateTo } from "../helpers/browser-navigation";
export function BrowserLink({ href, onClick, ...props }: ComponentProps<"a"> & { href: string }) { export function BrowserLink({
href,
onClick,
className,
...props
}: ComponentProps<"a"> & { href: string }) {
return ( return (
<a <a
{...props} {...props}
className={`app-link ${className ?? ""}`}
href={href} href={href}
onClick={(event) => { onClick={(event) => {
onClick?.(event); onClick?.(event);

View File

@ -33,9 +33,7 @@ export function CustomWorkspace({
return ( return (
<section className="space-y-4 p-6" role="alert"> <section className="space-y-4 p-6" role="alert">
<p>{t("errors.FORBIDDEN")}</p> <p>{t("errors.FORBIDDEN")}</p>
<BrowserLink className="underline" href="/dashboard"> <BrowserLink href="/dashboard">{t("workspace")}</BrowserLink>
{t("workspace")}
</BrowserLink>
</section> </section>
); );
} }

View File

@ -21,7 +21,7 @@ export function CatalogName({
return ( return (
<> <>
<button <button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={onView} onClick={onView}
type="button" type="button"
> >

View File

@ -72,7 +72,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<BrowserLink <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={entityHref(item.id)} href={entityHref(item.id)}
> >
{item.values.name} {item.values.name}