From ae5589b0d46830424b40a0de52c85744248d514c Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 17 May 2026 17:56:32 +0200 Subject: [PATCH] Add Eidos tooltip reference comparison --- continue.md | 12 +++++ src/uix/eidos/components/tooltip/README.md | 51 +++++++++++++++++++ src/uix/eidos/components/tooltip/index.ts | 1 + .../components/tooltip/tooltip-content.svelte | 45 +++++++++++++++- src/uix/eidos/components/tooltip/tooltip.css | 16 +++++- src/uix/eidos/components/tooltip/types.ts | 19 +++++++ .../tooltip/components/tooltip-group.svelte | 2 + .../tooltip/components/tooltip.svelte | 2 + .../tooltip/tooltip-provider.svelte.test.ts | 49 ++++++++++++++++++ .../tooltip/tooltip-provider.svelte.ts | 30 +++++++++-- src/uix/soma/components/tooltip/types.ts | 4 ++ 11 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/uix/eidos/components/tooltip/README.md diff --git a/continue.md b/continue.md index d6001f477..0196545ad 100644 --- a/continue.md +++ b/continue.md @@ -54,6 +54,18 @@ Fecha de corte: 2026-05-17. Rama: `active-uix`. `height`, `minHeight`, `maxHeight` en `Popover.Content`, serializados como variables privadas. La receta usa transform-origin y available-height de Soma Floating. +- Cuarto componente auditado con el nuevo protocolo: + - `Tooltip`: Air no tenia equivalente en `glm-5`; referencia local + Soma/Morfo, comparativa externa Radix, Ark UI, Bits UI y shadcn-svelte. + - Decision: no se crea `Tooltip.Positioner` ni `Tooltip.ArrowTip`; Soma + Floating y Soma Arrow son los duenos de esa geometria. + - Decision: se anaden `matchAnchorWidth`, `width`, `minWidth`, `maxWidth`, + `height`, `minHeight`, `maxHeight` en `Tooltip.Content`, serializados como + variables privadas. La receta usa transform-origin y available-height de + Soma Floating. + - Decision: se alinea Soma con Radix/Bits: el tooltip cierra al activar/clicar + el trigger por defecto. `disableCloseOnTriggerClick` queda como escape hatch + en `Tooltip.Group` y `Tooltip`. - Componentes Eidos nuevos desde Soma: - `meter` - `progress` diff --git a/src/uix/eidos/components/tooltip/README.md b/src/uix/eidos/components/tooltip/README.md new file mode 100644 index 000000000..855abb8b2 --- /dev/null +++ b/src/uix/eidos/components/tooltip/README.md @@ -0,0 +1,51 @@ +# Eidos Tooltip + +Fecha de revision: 2026-05-17. + +## Baseline + +Air no tenia `tooltip` en `glm-5:src/uix/air/components`; este componente es +una envoltura visual sobre Soma/Morfo, no un port de Air. + +## Comparativa + +| Referencia | Superficie relevante | Estado Eidos | +| --- | --- | --- | +| Air `glm-5` | No existe `tooltip` en Air. | Sin baseline Air. La referencia local es Soma/Morfo. | +| Soma actual | `Group`, `Provider`, `Trigger`, `Content`, `Arrow`; delay/skip-delay, hover/focus, hoverable content, disabled, side/align/collision, Floating CSS vars, `aria-describedby`, `role="tooltip"`. | Cubierto por passthrough. Eidos solo anade receta visual. | +| Radix Tooltip | Provider global, Root, Trigger, Portal, Content, Arrow; abre en hover/focus, cierra en activacion del trigger o Escape, custom timings, collision, CSS vars para transform-origin/available size/trigger size. | Cubierto. Se alinea Soma para cerrar al click de trigger por defecto y Eidos consume vars de Floating para medidas/animacion. | +| Ark UI Tooltip | Root, Trigger, Positioner, Arrow/ArrowTip, Content; control, root provider, delay, positioning, fixed containers, context. | Cubierto salvo `Positioner`/`ArrowTip` explicitos: en UIX viven dentro de Soma Floating/Arrow. | +| Bits UI / shadcn-svelte | Provider recomendado en layout, Root, Trigger, Portal, Content, Arrow; delayDuration, skipDelayDuration, disableHoverableContent, forceMount, Floating vars, nested providers. | Cubierto. `disableCloseOnTriggerClick` se soporta en Soma como escape hatch. | + +Fuentes externas consultadas: + +- Radix Tooltip: https://www.radix-ui.com/primitives/docs/components/tooltip +- Ark UI Tooltip: https://ark-ui.com/docs/components/tooltip +- Bits UI Tooltip: https://bits-ui.com/docs/components/tooltip +- shadcn-svelte Tooltip: https://www.shadcn-svelte.com/docs/components/tooltip + +## Decisiones + +- No se crea `Tooltip.Positioner`. La posicion pertenece a Soma Floating; + Eidos consume `data-side`, `data-align` y variables CSS publicadas por Soma. +- No se crea `Tooltip.ArrowTip`. Soma Arrow ya encapsula la geometria del + arrow; la receta solo pinta fill/stroke. +- `Tooltip.Content` mantiene `size` y `variant`, y anade `matchAnchorWidth`, + `width`, `minWidth`, `maxWidth`, `height`, `minHeight`, `maxHeight` como + props visuales responsivas. +- Las medidas se serializan como variables privadas del recipe + (`--_tooltip-content-*`); el contrato publico de tokens sigue siendo la + recipe. +- La receta usa `--soma-tooltip-content-transform-origin` para animaciones + origin-aware y `--soma-tooltip-content-available-height` como max-height por + defecto cuando Floating ya lo conoce. +- Soma cierra al activar/clicar el trigger por defecto, alineado con Radix. Si + se necesita mantenerlo abierto, usar `disableCloseOnTriggerClick`. + +## Pendiente deliberado + +- `ignoreNonKeyboardFocus` de Bits queda fuera hasta que haya un caso real en + accesibilidad/focus management. No debe vivir en Eidos. +- `customAnchor` para Tooltip queda fuera por ahora; Popover ya cubre el caso + de panel flotante anclado a otro elemento. Si Tooltip lo necesita, debe + anadirse primero a Soma. diff --git a/src/uix/eidos/components/tooltip/index.ts b/src/uix/eidos/components/tooltip/index.ts index 78b4aeb76..78602c384 100644 --- a/src/uix/eidos/components/tooltip/index.ts +++ b/src/uix/eidos/components/tooltip/index.ts @@ -46,5 +46,6 @@ export type { TooltipContentProps as ContentProps, TooltipArrowProps as ArrowProps, TooltipSize, + TooltipCssLength, TooltipVariant } from './types'; diff --git a/src/uix/eidos/components/tooltip/tooltip-content.svelte b/src/uix/eidos/components/tooltip/tooltip-content.svelte index 4b42d952f..e6f73f2ba 100644 --- a/src/uix/eidos/components/tooltip/tooltip-content.svelte +++ b/src/uix/eidos/components/tooltip/tooltip-content.svelte @@ -9,21 +9,64 @@ * continue to come from the morfo runtime. */ import { ActiveEidos } from '$uix/eidos'; + import { composeInlineStyle } from '$uix/eidos/lib/style'; import * as Tooltip from '$soma/components/tooltip'; import type { TooltipContentProps } from './types'; let { size = 'md', variant = 'solid', + matchAnchorWidth = false, + width, + minWidth, + maxWidth, + height, + minHeight, + maxHeight, + style, children: bodyContent, ...rest }: TooltipContentProps = $props(); const eidos = ActiveEidos.require(); const resolvedSize = $derived(eidos.resolve(size, 'md')); + const resolvedMatchAnchorWidth = $derived(eidos.resolve(matchAnchorWidth, false)); + + function serializeLength(value: string | number | undefined): string | undefined { + if (value === undefined) return undefined; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return undefined; + return value === 0 ? '0' : `${value}px`; + } + const trimmed = value.trim(); + return trimmed || undefined; + } + + function dimensionVar(name: string, value: string | number | undefined): string | undefined { + const serialized = serializeLength(value); + return serialized ? `${name}: ${serialized};` : undefined; + } + + const dimensionsStyle = $derived( + [ + resolvedMatchAnchorWidth + ? '--_tooltip-match-anchor-width: var(--soma-tooltip-anchor-width);' + : undefined, + dimensionVar('--_tooltip-content-width-override', serializeLength(eidos.resolve(width))), + dimensionVar('--_tooltip-content-min-width-override', serializeLength(eidos.resolve(minWidth))), + dimensionVar('--_tooltip-content-max-width-override', serializeLength(eidos.resolve(maxWidth))), + dimensionVar('--_tooltip-content-height-override', serializeLength(eidos.resolve(height))), + dimensionVar('--_tooltip-content-min-height-override', serializeLength(eidos.resolve(minHeight))), + dimensionVar('--_tooltip-content-max-height-override', serializeLength(eidos.resolve(maxHeight))) + ] + .filter(Boolean) + .join(' ') + ); + + const composedStyle = $derived(composeInlineStyle(style, dimensionsStyle)); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/tooltip/tooltip.css b/src/uix/eidos/components/tooltip/tooltip.css index 97b5459cc..b623c333b 100644 --- a/src/uix/eidos/components/tooltip/tooltip.css +++ b/src/uix/eidos/components/tooltip/tooltip.css @@ -11,15 +11,29 @@ --_tooltip-bg: var(--tooltip-bg); --_tooltip-border: var(--tooltip-border); --_tooltip-shadow: var(--tooltip-shadow); + --_tooltip-width: var(--_tooltip-content-width-override, var(--_tooltip-match-anchor-width, auto)); + --_tooltip-max-width: var(--_tooltip-content-max-width-override, var(--tooltip-max-width)); + --_tooltip-max-height: var( + --_tooltip-content-max-height-override, + var(--soma-tooltip-content-available-height, calc(100dvh - var(--space-4))) + ); display: block; - max-inline-size: min(var(--tooltip-max-width), calc(100vw - var(--space-4))); + box-sizing: border-box; + inline-size: var(--_tooltip-width); + min-inline-size: var(--_tooltip-content-min-width-override, auto); + max-inline-size: min(var(--_tooltip-max-width), calc(100vw - var(--space-4))); + block-size: var(--_tooltip-content-height-override, auto); + min-block-size: var(--_tooltip-content-min-height-override, auto); + max-block-size: var(--_tooltip-max-height); padding: var(--_tooltip-py) var(--_tooltip-px); + overflow: auto; border: var(--tooltip-border-width) solid var(--_tooltip-border); border-radius: var(--tooltip-radius); background: var(--_tooltip-bg); color: var(--tooltip-color); box-shadow: var(--_tooltip-shadow); + transform-origin: var(--soma-tooltip-content-transform-origin, center); font-family: var(--tooltip-font-family); font-size: var(--_tooltip-font-size); font-weight: var(--tooltip-font-weight); diff --git a/src/uix/eidos/components/tooltip/types.ts b/src/uix/eidos/components/tooltip/types.ts index 199a75756..9f9d9502a 100644 --- a/src/uix/eidos/components/tooltip/types.ts +++ b/src/uix/eidos/components/tooltip/types.ts @@ -14,6 +14,8 @@ import type { */ export type TooltipSize = Extract; +export type TooltipCssLength = string | number; + /** * Visual treatment of the tooltip surface. * @@ -38,6 +40,23 @@ export type TooltipContentProps = SomaTooltipContentProps & { * @default 'md' */ size?: ResponsiveProp; + /** + * Match the floating label inline size to the active trigger width. + * Useful for dense controls whose help text should align with the field. + */ + matchAnchorWidth?: ResponsiveProp; + /** Inline-size override. Numbers serialize as px. */ + width?: ResponsiveProp; + /** Minimum inline-size constraint. Numbers serialize as px. */ + minWidth?: ResponsiveProp; + /** Maximum inline-size constraint. Numbers serialize as px. */ + maxWidth?: ResponsiveProp; + /** Block-size override. Numbers serialize as px. */ + height?: ResponsiveProp; + /** Minimum block-size constraint. Numbers serialize as px. */ + minHeight?: ResponsiveProp; + /** Maximum block-size constraint. Numbers serialize as px. */ + maxHeight?: ResponsiveProp; /** * Visual treatment — `'solid' | 'outline' | 'ghost'`. Maps to the * recipe's surface tokens. diff --git a/src/uix/soma/components/tooltip/components/tooltip-group.svelte b/src/uix/soma/components/tooltip/components/tooltip-group.svelte index 1467986e2..550b8ec2b 100644 --- a/src/uix/soma/components/tooltip/components/tooltip-group.svelte +++ b/src/uix/soma/components/tooltip/components/tooltip-group.svelte @@ -9,6 +9,7 @@ delayDuration = 700, skipDelayDuration = 300, disableHoverableContent = false, + disableCloseOnTriggerClick = false, disabled = false, children }: TooltipGroupProps = $props(); @@ -17,6 +18,7 @@ delayDuration: readableActive(() => delayDuration), skipDelayDuration: readableActive(() => skipDelayDuration), disableHoverableContent: readableActive(() => disableHoverableContent), + disableCloseOnTriggerClick: readableActive(() => disableCloseOnTriggerClick), disabled: readableActive(() => disabled) }); diff --git a/src/uix/soma/components/tooltip/components/tooltip.svelte b/src/uix/soma/components/tooltip/components/tooltip.svelte index 57fa2eb58..1d927e73d 100644 --- a/src/uix/soma/components/tooltip/components/tooltip.svelte +++ b/src/uix/soma/components/tooltip/components/tooltip.svelte @@ -18,6 +18,7 @@ delayDuration, closeDelay = 150, disableHoverableContent, + disableCloseOnTriggerClick, disabled, children }: TooltipProps = $props(); @@ -35,6 +36,7 @@ delayDuration: readableActive(() => delayDuration), closeDelay: readableActive(() => closeDelay), disableHoverableContent: readableActive(() => disableHoverableContent), + disableCloseOnTriggerClick: readableActive(() => disableCloseOnTriggerClick), disabled: readableActive(() => disabled) }); diff --git a/src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts b/src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts index 0fa43eca6..0b3190e1b 100644 --- a/src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts +++ b/src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts @@ -70,6 +70,7 @@ function groupOpts() { delayDuration: state(700), skipDelayDuration: state(300), disableHoverableContent: state(false), + disableCloseOnTriggerClick: state(false), disabled: state(false) }; } @@ -82,6 +83,7 @@ function tooltipOpts() { delayDuration: state(undefined), closeDelay: state(150), disableHoverableContent: state(undefined), + disableCloseOnTriggerClick: state(undefined), disabled: state(undefined), onOpenChangeComplete: state<(open: boolean) => void>(() => undefined) }; @@ -212,6 +214,53 @@ describe('TooltipProvider', () => { harness.dom.dispose(); }); + it('closes immediately when the trigger is activated unless disabled', async () => { + const harness = installSomaHarness(); + const triggerEl = document.createElement('button'); + document.body.appendChild(triggerEl); + const opts = tooltipOpts(); + + const { result, cleanup } = withEffectRoot(() => { + const group = TooltipGroupProvider.create(groupOpts()); + harness.groupGet.mockReturnValue(group); + const provider = TooltipProvider.create(opts); + vi.spyOn(TooltipProvider, 'require').mockReturnValue(provider); + vi.spyOn(FloatingProvider.ctx, 'get').mockReturnValue(provider.floatingProvider); + const trigger = TooltipTriggerProvider.create({ + id: state('tooltip-trigger'), + ref: state(triggerEl) + }); + return { provider, trigger }; + }); + + result.trigger.onfocus(); + await tick(); + expect(opts.open.current).toBe(true); + + result.trigger.onclick(); + await tick(); + expect(opts.open.current).toBe(false); + expect(result.provider.tooltipState).toBe('closed'); + + result.trigger.onfocus(); + await tick(); + const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }); + result.trigger.onkeydown(keyEvent); + await tick(); + expect(keyEvent.defaultPrevented).toBe(true); + expect(opts.open.current).toBe(false); + + opts.disableCloseOnTriggerClick.current = true; + result.trigger.onfocus(); + await tick(); + result.trigger.onclick(); + await tick(); + expect(opts.open.current).toBe(true); + + cleanup(); + harness.dom.dispose(); + }); + it('registers trigger/content/arrow parts and links content while open', async () => { const harness = installSomaHarness(); const triggerEl = document.createElement('button'); diff --git a/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts b/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts index 8bca01790..2ca1ce385 100644 --- a/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts +++ b/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts @@ -8,7 +8,6 @@ import { } from '$libs/reactive'; import type { OnChangeFn, - SomaKeyboardEvent, Direction, Arrayable, StyleProperties @@ -40,6 +39,7 @@ interface TooltipGroupOpts { delayDuration: Active; skipDelayDuration: Active; disableHoverableContent: Active; + disableCloseOnTriggerClick: Active; disabled: Active; } @@ -85,6 +85,7 @@ interface TooltipOpts delayDuration: number | undefined; closeDelay: number; disableHoverableContent: boolean | undefined; + disableCloseOnTriggerClick: boolean | undefined; disabled: boolean | undefined; onOpenChangeComplete: OnChangeFn; }> {} @@ -194,6 +195,15 @@ export class TooltipProvider { ); } + /** Effective trigger activation behavior. */ + get closeOnTriggerClickDisabled(): boolean { + return ( + (this.opts.disableCloseOnTriggerClick.current ?? + this.group?.opts.disableCloseOnTriggerClick.current) || + false + ); + } + handleOpen(instant = false) { if (this.isDisabled) return; this.clearTimers(); @@ -216,11 +226,11 @@ export class TooltipProvider { } } - handleClose() { + handleClose(immediate = false) { this.clearTimers(); const closeDelay = this.opts.closeDelay.current; - if (closeDelay > 0) { + if (!immediate && closeDelay > 0) { this.closeTimer = this.soma.uix.timers.schedule( this.closeTimerKey, closeDelay, @@ -330,10 +340,24 @@ export class TooltipTriggerProvider { this.provider.handleClose(); }; + readonly onclick = () => { + if (!this.provider.opts.open.current || this.provider.closeOnTriggerClickDisabled) return; + this.provider.handleClose(true); + }; + + readonly onkeydown = (e: KeyboardEvent) => { + if (e.key !== KEYS.ENTER && e.key !== KEYS.SPACE) return; + if (!this.provider.opts.open.current || this.provider.closeOnTriggerClickDisabled) return; + e.preventDefault(); + this.provider.handleClose(true); + }; + readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, tabindex: 0, + onclick: this.onclick, + onkeydown: this.onkeydown, onpointerenter: this.onpointerenter, onpointerleave: this.onpointerleave, onfocus: this.onfocus, diff --git a/src/uix/soma/components/tooltip/types.ts b/src/uix/soma/components/tooltip/types.ts index 20710591f..6b5be22ed 100644 --- a/src/uix/soma/components/tooltip/types.ts +++ b/src/uix/soma/components/tooltip/types.ts @@ -20,6 +20,8 @@ export type TooltipGroupProps = { skipDelayDuration?: number; /** Prevent tooltip staying open when pointer moves to content. @default false */ disableHoverableContent?: boolean; + /** Keep the tooltip open when the trigger is clicked / activated. @default false */ + disableCloseOnTriggerClick?: boolean; /** Disable all tooltips under this provider. @default false */ disabled?: boolean; children?: Snippet; @@ -40,6 +42,8 @@ export type TooltipProps = { closeDelay?: number; /** Per-tooltip override. Inherits from group if omitted. */ disableHoverableContent?: boolean; + /** Per-tooltip override. Inherits from group if omitted. */ + disableCloseOnTriggerClick?: boolean; /** Disable this tooltip. Inherits from group if omitted. */ disabled?: boolean; children?: Snippet;