fix(card): motion prop (state-domain pilot) + nested-interactive guard

- `motion` prop: the Card is the state-domain pilot. Becoming `selected` plays the
  preset once via `data-motion-state` (mount-guarded — animates on the transition,
  not on mount/deselect). `card-emerge` yields to the motion system when `motion`
  is set (otherwise toggling `data-motion-state` re-triggers the mount fade).
- Nested-interactive guard: clicking a control inside an interactive Card (a Footer
  button, a link) must not select the Card. `handleActivate`/`handleKeydown` bail
  when the event originates from an interactive descendant, and the `:active`
  press-scale is suppressed via `:not(:has(button:active, …))` — the press cascade
  reaches the ancestor `<button>`, which `stopPropagation` can't touch.
- Demo: `motion` chip control on the live Card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 9e4187bc3c
commit 50d56059d2

@ -68,8 +68,14 @@
}
/* Mount animation — fade-in + slide-up. Disabled by data-no-emerge
* (long lists) and by prefers-reduced-motion. */
[data-card]:not([data-no-emerge]) {
* (long lists) and by prefers-reduced-motion.
*
* `:not([data-animation-style])` — when the card opts into the motion system
* (the `motion` prop sets data-animation-style), THAT system owns the element's
* single `animation` property. card-emerge yields: otherwise toggling
* `data-motion-state` would flip animation-name card-emerge↔select-pop-in and
* re-trigger this mount fade on every deselect. */
[data-card]:not([data-no-emerge]):not([data-animation-style]) {
animation: card-emerge var(--card-emerge-duration) var(--card-emerge-ease) both;
}
@ -197,7 +203,13 @@
background: var(--_card-track);
}
[data-card][data-interactive]:active:not([data-disabled]) {
/* `:not(:has(…:active))` — when an INTERACTIVE descendant is being pressed (a
* Footer button, a link, a form control), the card is ALSO `:active` because the
* activation chain cascades to ancestors; without this guard pressing a control
* would press-scale the whole card. Pressing a control inside the card must not
* move the card. (Plain content presses still scale: only interactive descendants
* suppress it, so `:active` on the card's own surface is unaffected.) */
[data-card][data-interactive]:active:not([data-disabled]):not(:has(button:active, a:active, input:active, select:active, textarea:active, [role='button']:active)) {
transform: scale(var(--card-press-scale));
transition-duration: var(--card-press-duration);
}

@ -30,6 +30,7 @@
selected = false,
disabled = false,
noEmerge = false,
motion,
onSelect,
children,
...rest
@ -40,6 +41,25 @@
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedRounded = $derived(eidos.resolve(rounded, 'md'));
// State-domain motion (opt-in). `data-animation-style` selects the preset;
// `data-motion-state` drives its enter — kept SEPARATE from the semantic
// `data-state` so it never clobbers selected/idle.
const resolvedMotion = $derived(motion && motion !== 'none' ? motion : undefined);
// Play the preset only on a select TRANSITION (selected flips true post-mount),
// not on the initial mount and not on deselect (animate-on, snap-off). This also
// keeps it clear of the `card-emerge` mount animation.
let motionState = $state<'open' | undefined>(undefined);
let firstRun = true;
$effect(() => {
const isSelected = selected;
if (firstRun) {
firstRun = false;
return;
}
motionState = isSelected ? 'open' : undefined;
});
// Distinguish canonical ColorRole from arbitrary CSS color string.
// COLOR_ROLES = ['primary', 'secondary', 'neutral', 'affirm', ...]
const isCanonicalColor = $derived(
@ -50,13 +70,29 @@
const tag = $derived(interactive ? 'button' : 'div');
// A click/keypress that originates from an interactive descendant (a Footer
// action, a link, a form control) must NOT activate the card — running that
// control should not select the card. The companion CSS (`:not(:has(…:active))`)
// suppresses the press-scale for the same case; together, pressing a control
// inside the card leaves the card untouched.
function isFromNestedInteractive(event: Event): boolean {
const target = event.target as Element | null;
if (!target || target === event.currentTarget) return false;
const el = target.closest(
'button, a[href], input, select, textarea, label, [role="button"], [role="link"]'
);
return !!el && el !== event.currentTarget;
}
function handleActivate(event: MouseEvent | KeyboardEvent) {
if (!interactive || disabled) return;
if (isFromNestedInteractive(event)) return;
onSelect?.(event);
}
function handleKeydown(event: KeyboardEvent) {
if (!interactive || disabled) return;
if (isFromNestedInteractive(event)) return;
// `<button>` activates on Enter/Space natively, but when the
// consumer overrides the tag via composition we still want
// keyboard parity.
@ -80,6 +116,8 @@
data-selected={selected ? '' : undefined}
data-disabled={disabled ? '' : undefined}
data-state={selected ? 'selected' : 'idle'}
data-animation-style={resolvedMotion}
data-motion-state={resolvedMotion ? motionState : undefined}
data-no-emerge={noEmerge ? '' : undefined}
type={interactive ? 'button' : undefined}
disabled={interactive && disabled ? true : undefined}

@ -7,6 +7,7 @@ import type {
ShapeFamily,
Size
} from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type { BoxProps } from '../box/types';
/**
@ -95,6 +96,21 @@ export type CardProps = Omit<BoxProps, 'display'> & {
* @default false
*/
selected?: boolean;
/**
* State-domain motion preset for the `selected` transition. When set
* (and not `'none'`), the card plays the preset once each time it
* BECOMES selected — driven by the dedicated `data-motion-state` so it
* never clobbers the card's semantic `data-state`. Animate-on /
* snap-off: deselecting and the initial mount don't animate (composes
* with — does not replace — the recipe's `box-shadow` ring transition).
* Use an EMPHASIS preset (`'select-pop'`); a presence preset
* (`'scale-fade'`…) would flash the still-visible card from invisible.
* Setting `motion` makes the motion system own the card's single
* `animation`, so it suppresses the default mount `card-emerge` fade
* (the entrance yields to the state emphasis).
* @default undefined
*/
motion?: MotionPresetName;
/**
* Disable interaction (only meaningful when `interactive`). Adds
* `data-disabled` + `aria-disabled` and suppresses the

@ -7,6 +7,7 @@
type CardRounded
} from '$uix/eidos/components/card';
import { SHAPE_FAMILIES, type ShapeFamily } from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import { Avatar } from '$uix/eidos/components/avatar';
@ -28,10 +29,11 @@
let color = $state<CardColor>('neutral');
let rounded = $state<CardRounded>('md');
let shape = $state<ShapeFamily>('rounded');
let interactive = $state(false);
let interactive = $state(true);
let selected = $state(false);
let disabled = $state(false);
let noEmerge = $state(false);
let motion = $state<MotionPresetName>('select-pop');
let titleText = $state('Welcome aboard');
let descriptionText = $state(
'Your workspace is ready. Pick where you want to start.'
@ -56,6 +58,8 @@
'loss'
];
const roundeds: CardRounded[] = ['sm', 'md', 'lg'];
// State-domain motion: `select-pop` is the emphasis preset; `none` opts out.
const motions: MotionPresetName[] = ['none', 'select-pop'];
// ── Compiled morfo ────────────────────────────────────────────────────
const compiled = compileMorfo(cardMorfo);
@ -118,6 +122,7 @@
selected && ' selected',
disabled && ' disabled',
noEmerge && ' noEmerge',
motion !== 'none' && ` motion="${motion}"`,
interactive && ' onSelect={() => pick(id)}',
'>',
' <Card.Header>',
@ -188,6 +193,7 @@
{selected}
{disabled}
{noEmerge}
{motion}
onSelect={handleSelect}
style="max-inline-size: 28rem; inline-size: 100%;"
>
@ -341,6 +347,14 @@
<span data-uix-switch-label>{noEmerge ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>motion <span data-uix-control-hint>plays when the card becomes selected — click the card surface, not its buttons (state domain)</span></span>
<span data-uix-chips role="radiogroup">
{#each motions as m (m)}
<button data-uix-chip data-active={motion === m} onclick={() => (motion = m)}>{m}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>title</span>
<input

Loading…
Cancel
Save

Powered by TurnKey Linux.