Add Eidos tooltip reference comparison

active-uix
dev 5 months ago
parent 5c0137e57e
commit ae5589b0d4

@ -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`

@ -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.

@ -46,5 +46,6 @@ export type {
TooltipContentProps as ContentProps,
TooltipArrowProps as ArrowProps,
TooltipSize,
TooltipCssLength,
TooltipVariant
} from './types';

@ -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));
</script>
<Tooltip.Content {...rest} data-size={resolvedSize} data-variant={variant}>
<Tooltip.Content {...rest} style={composedStyle} data-size={resolvedSize} data-variant={variant}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}

@ -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);

@ -14,6 +14,8 @@ import type {
*/
export type TooltipSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type TooltipCssLength = string | number;
/**
* Visual treatment of the tooltip surface.
*
@ -38,6 +40,23 @@ export type TooltipContentProps = SomaTooltipContentProps & {
* @default 'md'
*/
size?: ResponsiveProp<TooltipSize>;
/**
* 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<boolean>;
/** Inline-size override. Numbers serialize as px. */
width?: ResponsiveProp<TooltipCssLength>;
/** Minimum inline-size constraint. Numbers serialize as px. */
minWidth?: ResponsiveProp<TooltipCssLength>;
/** Maximum inline-size constraint. Numbers serialize as px. */
maxWidth?: ResponsiveProp<TooltipCssLength>;
/** Block-size override. Numbers serialize as px. */
height?: ResponsiveProp<TooltipCssLength>;
/** Minimum block-size constraint. Numbers serialize as px. */
minHeight?: ResponsiveProp<TooltipCssLength>;
/** Maximum block-size constraint. Numbers serialize as px. */
maxHeight?: ResponsiveProp<TooltipCssLength>;
/**
* Visual treatment — `'solid' | 'outline' | 'ghost'`. Maps to the
* recipe's surface tokens.

@ -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)
});
</script>

@ -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)
});
</script>

@ -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<number | undefined>(undefined),
closeDelay: state(150),
disableHoverableContent: state<boolean | undefined>(undefined),
disableCloseOnTriggerClick: state<boolean | undefined>(undefined),
disabled: state<boolean | undefined>(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<HTMLElement | null>(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');

@ -8,7 +8,6 @@ import {
} from '$libs/reactive';
import type {
OnChangeFn,
SomaKeyboardEvent,
Direction,
Arrayable,
StyleProperties
@ -40,6 +39,7 @@ interface TooltipGroupOpts {
delayDuration: Active<number>;
skipDelayDuration: Active<number>;
disableHoverableContent: Active<boolean>;
disableCloseOnTriggerClick: Active<boolean>;
disabled: Active<boolean>;
}
@ -85,6 +85,7 @@ interface TooltipOpts
delayDuration: number | undefined;
closeDelay: number;
disableHoverableContent: boolean | undefined;
disableCloseOnTriggerClick: boolean | undefined;
disabled: boolean | undefined;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
@ -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,

@ -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;

Loading…
Cancel
Save

Powered by TurnKey Linux.