Adds external comparison table (WAI-ARIA / React Aria / Ark UI / Base UI / Bits) with sources and a Gaps table with disposition markers, bringing the css-field audit scorecard to parity with number-field (only the systemic E-2.2 css-import rule + R-1.3 readonly warning remain, both shared with the reference component). Smoke PASS for /uix/components/css-field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>active-uix
parent
7ff1738a12
commit
9452bc2545
@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Clamped virtual anchor for the properties Popover.
|
||||||
|
*
|
||||||
|
* The panel is anchored to the ACTIVE block but must always sit on a chosen
|
||||||
|
* `side` (the main panel to the right, the appearance popover to the left). A
|
||||||
|
* full-width block fills the content column, so in a narrow window the gutter on
|
||||||
|
* that side is smaller than the panel — a plain block anchor then makes the
|
||||||
|
* Popover FLIP to the opposite side (the panel jumps over the doc sidebar). The
|
||||||
|
* user wants it to stay put ("que apareciera a la derecha").
|
||||||
|
*
|
||||||
|
* Fix: hand the Popover a VIRTUAL anchor (a floating-ui `Measurable`) whose
|
||||||
|
* rect is CLAMPED on the main axis so the panel always fits the viewport on
|
||||||
|
* `side`. When the gutter is too small the anchor slides inward, so the panel
|
||||||
|
* overlaps the content's edge instead of flipping — `flip()` never triggers
|
||||||
|
* because the placement always fits. A zero-width strip spanning the block's
|
||||||
|
* vertical extent keeps `align='center'` centred on the block's mid-height.
|
||||||
|
*
|
||||||
|
* floating-ui calls `getBoundingClientRect` on every autoUpdate cycle, so scroll
|
||||||
|
* and resize re-clamp automatically (`contextElement` = the block, the element
|
||||||
|
* autoUpdate observes for scroll parents). The rect depends only on the block +
|
||||||
|
* viewport — never on the panel — so moving it can't feed back into a resize loop.
|
||||||
|
*
|
||||||
|
* NOTE on the type: the soma floating layer accepts `Measurable` as its anchor
|
||||||
|
* (`FloatingContent.customAnchor: string | HTMLElement | null | Measurable`),
|
||||||
|
* but the public `<Popover.Content customAnchor>` prop narrows that to
|
||||||
|
* `string | HTMLElement | null`. The capability is real at runtime; consumers
|
||||||
|
* cast the result to `HTMLElement` at the call site (we don't modify soma).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Minimal floating-ui virtual-element shape (avoids a runtime floating-ui dep). */
|
||||||
|
export interface Measurable {
|
||||||
|
getBoundingClientRect(): DOMRect;
|
||||||
|
contextElement?: Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClampedAnchorOptions {
|
||||||
|
/** Which side the panel opens toward — the axis edge that gets clamped. */
|
||||||
|
side: 'left' | 'right';
|
||||||
|
/** The panel's width (must match `<Popover.Content width>`). */
|
||||||
|
panelWidth: number;
|
||||||
|
/** Gap between block and panel (must match `sideOffset`). */
|
||||||
|
gap: number;
|
||||||
|
/** Viewport edge padding to keep (match `collisionPadding`). */
|
||||||
|
padding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a clamped virtual anchor over `block`. `win` resolves the viewport width
|
||||||
|
* (pass the block's own window so it stays iframe / popup correct).
|
||||||
|
*/
|
||||||
|
export function clampedBlockAnchor(
|
||||||
|
block: HTMLElement,
|
||||||
|
win: Window,
|
||||||
|
opts: ClampedAnchorOptions
|
||||||
|
): Measurable {
|
||||||
|
return {
|
||||||
|
contextElement: block,
|
||||||
|
getBoundingClientRect() {
|
||||||
|
const b = block.getBoundingClientRect();
|
||||||
|
const vw = win.innerWidth;
|
||||||
|
let x: number;
|
||||||
|
if (opts.side === 'right') {
|
||||||
|
// panel.left = x + gap → keep panel.left + panelWidth <= vw - padding.
|
||||||
|
const maxRight = vw - opts.panelWidth - opts.gap - opts.padding;
|
||||||
|
x = Math.min(b.right, maxRight);
|
||||||
|
} else {
|
||||||
|
// panel.right = x - gap → keep panel.right - panelWidth >= padding.
|
||||||
|
const minLeft = opts.padding + opts.panelWidth + opts.gap;
|
||||||
|
x = Math.max(b.left, minLeft);
|
||||||
|
}
|
||||||
|
// Zero-width strip at the clamped x, spanning the block's height.
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y: b.top,
|
||||||
|
width: 0,
|
||||||
|
height: b.height,
|
||||||
|
top: b.top,
|
||||||
|
right: x,
|
||||||
|
bottom: b.bottom,
|
||||||
|
left: x,
|
||||||
|
toJSON() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Reusable segmented control for the palabras panel — a single-select
|
||||||
|
* `ToggleGroup` with the demo's appearance (`outline` + `attached`, `xs`).
|
||||||
|
* Each option renders as an ICON (icon-only, the label becomes the accessible
|
||||||
|
* name + tooltip) when it carries an `icon`, otherwise as its text label.
|
||||||
|
*
|
||||||
|
* Every `pills` field routes through here (alignment with icons, fit with
|
||||||
|
* text, …). Palabras-scoped on purpose: it bundles panel-specific defaults,
|
||||||
|
* it is NOT a new framework primitive — the framework `ToggleGroup` stays
|
||||||
|
* untouched and we just compose it.
|
||||||
|
*/
|
||||||
|
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
|
||||||
|
import type { PalabrasFieldOption } from './types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel,
|
||||||
|
onValueChange
|
||||||
|
}: {
|
||||||
|
/** Currently selected option value (stringified). */
|
||||||
|
value: string | undefined;
|
||||||
|
readonly options: readonly PalabrasFieldOption[];
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Fired with the picked option's stringified value, or `undefined` when the
|
||||||
|
* active item is re-clicked (deselected) — lets the field clear / restore. */
|
||||||
|
onValueChange: (value: string | undefined) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="xs"
|
||||||
|
attached
|
||||||
|
{disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
value={value == null ? [] : [value]}
|
||||||
|
onValueChange={(v) => onValueChange(v[0])}
|
||||||
|
>
|
||||||
|
{#each options as opt (opt.value)}
|
||||||
|
{@const Icon = opt.icon}
|
||||||
|
<ToggleGroup.Item
|
||||||
|
value={String(opt.value)}
|
||||||
|
iconOnly={!!Icon}
|
||||||
|
aria-label={opt.label}
|
||||||
|
title={opt.label}
|
||||||
|
>
|
||||||
|
{#if Icon}<Icon />{:else}{opt.label}{/if}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
{/each}
|
||||||
|
</ToggleGroup>
|
||||||
Loading…
Reference in new issue