- Migrate to eslint flat config + composite tsconfig. - Complete the asChild→as="template" refactor (remove asChild prop + :as-child bindings across components, matching Primitive's slot model). - Fix test type errors and source type-safety (useGraceArea hull/point math, FocusScope/util ref typing). Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on transparent wrapper components + a couple of duplicate-export naming collisions) — not gated by CI (build/lint/test green); pending a component-attribute-typing design decision.
445 KiB
@robonen/primitives vs reka-ui v2.9.7 — full comparison
Generated by a 50-primitive fan-out: each primitive compared file-by-file against its reka-ui analogue, every claimed gap re-checked by an independent adversarial verifier.
Headline
- 50 primitives compared (every exported primitive;
commandhas no reka analogue and was compared vs Combobox/Listbox). - 0 primitives are strictly better than reka today. Verdicts: 11 reka-better, 39 robonen-better-with-gaps, 0 robonen-strictly-better.
- Across all primitives the comparison raised 463 gaps; the adversarial verifier confirmed 430, downgraded 30 to partial, refuted 3 (~93% confirmed, 0.6% false-positive).
- So robonen is competitive/close everywhere but reka currently wins or ties on features in every single primitive — to hit "better in everything" each list below must be cleared.
3 refuted false-positives (ignore these): dialog content-ref forwarding (robonen has it via useForwardExpose); popper SIDE_OPTIONS/ALIGN_OPTIONS runtime export (reka doesn't export them either); popper imperative update (reka destructures but never uses it).
Ranking — most work needed first
| Primitive | Verdict | Gaps (C/M/m) | Verifier (conf/part/ref) | robonen wins |
|---|---|---|---|---|
| toast | reka better | 3/9/7 | 19/0/0 | 4 |
| date-picker | reka better | 3/5/4 | 12/0/0 | 6 |
| select | reka better | 2/9/6 | 17/0/0 | 3 |
| menu | reka better | 2/8/8 | 18/0/0 | 4 |
| tabs | reka better | 2/4/6 | 11/1/0 | 5 |
| toolbar | reka better | 1/5/7 | 12/1/0 | 4 |
| checkbox | reka better | 1/4/6 | 11/0/0 | 3 |
| toggle-group | reka better | 1/4/6 | 11/0/0 | 4 |
| number-field | reka better | 0/11/6 | 15/2/0 | 5 |
| radio-group | reka better | 0/7/9 | 15/1/0 | 5 |
| aspect-ratio | reka better | 0/4/2 | 6/0/0 | 3 |
| scroll-area | better with gaps | 2/3/7 | 12/0/0 | 7 |
| dropdown-menu | better with gaps | 1/5/6 | 11/1/0 | 3 |
| tree | better with gaps | 1/5/6 | 11/1/0 | 7 |
| popover | better with gaps | 1/4/3 | 8/0/0 | 6 |
| menubar | better with gaps | 1/3/7 | 10/1/0 | 5 |
| accordion | better with gaps | 1/3/6 | 9/1/0 | 5 |
| editable | better with gaps | 1/3/4 | 7/1/0 | 6 |
| tooltip | better with gaps | 1/3/2 | 6/0/0 | 5 |
| pin-input | better with gaps | 1/2/10 | 12/1/0 | 6 |
| collapsible | better with gaps | 1/2/5 | 8/0/0 | 5 |
| primitive | better with gaps | 1/2/5 | 6/2/0 | 5 |
| hover-card | better with gaps | 1/2/4 | 6/1/0 | 7 |
| tags-input | better with gaps | 1/2/3 | 6/0/0 | 6 |
| roving-focus | better with gaps | 1/1/1 | 3/0/0 | 5 |
| collection | better with gaps | 1/0/1 | 2/0/0 | 5 |
| dismissable-layer | better with gaps | 0/7/4 | 11/0/0 | 5 |
| navigation-menu | better with gaps | 0/7/4 | 11/0/0 | 6 |
| listbox | better with gaps | 0/6/4 | 10/0/0 | 7 |
| calendar | better with gaps | 0/5/8 | 13/0/0 | 7 |
| dialog | better with gaps | 0/5/6 | 9/1/1 | 6 |
| combobox | better with gaps | 0/4/14 | 16/2/0 | 8 |
| context-menu | better with gaps | 0/4/10 | 12/2/0 | 4 |
| slider | better with gaps | 0/4/5 | 6/3/0 | 7 |
| stepper | better with gaps | 0/4/4 | 7/1/0 | 6 |
| config-provider | better with gaps | 0/4/3 | 7/0/0 | 6 |
| avatar | better with gaps | 0/3/5 | 8/0/0 | 6 |
| visually-hidden | better with gaps | 0/3/5 | 8/0/0 | 4 |
| alert-dialog | better with gaps | 0/2/5 | 7/0/0 | 4 |
| toggle | better with gaps | 0/2/4 | 6/0/0 | 5 |
| focus-scope | better with gaps | 0/2/2 | 4/0/0 | 4 |
| progress | better with gaps | 0/2/2 | 4/0/0 | 5 |
| command | better with gaps | 0/1/12 | 12/1/0 | 6 |
| popper | better with gaps | 0/1/6 | 4/1/2 | 6 |
| label | better with gaps | 0/1/4 | 4/1/0 | 4 |
| separator | better with gaps | 0/0/5 | 4/1/0 | 4 |
| pagination | better with gaps | 0/0/4 | 3/1/0 | 6 |
| teleport | better with gaps | 0/0/2 | 0/2/0 | 6 |
| presence | better with gaps | 0/0/0 | 0/0/0 | 5 |
| switch | better with gaps | 0/0/0 | 0/0/0 | 5 |
Per-primitive detail
toast — reka-better
Parts reka has that robonen lacks: ToastPortal, ToastRootImpl (impl split from ToastRoot), ToastAnnounce (visually-hidden live region), ToastAnnounceExclude, FocusProxy (head/tail focus sentinels)
robonen's toast is a clean but heavily reduced reimplementation of Radix/reka toast. It correctly covers the basics — provider/viewport/root/title/description/action/close parts, polite/assertive aria-live, a label region, hotkey-to-focus (with two genuinely better bug fixes vs reka: empty-hotkey guard and reactive-duration timer restart, both regression-tested), and pause/resume on hover/focus. However it is missing most of the hard parts that make a toast accessible and production-grade: there is NO swipe-to-dismiss at all (the swipeDirection/swipeThreshold props are declared but never used — dead API), NO dedicated screen-reader announce region (ToastAnnounce/ToastAnnounceExclude with deferred double-rAF text harvesting), NO focus-order management or FocusProxy guards for the portaled viewport, NO Teleport/Collection so toasts can't be authored outside the viewport, NO focus return on close, NO elapsed-time-preserving pause (it restarts the full duration so hovering resets the countdown), NO remaining/duration slot props, NO forceMount/defaultOpen, NO DismissableLayer integration, NO window blur/focus pause, and a weaker Escape story (escapeKeyDown is emitted but never closes, and isFocusedToastEscapeKeyDownRef is dead). reka also has ToastPortal, type='button' on close, label {hotkey} interpolation, and author-error validation. Net: reka is currently materially better on accessibility, keyboard/focus, and features. The good news is robonen already ships the supporting primitives (collection, dismissable-layer, visually-hidden, focus-scope, teleport, presence), so every gap is implementable.
Critical gaps:
- ⛔ (features) No swipe-to-dismiss gesture support at all. reka implements full pointer swipe handling on the toast: pointerdown/pointermove/pointerup with direction clamping, threshold detection, pointer capture, and emits swipeStart/swipeMove/swipeCancel/swipeEnd, plus sets data-swipe and --reka-toast-swipe-move-x/y / --reka-toast-swipe-end-x/y CSS vars for animation.
- evidence: reka ToastRootImpl.vue pointerdown/pointermove/pointerup handlers (lines 218-280) + ToastRoot.vue swipe emit wrappers (lines 69-108) + utils.ts isDeltaInDirection/handleAndDispatchCustomEvent/SwipeEvent. robonen declares swipeDirection/swipeThreshold in ToastProvider but NEVER consumes them — there is zero pointer/swipe handling anywhere in robonen/toast. The props are dead.
- ⛔ (accessibility) No screen-reader announce region. reka renders a separate visually-hidden ToastAnnounce live region with double-rAF deferred text injection (to force NVDA to announce) and harvests toast text via getAnnounceTextContent, honoring aria-hidden/hidden/display:none and ToastAnnounceExclude.
- evidence: reka ToastAnnounce.vue (double requestAnimationFrame + useTimeout(1000)), ToastRootImpl.vue lines 120,181-200, utils.ts getAnnounceTextContent. robonen instead puts role=status + aria-live directly on the visible toast li (ToastRoot.vue lines 129-131) with no deferred-render trick and no separate hidden announce node, so NVDA/VoiceOver announcement reliability is worse and there is no text-harvesting for SR.
- ⛔ (keyboard) No focus-order management / tab reversal across toasts and no FocusProxy guards. reka programmatically reverses tab order (newest-to-oldest), uses head/tail FocusProxy VisuallyHidden sentinels to proxy focus out of the portaled viewport back into document order, and on Tab navigates between toast tabbable candidates.
- evidence: reka ToastViewport.vue handleKeyDown (lines 97-128), getSortedTabbableCandidates (lines 150-161), FocusProxy.vue, focusFirst/getTabbableCandidates imports. robonen ToastViewport.vue has NO keydown/Tab handling and no focus proxies at all; tabbing through portaled toasts will follow DOM order and can escape the viewport unexpectedly.
Major gaps:
- 🔶 (accessibility) No ToastAnnounceExclude part and no altText harvesting on ToastAction. reka's ToastAction wraps ToastClose in ToastAnnounceExclude so the action's altText is read by SR (and the button's own visible text is excluded). robonen's ToastAction only sets aria-label=altText on the button and provides no exclude mechanism, so action content cannot be excluded from / substituted in the SR announcement.
- 🔶 (features) Toast is not teleported into the viewport and there is no Collection registration. reka teleports each ToastRootImpl into providerContext.viewport via and wraps it in CollectionItem/CollectionSlot so toasts authored anywhere in the tree render inside the viewport and are enumerable for focus ordering.
- 🔶 (accessibility) On programmatic/keyboard close, focus is not returned to the viewport. reka's handleClose focuses the viewport when the toast being closed contained focus (so SR users keep context and focus isn't lost), and resets isClosePausedRef.
- 🔶 (api-surface) No remaining-time / duration slot props. reka exposes a default slot with { open, remaining, duration } driven by a useRafFn countdown, enabling progress bars/countdowns. robonen's ToastRoot default slot is empty with no scope.
- 🔶 (api-surface) No forceMount prop and no defaultOpen prop. reka ToastRoot supports forceMount (force render for animation libs) and defaultOpen (uncontrolled initial state distinct from the v-model). robonen only has defineModel('open', {default:true}) with no forceMount and no separate defaultOpen.
- 🔶 (features) No DismissableLayer integration on the viewport. reka wraps the viewport in DismissableLayerBranch so toasts participate in the global dismissable-layer stack (correct Escape/outside-interaction layering with dialogs/popovers). robonen's viewport is a plain Primitive region with no layer participation.
- 🔶 (edge-cases) No window blur/focus pause-resume and no pointermove-based pause. reka pauses the dismiss timer on window blur and resumes on window focus, and uses pointermove (not just pointerenter) plus pointerleave-with-active-element checks. robonen only pauses on pointerenter/focusin and resumes on pointerleave/focusout.
- 🔶 (edge-cases) Pause does not preserve elapsed/remaining time; timer restarts from full duration on resume. reka tracks closeTimerStartTimeRef/closeTimerRemainingTimeRef and on resume continues from the remaining time. robonen's resumeTimer calls startTimer(full duration) again, so hovering repeatedly can keep a toast alive far longer than its duration and the countdown effectively resets.
- 🔶 (keyboard) Escape handling differs and is weaker. reka uses onKeyStroke('Escape') globally on the root, sets isFocusedToastEscapeKeyDownRef, and closes unless event.defaultPrevented. robonen only emits escapeKeyDown on @keydown.escape bound to the toast element (must be focused), never closes on Escape, and never sets isFocusedToastEscapeKeyDownRef (the provider ref exists but is dead).
Minor gaps (7):
- ▫️ (api-surface) No disableSwipe prop. reka ToastProvider exposes disableSwipe to turn off swipe gestures (and adjusts userSelect/touchAction styles). robonen has no equivalent (and no swipe at all).
- ▫️ (accessibility) ToastViewport label loses the {hotkey} placeholder feature and the function-form label. reka label supports '{hotkey}' interpolation (auto-replaced, e.g. 'Notifications (F8)') and a (hotkey)=>string function form, improving SR landmark context. robonen viewport label is a plain string with no hotkey interpolation.
- ▫️ (api-surface) No ToastPortal part. reka exports a ToastPortal (TeleportPrimitive wrapper) for placing the viewport in an arbitrary container. robonen exports no Portal.
- ▫️ (code-quality) ToastProvider does not validate a non-empty label and does not validate type. reka throws on empty/whitespace label and on invalid type values, catching author mistakes early. robonen performs no such validation.
- ▫️ (forms) ToastClose does not set type='button'. reka's ToastClose sets type='button' when as==='button' to avoid implicit form submit. robonen ToastClose renders a button with no explicit type.
- ▫️ (accessibility) ToastRoot default type differs and may be less appropriate. reka defaults type='foreground' (ToastRoot.vue line 28) for user-action toasts (assertive). robonen defaults type='background' (ToastRoot.vue line 32, polite). A behavioral/aria-live divergence worth a deliberate decision.
- ▫️ (code-quality) Components do not set inheritAttrs:false where reka does, which blocks precise attribute placement around wrappers (FocusProxy/DismissableLayerBranch/Teleport). reka uses defineOptions({inheritAttrs:false}) + v-bind="$attrs".
Recommendations to make robonen strictly better:
- Implement swipe-to-dismiss: port reka's pointerdown/pointermove/pointerup logic and isDeltaInDirection/handleAndDispatchCustomEvent into ToastRoot, add swipeStart/swipeMove/swipeCancel/swipeEnd emits, set data-swipe + --primitives-toast-swipe-move-x/y / -end-x/y CSS vars, and actually consume the existing swipeDirection/swipeThreshold provider props (currently dead). Add a disableSwipe provider prop.
- Add a dedicated screen-reader announce region: create a ToastAnnounce visually-hidden component (robonen already ships a visually-hidden primitive) that defers text injection with double requestAnimationFrame, and a getAnnounceTextContent util that walks the toast harvesting text while honoring aria-hidden/hidden/display:none and a new ToastAnnounceExclude (data-primitives-toast-announce-exclude / -alt). Move aria-live off the visible li into this hidden region.
- Add ToastAnnounceExclude and wire ToastAction to wrap its button in it (passing altText) so SR reads altText instead of the button label, matching reka.
- Teleport each ToastRoot into the provider viewport and integrate the existing collection primitive (useCollection isProvider on provider, CollectionItem on root, CollectionSlot on viewport) so toasts can be authored anywhere and are enumerable.
- Implement viewport focus management: add a keydown Tab handler that reverses tab order newest-to-oldest using collection items + getTabbableCandidates, and add head/tail FocusProxy sentinels (VisuallyHidden tabindex=0) to proxy focus out of the portaled viewport. Export focusFirst/getTabbableCandidates from focus-scope for reuse.
- On keyboard close, focus the viewport when the closing toast contained focus, and reset isClosePausedRef (currently isFocusedToastEscapeKeyDownRef and the focus-return behavior are missing).
- Preserve elapsed time across pause/resume: track closeTimerStartTime and closeTimerRemainingTime and resume from remaining (not full duration). Add a useRafFn-based remaining countdown and expose { open, remaining, duration } as ToastRoot slot props.
- Add forceMount and defaultOpen props to ToastRoot (Presence :present="forceMount || open"; defaultOpen seeds the uncontrolled model).
- Wrap ToastViewport in a DismissableLayerBranch (add a Branch export to the dismissable-layer primitive) so toasts join the dismissable-layer stack; add window blur/focus pause-resume and pointermove-based pause to the viewport.
- Implement global Escape handling on ToastRoot via a keydown listener: emit escapeKeyDown, set isFocusedToastEscapeKeyDownRef, and close unless defaultPrevented.
- Add a ToastPortal part (Teleport wrapper) and support {hotkey} placeholder + function-form label on ToastViewport.
- Add author-error validation (non-empty label in ToastProvider, valid type in ToastRoot), set type='button' on ToastClose when as==='button', and reconsider defaulting ToastRoot type to 'foreground' to match the common user-action semantics.
Where robonen is already better:
- ✅ ToastViewport hotkey matching is more correct than reka's: robonen guards against an empty hotkey array (ToastViewport.vue line 47,
if (!hotkey || hotkey.length === 0) return) and supports modifier-key tokens ('altKey'/'ctrlKey'/'shiftKey'/'metaKey') in the hotkey array. reka'sonKeyStroke(hotkey.value, ...)(ToastViewport.vue line 55) is registered once with the initial value (not reactive to hotkey changes) and an empty array would match every keystroke. robonen has explicit regression tests for both bugs. - ✅ ToastRoot restarts the auto-dismiss timer reactively when
duration(root or provider) changes while open and not paused (ToastRoot.vue watch lines 83-91), backed by a dedicated regression test. reka resets closeTimerRemainingTimeRef on duration change (ToastRootImpl.vue lines 154-160) but robonen's restart behavior here is explicitly tested. - ✅ robonen normalizes the disable case more defensively in startTimer:
if (ms === Infinity || ms <= 0 || !Number.isFinite(ms)) return(ToastRoot.vue line 57) also handles NaN, whereas reka only checksduration <= 0 || duration === Number.POSITIVE_INFINITY(ToastRootImpl.vue line 90). - ✅ robonen's context layer uses a typed useContextFactory and shallowRef for the viewport element (ToastProvider.vue line 34), marginally more efficient for a DOM-node ref than reka's plain ref (ToastProvider.vue line 71).
date-picker — reka-better
Parts reka has that robonen lacks: DatePickerField backed by a true segmented DateFieldRoot (role=group, segments slot, VisuallyHidden focusable form input), DatePickerInput as an individual editable segment part (role=spinbutton, part: SegmentPart) — robonen's DatePickerInput is just an alias of the plain DatePickerField Parts robonen has that reka lacks: DatePickerCalendar wrapper component (reka has DatePickerCalendar too, but robonen's is a thin Primitive div; functionally parity), DatePickerField with editable/format/placeholderText plain-input ergonomics (no reka equivalent because reka is always segmented), valueFormat serializer on Root
Both libraries structure DatePicker as a composition over Popover + Calendar + a Field, and robonen has real parity (or better) on the calendar grid: CalendarCellTrigger.vue implements full RTL-aware Arrow/Home/End/PageUp/PageDown(+Shift for year)/Enter/Space keyboard nav, roving tabindex, data-* states, initialFocus, and aria-selected/aria-disabled — matching reka's CalendarCellTrigger. robonen also wins on smaller surface (native Date, no @internationalized/date dependency), a valueFormat hidden-input serializer, and a friendlier closeOnSelect=true default. However, the verdict is reka-better because reka's DatePicker centers on a segmented, editable DateField (DateFieldRoot/DateFieldInput) that robonen completely lacks: robonen's DatePickerField is a single plain doing new Date(text) parsing. That single architectural gap cascades into many missing features: per-segment spinbutton keyboard editing (Arrow increment, numeric type-ahead with auto-advance, Backspace, a/p for AM/PM), RTL-aware inter-segment Left/Right navigation, time support (granularity/hourCycle/step/hideTimeZone), segment-level ARIA (role=group, role=spinbutton, aria-valuemin/max/now/valuetext). Additionally reka has preventDeselect toggle-off, a focusable VisuallyHidden native input enabling real form constraint validation (required/min/max) where robonen uses a non-validatable display:none input, required/id forwarding, a trigger that honors disabled (robonen's does not), and DatePickerContent that auto-portals with a portal prop (robonen requires manual DatePickerPortal). To become strictly better, robonen must implement a segmented DateField with full segment keyboard + ARIA, add time/granularity support, preventDeselect, a focusable hidden form input with required, disabled trigger forwarding, and content auto-portalling — then it would surpass reka given its lighter dependency profile and better defaults. Relevant files: /Users/robonen/Projects/tools/vue/primitives/src/date-picker/DatePickerRoot.vue, /Users/robonen/Projects/tools/vue/primitives/src/date-picker/DatePickerField.vue, /Users/robonen/Projects/tools/vue/primitives/src/date-picker/DatePickerTrigger.vue, /Users/robonen/Projects/tools/vue/primitives/src/date-picker/DatePickerContent.vue, /Users/robonen/Projects/tools/vue/primitives/src/calendar/CalendarCellTrigger.vue; reka: /Users/robonen/Projects/external/reka-ui/packages/core/src/DatePicker/DatePickerRoot.vue, /Users/robonen/Projects/external/reka-ui/packages/core/src/DateField/DateFieldRoot.vue, /Users/robonen/Projects/external/reka-ui/packages/core/src/DateField/DateFieldInput.vue, /Users/robonen/Projects/external/reka-ui/packages/core/src/shared/date/useDateField.ts, /Users/robonen/Projects/external/reka-ui/packages/core/src/DatePicker/DatePickerContent.vue.
Critical gaps:
- ⛔ (features) Segmented, editable date field. reka's DatePickerField renders a DateFieldRoot (role=group) whose DatePickerInput parts (DateFieldInput.vue, part: SegmentPart) are individually-focusable contenteditable segments (day/month/year/hour/minute/second/dayPeriod/literal) with role=spinbutton. robonen's DatePickerField is a single plain that does new Date(text) parsing (DatePickerField.vue:45-47). robonen has no per-segment editing, no SegmentPart model, no segments slot, no DateFieldRoot/DateFieldInput.
- evidence: reka DateFieldRoot.vue (segmentValues/segmentContents, role=group) + DateFieldInput.vue (data-reka-date-field-segment, role=spinbutton via useDateField commonSegmentAttrs); robonen DatePickerField.vue is a single with no segment support.
- ⛔ (keyboard) Per-segment keyboard interaction. reka's useDateField.ts handles ArrowUp/ArrowDown to increment/decrement each segment, numeric type-ahead with auto-advance to next segment (focusNext), Backspace to clear, and 'a'/'p' to set AM/PM (handleDayPeriodSegmentKeydown:846-871). robonen's field only handles Enter to commit a full parsed string (DatePickerField.vue:52-54) — no arrow increment, no type-ahead, no backspace-per-segment, no AM/PM keys.
- evidence: reka useDateField.ts handleDay/Month/Year/Hour/Minute/Second/DayPeriodSegmentKeydown (lines 635-871) vs robonen DatePickerField.vue handleKeydown only checking e.key === 'Enter'.
- ⛔ (features) Time / granularity support. reka exposes granularity ('day'|'hour'|'minute'|'second'), hourCycle (12/24), step (DateStep), and hideTimeZone on DatePickerRootProps (via DateFieldRootProps) and renders time segments accordingly (DatePickerRoot.vue:104-112 destructures granularity/hideTimeZone/hourCycle/step; DatePickerGranular.story.vue). robonen's DatePickerRoot has NO granularity/hourCycle/step/hideTimeZone props — it is date-only (toDateOnly strips time everywhere, DatePickerRoot.vue:105,183,198).
- evidence: reka DatePickerRoot.vue context fields hourCycle/granularity/hideTimeZone/step (lines 21-23,44) + DateFieldRoot.vue inferredGranularity/hasTime; robonen DatePickerRoot.vue exposes no time props and calls toDateOnly throughout.
Major gaps:
- 🔶 (rtl-i18n) RTL-aware segment navigation with ArrowLeft/ArrowRight between field segments. reka DateFieldRoot.vue handleKeydown (253-260) moves focus to prev/next focusable segment, with sign flipped for dir==='rtl' (nextFocusableSegment/prevFocusableSegment:227-244). robonen's plain field has no inter-segment Left/Right navigation at all (single input). (robonen's calendar grid is RTL-aware, but the field is not.)
- 🔶 (features) preventDeselect / toggle-off behavior. reka's onDateChange (DatePickerRoot.vue:180-190) deselects (sets modelValue=undefined) when the already-selected date is clicked again unless preventDeselect is set; preventDeselect is a real prop. robonen's setDate (DatePickerRoot.vue:180-185) always sets the date and never deselects on re-click, and there is no preventDeselect prop.
- 🔶 (forms) Form constraint validation via a focusable native input. reka renders a VisuallyHidden as="input" with feature="focusable", real type (date/datetime-local), :value, :min, :max, :required (DateFieldRoot.vue:310-323), so browser-level required/min/max validation and form participation work, and focusing it forwards focus to the first segment. robonen's hidden input is type=hidden with style="display:none" (DatePickerRoot.vue:322-331) — no required, no min/max, not focusable, and display:none inputs are excluded from constraint validation.
- 🔶 (edge-cases) Trigger does not honor disabled. reka DatePickerTrigger binds :disabled="rootContext.disabled.value" (DatePickerTrigger.vue:20) so the open button is disabled with the picker. robonen DatePickerTrigger.vue never reads ctx disabled — the trigger stays clickable and ctx.onOpenToggle fires even when the picker is disabled (no disabled binding, no guard).
- 🔶 (accessibility) Field-level ARIA group/state semantics. reka DateFieldRoot sets role="group", aria-disabled, data-readonly, data-invalid (DateFieldRoot.vue:294-301) and each segment carries role=spinbutton, aria-valuemin/max/now/valuetext and aria-label (useDateField commonSegmentAttrs + per-part attrs:48-189). robonen's field is a bare with only data-primitives-date-picker-field and no aria-invalid/aria-disabled/group semantics.
Minor gaps (4):
- ▫️ (forms) required prop and id forwarding. reka DatePickerRoot forwards required and id into the field/native input (DatePickerRoot.vue:103,170,173; DateFieldRoot props required/id). robonen DatePickerRootProps has name but no required and no user-supplied id prop for the field/form control.
- ▫️ (features) Content auto-portals and exposes a portal prop. reka DatePickerContent wraps children in PopoverPortal automatically and accepts portal?: PopoverPortalProps (DatePickerContent.vue:7-12,28). robonen DatePickerContent.vue does NOT portal — it renders inline; consumers must manually add DatePickerPortal around it, and there is no portal prop on Content.
- ▫️ (edge-cases) closeOnSelect is reactive in reka via a watcher on modelValue (DatePickerRoot.vue:143-150) and is provided as a Ref in context, so changes apply consistently from any selection source (field or calendar). robonen only closes inside setDate (DatePickerRoot.vue:184) which is the calendar path; selecting/committing via the editable DatePickerField sets ctx.modelValue directly (DatePickerField.vue:47) and does NOT trigger closeOnSelect.
- ▫️ (rtl-i18n) Locale-change segment re-ordering. reka re-collects segment elements on locale change so Left/Right order matches the new locale (DateFieldRoot.vue:192-202). robonen has no segments, so changing locale only reformats the single input string — a non-issue for plain input but a real capability gap if segmented editing is added.
Recommendations to make robonen strictly better:
- Build a segmented DateField (DateFieldRoot + DateFieldInput with a SegmentPart model) and have DatePickerField/DatePickerInput render it. Each segment should be role=spinbutton, contenteditable, with aria-valuemin/valuemax/valuenow/valuetext and aria-label, replacing the single plain in DatePickerField.vue. This is the central gap.
- Add per-segment keyboard handling to the field: ArrowUp/ArrowDown to increment/decrement, numeric type-ahead with auto-advance to the next segment, Backspace to clear a segment, and 'a'/'p' for AM/PM, plus RTL-aware ArrowLeft/ArrowRight to move between segments (mirror reka useDateField.ts and DateFieldRoot.vue handleKeydown).
- Add time support: granularity ('day'|'hour'|'minute'|'second'), hourCycle (12/24), step, and hideTimeZone props on DatePickerRoot; stop unconditionally calling toDateOnly so time-of-day can be preserved and rendered as hour/minute/second/dayPeriod segments.
- Add preventDeselect prop and make setDate deselect (set modelValue=undefined) when the currently-selected date is re-selected, unless preventDeselect is true (match reka onDateChange).
- Replace the display:none hidden input with a focusable VisuallyHidden native input (type date/datetime-local, with :value/:min/:max/:required), so the control participates in browser constraint validation and focusing it forwards focus into the field. Add required and id props to DatePickerRootProps.
- Forward root disabled to DatePickerTrigger (bind :disabled and guard onClick/onOpenToggle) so a disabled picker cannot be opened.
- Make DatePickerContent auto-wrap its children in DatePickerPortal and accept a portal?: prop (like reka DatePickerContent.portal), so portalling is the default and DatePickerPortal becomes opt-out rather than required boilerplate.
- Make closeOnSelect fire for ALL selection sources by watching modelValue at the Root (like reka) instead of only inside setDate, so committing via the field also closes the popover.
- Add field-level ARIA: role=group, aria-disabled, aria-readonly, data-invalid/data-readonly/data-disabled on the field wrapper, and aria-invalid wiring driven by the existing isInvalid computed.
Where robonen is already better:
- ✅ Zero-dependency value model: robonen uses native JS Date throughout (DatePickerRoot.vue modelValue: Date | undefined) whereas reka hard-depends on @internationalized/date (DateValue, .compare/.copy/.set). Smaller bundle and no extra peer dep.
- ✅ Custom hidden-input serialization: robonen exposes valueFormat?: 'iso' | ((d: Date) => string) (DatePickerRoot.vue:13-14, hiddenValue computed:256-260) for controlling the submitted string. reka has no equivalent serializer hook — it relies on the VisuallyHidden native input's normalized value only.
- ✅ Better default UX for closeOnSelect: robonen defaults closeOnSelect = true (DatePickerRoot.vue:16,57) so the popover closes after picking a date; reka defaults closeOnSelect: false (DatePickerRoot.vue:85), requiring opt-in.
- ✅ DatePickerField offers a simple plain-input mode with ergonomic props editable/format/placeholderText (DatePickerField.vue:4-11) plus Enter/blur commit — lower barrier for the common 'one text input' use case than reka's mandatory multi-segment composition.
- ✅ modal popover support is wired end-to-end in robonen (DatePickerContent.vue FocusScope :trapped=ctx.modal, DismissableLayer :disable-outside-pointer-events=ctx.modal) and surfaced as a first-class DatePicker concern; reka delegates entirely to PopoverRoot.
- ✅ Explicit isInvalid is computed and provided to the calendar context (DatePickerRoot.vue:175-178), giving day cells/styling access to validity, in addition to per-field checks.
select — reka-better
Parts reka has that robonen lacks: BubbleSelect.vue (native VisuallyHidden for real form submission, multiple, autofill, native change bubbling), SelectProvider.vue (re-provides root+default content context so items can mount in a detached DocumentFragment while closed) robonen's select is a structurally complete single-value port (all the same visible parts exist: Root/Trigger/Value/Icon/Portal/Content/Viewport/ScrollButtons/Group/Label/Item/ItemText/ItemIndicator/Separator/Arrow plus item-aligned and popper positioners), but it is materially behind reka on substance. Critical gaps: it supports only string values with no multiple/by/object support; and its SelectValue cannot render the initially-selected label until the dropdown is opened once, because items unmount when closed (reka renders them in a detached fragment via SelectProvider). Major gaps: it never focuses the selected item on open; its item-aligned positioning is a thin stub missing the entire Radix centering/collision/expand-on-scroll/RTL/resize algorithm; typeahead is primitive (no cycling/repeated-char/wrap) and absent on the closed trigger; it omits useHideOthers/useFocusGuards (both available in-repo); it has no native form bubbling (only a plain hidden input, so no autofill, no multiple, no object values); ARIA is mis-wired (role=listbox on the viewport not the content, content id missing so trigger aria-controls dangles, items lack aria-labelledby/data-highlighted/select event). Minor gaps include no Tab/contextmenu prevention, no viewport nonce/scrollbar hiding, an always-rendered arrow, an unset content-available-height CSS var (broken viewport sizing), no forceMount, no SelectValue slot, and missing touch/pointer hardening. robonen's only genuine edges are a smoother rAF auto-scroll and a slightly broader set of exported per-part types. Verdict: reka-better; substantial work is required for robonen to reach, let alone exceed, parity.
Critical gaps:
- ⛔ (features) robonen only supports a single string value (export type SelectValue = string; modelValue typed string|undefined). reka supports any value type (AcceptableValue: string|number|boolean|object|null) AND multiple selection via the
multipleprop, with array model values, by-field/by-fn object comparison, and toggle-on-reselect logic.- evidence: reka SelectRoot.vue: props
multiple,by,modelValue?: T|Array<T>,defaultValue?: T|Array<T>, handleValueChange() toggles array membership and keeps open when multiple; isEmptyModelValue computed; utils.ts valueComparator/compare. robonen context.tsSelectValue = string, SelectRoot has no multiple/by, handleValueChange always closes and assigns scalar.
- evidence: reka SelectRoot.vue: props
- ⛔ (edge-cases) robonen's SelectValue cannot show the initially-selected option's label until the dropdown has been opened at least once, because items only register into optionsSet when SelectItemText mounts, and the items are unmounted by
Presence :present=openwhile closed.- evidence: robonen SelectContent.vue wraps SelectContentImpl (items) in
<Presence :present="rootCtx.open.value">; SelectItem registers via onMounted/onItemTextChange only. reka SelectContent.vue renders a<Teleport :to="fragment">with SelectProvider when closed so SelectItemText still mounts in a detached DocumentFragment and registers options, so SelectValue shows the correct label on first paint.
- evidence: robonen SelectContent.vue wraps SelectContentImpl (items) in
Major gaps:
- 🔶 (accessibility) robonen never focuses the currently selected item when the listbox opens; it tracks selectedItemRef but no code calls .focus() on it after positioning. So opening a select with an existing value lands focus on the content container, not the selected option.
- 🔶 (features) robonen's item-aligned positioning is a stub: it only pins content to triggerRect.bottom/left with a fixed minWidth. It omits the entire Radix MacOS-style algorithm: vertical centering of the selected item against the trigger, collision clamping to viewport (CONTENT_MARGIN), min/max height computation, dir=rtl horizontal math, expand-on-scroll, ResizeObserver reposition, and content-wrapper z-index copy.
- 🔶 (keyboard) robonen offers no type-to-select on the closed trigger; pressing letter keys on the trigger does nothing. reka runs typeahead on the trigger so you can select an option without opening.
- 🔶 (keyboard) robonen's typeahead matching is primitive: it always picks the first item whose text startsWith the buffer from the top of the list, with no forward-cycling from the current item, no repeated-character cycling, and no wrap-around. So pressing the same letter repeatedly cannot cycle through same-letter options.
- 🔶 (accessibility) robonen does not isolate the rest of the page from assistive tech while the listbox is open (no aria-hidden on siblings) and installs no focus guards, so screen-reader users and Tab can escape past the listbox boundary inconsistently.
- 🔶 (forms) robonen does not provide native form submission for arbitrary/multiple values. It renders a single hidden that stringifies value, so non-string values and multiple selections cannot be submitted, autofill cannot work, and no native change event bubbles.
- 🔶 (accessibility) robonen's SelectItem does not associate the option with its label for AT and lacks a highlighted-state hook; it also has no cancelable select event or empty-value guard.
- 🔶 (accessibility) robonen's listbox role wiring is on the wrong element and inconsistent with reka. Content has no role; the viewport carries role=listbox. reka puts role=listbox on the content element and role=presentation on the viewport, which is the correct mapping for option grouping/labelling.
- 🔶 (api-surface) robonen's SelectValue has no slot and cannot render selected label(s) for object/multiple values; it renders a single persisted displayValue string only and has no data-placeholder attribute.
Minor gaps (6):
- ▫️ (keyboard) robonen's content keydown handler does not prevent Tab from moving focus out of the open listbox, and does not block contextmenu, so Tab can break the roving-focus model inside the listbox.
- ▫️ (features) robonen's SelectViewport lacks a
nonceprop and does not hide scrollbars / enable touch momentum scrolling, hurting CSP support and visual polish. - ▫️ (edge-cases) robonen's SelectArrow renders regardless of position mode, so an arrow can appear in item-aligned mode where it is meaningless; and SelectPopperPosition does not map popper CSS vars, so the viewport's max-height var is never set.
- ▫️ (features) robonen's SelectContent has no forceMount option for externally controlled animation libraries, and does not implement the delayed-render-presence workaround for state-based exit transitions.
- ▫️ (edge-cases) robonen's pointer handling lacks touch-device support and the open/select race protections reka added for issue #804: it opens on pointerdown for all pointer types and has no pointer-move-threshold guard before selecting on pointerup.
- ▫️ (code-quality) robonen's data hooks differ from reka's conventional attributes and it does not use a Collection abstraction, reducing robustness of keyboard nav and scroll-into-view ordering.
Recommendations to make robonen strictly better:
- Generalize the value model: change SelectValue from
stringto an AcceptableValue union and addmultiple,by, array model/defaultValue support in SelectRoot, mirroring reka's handleValueChange toggle logic and valueComparator/compare utils. Keep open on select when multiple. - Fix initial label display: render SelectContent children into a detached DocumentFragment (or forceMount) while closed, like reka's SelectProvider/Teleport branch, so SelectItemText registers options and SelectValue shows the selected label before the dropdown is ever opened.
- Focus the selected item on open: add
watch(isPositioned, () => focusFirst([selectedItemRef.value, contentEl]))in SelectContentImpl, and re-run it after the scroll-up button mounts in item-aligned mode. - Port the full Radix item-aligned positioning algorithm into SelectItemAlignedPosition (vertical centering on selected item, CONTENT_MARGIN collision clamping, min/max height, dir=rtl math, expand-on-scroll via a context, useResizeObserver, z-index copy).
- Add typeahead on the closed Trigger and replace the naive startsWith matcher with a cycling matcher (wrapArray + getNextMatch: forward from current item, repeated-char cycling, single-char exclusion, wrap-around). Reuse a shared useTypeahead.
- Wire ARIA correctly: put role=listbox + the content id on the content element (so trigger aria-controls resolves), set viewport role=presentation, add aria-labelledby=textId and data-highlighted to SelectItem, and add a cancelable
selectemit plus an empty-string value guard. - Add native form support via a VisuallyHidden (BubbleSelect equivalent) gated by useFormControl: support multiple, options from the registry, autofill @input, native change dispatch through the value setter, and a nativeSelectKey for re-render. Use the already-present useHideOthers(content) and add focus guards in SelectContentImpl; prevent Tab and contextmenu inside the open listbox. Add a nonce prop to SelectViewport and inject a scrollbar-hiding style tag with touch momentum; only render SelectArrow when position==='popper'; map popper CSS vars in SelectPopperPosition (---content-available-height, ---trigger-width/height) and align the viewport max-height var name so collision-aware sizing actually works. Add forceMount to SelectContent and a delayed renderPresence to keep state-based exit transitions accurate; add a slot to SelectValue exposing { selectedLabel, modelValue } and a data-placeholder attribute. Adopt touch/pointer hardening from reka issue #804: skip opening on touch pointerdown (open on pointerup), release implicit pointer capture, track trigger pointer-down position, and add a pointer-move threshold before selecting on pointerup. Where robonen is already better: ✅ robonen's SelectScrollButtonImpl uses requestAnimationFrame for smooth auto-scroll on pointerenter/pointerleave, whereas reka uses a setInterval(50ms) timer driven by an autoScroll emit; rAF is generally smoother and self-throttling (SelectScrollButtonImpl.vue startAutoScroll vs reka handlePointerDown/handlePointerMove setInterval). ✅ robonen explicitly exposes per-component prop/emit types from index.ts for nearly every part (SelectContentImplProps/Emits, SelectItemAlignedPositionProps/Emits, SelectPopperPositionProps/Emits, SelectScrollUpButtonProps, etc.), giving a broader public typed surface than reka's index.ts which omits several (e.g. it does not export SelectContentImpl, SelectItemAlignedPosition, SelectPopperPosition, SelectViewport types/components at all). ✅ robonen's SelectScrollUpButton/SelectScrollDownButton compute can-scroll state both on mount and via a passive scroll listener with proper cleanup, and the down-button uses a +1px tolerance (scrollHeight - scrollTop > clientHeight + 1) to avoid sub-pixel flicker; this is a small robustness edge reka's button does not special-case. menu — reka-better robonen's Menu mirrors reka-ui's full part set (Root, Anchor, Arrow, Portal, Content + modal/nonmodal Impl, Item/ItemImpl, CheckboxItem, RadioGroup/RadioItem, ItemIndicator, Group, Label, Separator, Sub/SubTrigger/SubContent) and matches it on Anchor/Arrow/Portal/Separator/ItemIndicator and basic ARIA roles, modal isolation (useHideOthers + useBodyScrollLock + useFocusGuard present in MenuRootContentModal), and roving-tabindex navigation (Arrow/Home/End/PageUp/PageDown/RTL/loop via RovingFocus). It is genuinely better in one area: MenuItem dispatches a real bubbling ITEM_SELECT CustomEvent on the DOM, and uses preventScroll focusing. However it is currently NOT strictly better. The submenu pointer grace-area is dead code (provided in context but never invoked, and MenuSub never tracks its content element), so diagonal mouse movement to a submenu closes it; there is no pointer-direction tracking; ArrowLeft/close-key cannot return focus from an open submenu (MenuSubContent has no keydown/open-auto-focus handlers); the content container does not focus first/last on FIRST_LAST keys; Tab is not trapped; Space selects during type-ahead; pointer-up drag-select and the Firefox text-selection workaround are missing; Group/Label aria-labelledby wiring is broken (MenuLabel never injects the group context); submenus don't auto-close with the parent and don't support uncontrolled open; and Root/Sub are controlled-only so they won't open without an explicit v-model. Verdict: reka-better until the submenu grace-area, sub-content close-key handling, FIRST_LAST/Tab content handling, group/label wiring, and controllable open are implemented. Critical gaps: ⛔ (edge-cases) Submenu pointer 'grace area' (safe diagonal triangle to the open submenu) is completely non-functional in robonen. reka's MenuSubTrigger.handlePointerLeave builds a 5-point polygon from menuContext.content.value.getBoundingClientRect() plus a directional bleed and calls contentContext.onPointerGraceIntentChange({area, side}) with a 300ms pointerGraceTimerRef reset. robonen's MenuSubTrigger.handlePointerLeave does none of this — it only calls onTriggerLeave then close(). onPointerGraceIntentChange/pointerGraceTimerRef are provided in robonen's content context but NEVER called by any component, so pointerGraceIntentRef is always null and onItemEnter/onItemLeave/onTriggerLeave grace checks are dead code. Result: moving the mouse diagonally from a sub-trigger toward its open submenu closes the submenu, the classic menu UX bug reka fixes. evidence: reka MenuSubTrigger.vue handlePointerLeave (contentRect polygon + onPointerGraceIntentChange + pointerGraceTimerRef 300ms) vs robonen MenuSubTrigger.vue handlePointerLeave (only close()); robonen onPointerGraceIntentChange has zero callers. ⛔ (keyboard) Closing a submenu with the close key (ArrowLeft in LTR / ArrowRight in RTL) from inside the open sub-content is not handled. reka's MenuSubContent has a @keydown that on SUB_CLOSE_KEYS calls menuContext.onOpenChange(false) and refocuses the trigger (trigger.focus + scrollIntoView), and an @open-auto-focus that focuses sub-content for keyboard users. robonen's MenuSubContent has NO keydown/open-auto-focus handlers at all — close-key handling only exists on the SubTrigger, so once focus is inside the submenu, ArrowLeft cannot return to the parent. evidence: reka MenuSubContent.vue @keydown (SUB_CLOSE_KEYS -> onOpenChange(false) + trigger.focus) and @open-auto-focus.prevent; robonen MenuSubContent.vue has no @keydown and no @open-auto-focus. Major gaps: 🔶 (edge-cases) Pointer-direction tracking is missing. reka's MenuContentImpl tracks pointerDirRef/lastPointerXRef via handlePointerMove and isPointerMovingToSubmenu(event) requires BOTH the cursor be inside the grace area AND moving toward the submenu side. robonen has no pointermove handler on PopperContent and no pointer-direction state, so even if a grace area existed it could not gate on movement direction. 🔶 (keyboard) When the menu content container itself is focused (e.g. opened via mouse with no highlighted item), pressing a FIRST_LAST key does not move focus to the first/last item. reka's handleKeyDown does if (event.target === contentElement && FIRST_LAST_KEYS.includes(key)) { event.preventDefault(); focusFirst(candidateNodes) } (reversing for LAST_KEYS). robonen's MenuContentImpl.handleKeyDown only calls event.stopPropagation() for FIRST_LAST_KEYS and never focuses an item from the container. 🔶 (keyboard) Tab inside the menu is not prevented. reka's handleKeyDown does if (event.key === 'Tab') event.preventDefault() when the key originates inside content, so Tab cannot move focus out of an open menu (ARIA menu pattern requirement). robonen never prevents Tab in MenuContentImpl; it only relies on RovingFocusItem shift+Tab handling, leaving forward Tab able to escape the menu. 🔶 (edge-cases) Selecting via pointer is fragile. reka's MenuItem tracks isPointerDownRef and on pointerup (if pointer started elsewhere) dispatches a synthetic click so pointer-down-then-move-to-another-item selects on release, and explicitly prevents Firefox text-selection lock when the menu closes. robonen's MenuItem/MenuItemImpl only handle plain @click; there is no pointerdown/pointerup logic, so drag-select across items and the Firefox text-selection edge case are unhandled. 🔶 (keyboard) Space during type-ahead incorrectly selects in robonen. reka's MenuItem keydown guards if (disabled || (isTypingAhead && event.key === ' ')) return so Space extends the search string instead of activating the item while typing. robonen's MenuItemImpl.handleKeyDown treats Enter/Space identically (if (event.key === 'Enter' || event.key === ' ') click()) with no typeahead check, so pressing Space mid-search selects the focused item instead of continuing the search. 🔶 (accessibility) Group/Label accessible name relationship is broken in robonen. reka's MenuGroup renders :aria-labelledby="id" and MenuLabel injects the group context to render :id="groupContext.id", wiring the group to its label for screen readers. robonen's MenuGroup renders :id="id" (not aria-labelledby) and MenuLabel does NOT inject MenuGroupContext at all — it renders a bare Primitive with no id — so the group is never labelled by the label. The provided MenuGroupContext has no consumer. 🔶 (edge-cases) Submenu does not auto-close when the parent menu closes, and the sub-menu's content element is never tracked. reka's MenuSub has watchEffect closing the sub when parentMenuContext.open is false (plus cleanup), and provides a reactive content ref + working onContentChange. robonen's MenuSub provides content: shallowRef(null) with a no-op onContentChange: () => {}, so menuContext.content for a submenu is permanently null (breaking any grace/positioning logic that reads sub content), and there is no parent-close watcher to dismiss orphaned submenus. 🔶 (api-surface) Root and Sub support only controlled open (no uncontrolled/passive mode). reka's MenuRoot and MenuSub use useVModel (MenuSub with defaultValue:false, passive so it self-manages open state when no v-model is bound). robonen's MenuRoot/MenuSub use open = openRef = toRef(() => open) and only emit update:open; if the consumer does not bind v-model:open, the menu can never open because props.open stays at its default false. Minor gaps (8): ▫️ (types) RadioGroup/RadioItem value is restricted to string. reka uses AcceptableValue (any serializable value) for MenuRadioGroup.modelValue and MenuRadioItem.value. robonen types both as string, so number/object radio values are not supported. ▫️ (accessibility) MenuRadioGroup does not render through MenuGroup. reka's MenuRadioGroup extends MenuGroupProps and renders <MenuGroup>, inheriting the group role + label-id wiring. robonen's MenuRadioGroup renders a bare Primitive role="group" and never provides MenuGroupContext, so a MenuLabel inside a radio group cannot be associated. ▫️ (accessibility) Modal content does not prevent focus from leaving the layer, and there is no disableOutsideScroll prop. reka's MenuRootContentModal uses @focus-outside.prevent so a modal menu keeps focus inside even on programmatic focus-outside; reka also exposes a disableOutsideScroll prop (modal passes true, nonmodal false). robonen's MenuRootContentModal forwards focus-outside without .prevent and has no disableOutsideScroll prop (it just calls useBodyScrollLock() unconditionally with no opt-out and no way for nonmodal to enable it). ▫️ (edge-cases) Submenu keydown does not bubble-guard across portals. reka's MenuContentImpl and MenuSubContent both compute isKeyDownInside = target.closest('[data-reka-menu-content]') === event.currentTarget to ignore key events that bubble up from a nested submenu through the portal. robonen has no such guard, so a key handled in a submenu can be re-processed by the parent content's handleKeyDown (double navigation/typeahead). ▫️ (edge-cases) Typeahead does not exclude input/textarea fields. reka's handleKeyDown computes isKeyDownInTextField and skips typeahead when typing in an input/textarea inside the menu (filter/combobox-in-menu support, also reflected in onItemEnter/onItemLeave checking INPUT/TEXTAREA active element). robonen's typeahead fires on any single character key with no text-field exclusion, so typing in a menu-embedded input also drives item typeahead. ▫️ (code-quality) isMouseEvent precision. reka's isMouseEvent uses event.pointerType === 'mouse', correctly excluding pen and touch from mouse-only hover logic. robonen's isMouseEvent checks event.type against mouse event names (mousedown/up/move/click) and elsewhere only negates pointerType === 'touch', treating pen as mouse. ▫️ (api-surface) CheckboxItem/RadioItem do not expose checked state via default slot. reka's MenuCheckboxItem/MenuRadioGroup expose modelValue as a slot prop (<slot :model-value=...>), letting consumers render based on state without re-reading. robonen's MenuCheckboxItem/MenuRadioItem render <slot /> with no slot props. ▫️ (code-quality) MenuItemImpl does not set inheritAttrs:false / forward fallthrough attrs explicitly. reka's MenuItemImpl declares defineOptions({inheritAttrs:false}) and binds v-bind="$attrs" onto the Primitive, giving precise control of attribute placement (needed since it is wrapped by CheckboxItem/RadioItem/SubTrigger that add role/aria). robonen's MenuItemImpl relies on default attr fallthrough, which can mis-place attrs when wrappers add their own. Recommendations to make robonen strictly better: Implement the submenu grace-area in MenuSubTrigger.handlePointerLeave: read menuCtx.content (which first requires fixing MenuSub to provide a real reactive content ref + working onContentChange), build the 5-point polygon from getBoundingClientRect() with a directional bleed, call onPointerGraceIntentChange({area, side}) and set pointerGraceTimerRef to a 300ms reset. Without callers, the existing grace plumbing in MenuContentImpl is dead code. Add pointer-direction tracking to MenuContentImpl: @pointermove handler maintaining pointerDirRef/lastPointerXRef and an isPointerMovingToSubmenu(event) gate that requires both grace-area containment AND movement toward the submenu side; use it in onItemEnter/onItemLeave/onTriggerLeave. Add a @keydown handler to MenuSubContent that on SUB_CLOSE_KEYS closes the submenu and refocuses the trigger (trigger.focus + scrollIntoView), and an @open-auto-focus.prevent that focuses the sub-content for keyboard users only. Currently ArrowLeft cannot return focus to the parent once inside a submenu. In MenuContentImpl.handleKeyDown, when event.target === contentElement and the key is in FIRST_LAST_KEYS, preventDefault and focusFirst the items (reversed for LAST_KEYS) so opening via mouse + ArrowDown/Home/End moves focus into the list. Also add if (event.key === 'Tab') event.preventDefault() to trap Tab inside the menu. Fix MenuSub: provide a real content = shallowRef(null) with a functional onContentChange, and add a watchEffect/watch to close the submenu when the parent menu closes (with cleanup), mirroring reka's parent-close protection. Switch MenuRoot and MenuSub from emit-only toRef(() => open) to a controllable/uncontrolled model (useControllableState or useVModel with passive defaulting to false) so the menu works without an explicit v-model:open binding. Add isTypingAhead Space guard to MenuItemImpl/MenuItem and MenuSubTrigger keydown (if (isTypingAhead && key === ' ') return) so Space extends type-ahead instead of selecting, and route Space through the typeahead path rather than activation while searching. Add pointerdown/pointerup selection logic to MenuItem (isPointerDownRef + synthetic click on pointerup when pointer started elsewhere) to support drag-to-select across items and avoid the Firefox text-selection lock on close. Fix Group/Label a11y: make MenuGroup render aria-labelledby=id and make MenuLabel inject MenuGroupContext to set its id; have MenuRadioGroup render through MenuGroup so radio groups get the same label wiring. Widen MenuRadioGroup.modelValue and MenuRadioItem.value to AcceptableValue (not just string) to support non-string values. Add isKeyDownInside (target.closest('[data-primitives-menu-content]') === currentTarget) guards in MenuContentImpl/MenuSubContent to stop submenu key events that bubble through portals from being double-processed, and add isKeyDownInTextField exclusion so embedded inputs don't trigger typeahead. Expose modelValue/checked as default slot props on MenuCheckboxItem and MenuRadioItem/MenuRadioGroup; add @focus-outside.prevent to MenuRootContentModal and a disableOutsideScroll prop on MenuContentImpl for parity and opt-out control; tighten isMouseEvent to pointerType==='mouse'; and set inheritAttrs:false + explicit v-bind=$attrs on MenuItemImpl. Where robonen is already better: ✅ MenuItem select composition uses a real DOM CustomEvent dispatched on the item element (MenuItem.vue handleSelect dispatches new CustomEvent(ITEM_SELECT, {bubbles:true,cancelable:true}) on currentTarget), so the ITEM_SELECT event genuinely bubbles through the DOM tree and any ancestor listener can preventDefault to keep the menu open. reka only emits the CustomEvent to the Vue select emit and awaits nextTick, so the event never actually traverses the DOM. ✅ Architecturally relies on a dedicated RovingFocusGroup/RovingFocusItem (roving tabindex managed declaratively with tabindex toggling, onItemShiftTab, focusable-item counting) instead of reka's imperative useArrowNavigation scanning [data-reka-collection-item] on every keystroke; the roving model keeps a real DOM tab order and shift+Tab-out handling that reka's content-level approach implements ad hoc. ✅ Type-ahead match reads an explicit data-primitive-menu-item-text-value data attribute set from the textValue prop on every item (MenuItemImpl + getNextMatch), giving deterministic typeahead text even for complex/icon content without depending on a Collection value object. ✅ focusFirst (utils.ts) snapshots getActiveElement() before each candidate via preventScroll: true, avoiding scroll jumps when programmatically focusing items; reka's focusFirst calls plain candidate.focus() without preventScroll. tabs — reka-better Parts reka has that robonen lacks: TabsIndicator (animated active-tab indicator via CSS vars + ResizeObserver) robonen Tabs is a clean, self-contained implementation (Root/List/Trigger/Content) with good DOM-order navigation via its Collection primitive, real root-level disabled gating, root-owned loop/orientation/dir context, and lean toRef passthroughs. However it is NOT yet strictly better than reka-ui and currently regresses on two critical axes. Accessibility: it emits no id/aria-controls on triggers and no id/aria-labelledby on panels, so screen readers cannot link a tab to its panel (reka does this via makeTriggerId/makeContentId + a contentIds registry that even suppresses aria-controls when no panel exists). Keyboard: there is no Enter/Space activation path at all (TabsTrigger has no enter/space handler and rovingKeyToAction ignores them), which makes activationMode='manual' unusable from the keyboard. Beyond these, reka adds a TabsIndicator part, Presence-based exit animations + unmountOnHide + mount-animation suppression, ConfigProvider dir inheritance (robonen ignores its own config-provider dir), PageUp/PageDown navigation, StringOrNumber generic value typing, guarded mousedown.left activation (ctrl-click defense), and group entry-focus management. robonen has no sub-components or features reka lacks. Verdict: reka-better until the critical a11y wiring and Enter/Space activation are added; the indicator, presence/unmountOnHide, dir inheritance, and generic value type close the remaining gaps. Critical gaps: ⛔ (accessibility) No ARIA relationship wiring between triggers and panels. reka generates stable ids (utils.ts makeTriggerId/makeContentId), sets :id=triggerId + :aria-controls=contentId on TabsTrigger and :id=contentId + :aria-labelledby=triggerId on TabsContent, and only emits aria-controls when a matching panel is registered (rootContext.contentIds + registerContent/unregisterContent). robonen TabsTrigger has NO id and NO aria-controls; TabsContent has NO id and NO aria-labelledby. Screen readers cannot associate a tab with its panel. evidence: reka Tabs/utils.ts + TabsTrigger.vue lines 29-30,42-50 (aria-controls/id) + TabsContent.vue lines 28-29,53-61 (id/aria-labelledby) + TabsRoot.vue contentIds/registerContent. robonen TabsTrigger.vue and TabsContent.vue emit neither id nor aria-controls nor aria-labelledby. ⛔ (keyboard) Keyboard activation (Enter/Space) on a focused trigger is not implemented. reka TabsTrigger has @keydown.enter.space="rootContext.changeModelValue(value)". robonen TabsTrigger only has @keydown delegating to ctx.onTriggerKeyDown, and onTriggerKeyDown (TabsRoot.vue rovingKeyToAction) returns null for Enter/Space, so it does nothing. In activationMode='manual' a keyboard user can move focus with arrows but CANNOT activate the focused tab via Enter or Space — manual mode is effectively unusable by keyboard. evidence: reka TabsTrigger.vue line 65 @keydown.enter.space. robonen TabsTrigger.vue lines 33-36 only forward to onTriggerKeyDown; src/utils/roving-focus.ts rovingKeyToAction has no Enter/Space case (default: return null). Major gaps: 🔶 (features) No TabsIndicator sub-component. reka ships TabsIndicator.vue which tracks the active tab via ResizeObserver/watchPostEffect and exposes --reka-tabs-indicator-size and --reka-tabs-indicator-position CSS vars (plus an exposed updateIndicatorStyle method) for animated underlines. robonen has no indicator part at all. 🔶 (features) No exit-animation / Presence support on content. reka wraps TabsContent in <Presence force-mount> so panels can play leave transitions before unmount and respects unmountOnHide for slot rendering; it also suppresses the mount animation on first render via isMountAnimationPreventedRef + animationDuration:'0s'. robonen TabsContent uses a raw v-if="forceMount || isSelected", so inactive panels are removed immediately with no exit animation and no mount-animation guard. 🔶 (api-surface) No unmountOnHide prop. reka TabsRoot exposes unmountOnHide (default true) and TabsContent uses it to decide whether the slot stays rendered while hidden (forceMount keeps the element but unmountOnHide controls slot content). robonen only has the per-panel forceMount boolean and always renders slot content when mounted; there is no way to keep an element mounted (hidden) while still tearing down its slot subtree, nor a root-level toggle. 🔶 (rtl-i18n) dir does not inherit from a global ConfigProvider. reka resolves direction with useDirection(propDir) so it falls back to the ConfigProvider/global dir when the prop is omitted. robonen TabsRoot hardcodes dir = 'ltr' default and never consults useConfig()/ConfigContext, despite robonen HAVING a config-provider with a reactive dir. RTL apps using ConfigProvider get LTR tab navigation unless dir is repeated on every TabsRoot. Minor gaps (6): ▫️ (keyboard) PageUp/PageDown navigation not handled. reka RovingFocus MAP_KEY_TO_FOCUS_INTENT maps PageUp->first and PageDown->last (used by Tabs via RovingFocusGroup). robonen rovingKeyToAction only handles Arrow/Home/End and returns null for PageUp/PageDown. ▫️ (types) value type is narrower. reka TabsRoot/Trigger/Content are generic over T extends StringOrNumber (string | number) with the v-model payload typed as T. robonen types value as string only (TabsRootProps.defaultValue: string, defineModel<string|undefined>, TabsTriggerProps.value: string), so numeric tab values are not first-class and the model is not generically inferred. ▫️ (keyboard) No entry-focus / group-tabindex management when tabbing INTO the tablist. reka RovingFocusGroup toggles the group tabindex (0/-1) and on focus dispatches an entryFocus event then focuses the active/highlighted/current item (handleFocus + focusFirst), with preventScrollOnEntryFocus and shift+Tab tab-out tracking. robonen sets per-trigger roving tabindex (selected=0 else -1) which covers the common case, but has no group-level entry focus, no preventScrollOnEntryFocus, and no shift+Tab 'tab back out' handling. ▫️ (edge-cases) Pointer activation uses click, not guarded mousedown. reka activates on @mousedown.left and explicitly guards ctrlKey (avoids MacOS ctrl-click context menu accidentally activating) and calls event.preventDefault() to avoid accidental activation for disabled. robonen uses @click with no ctrl-click guard; activation therefore fires later (on click) and does not defend against ctrl+left-click. ▫️ (accessibility) focusFirst scroll-fallback behavior absent. reka focusFirst iterates candidate nodes and supports { preventScroll }, bailing when focus already on target and trying the next candidate if focus did not move (e.g. element became unfocusable). robonen just calls target.focus() on the single resolved index with no preventScroll and no fallback if that element refuses focus. ▫️ (edge-cases) Root keydown still navigates focus even when root is disabled. robonen onTriggerKeyDown does not check ctx.disabled, so with disabled: true on root, Arrow keys still move focus between triggers (only selection is blocked). reka's disabled handling is per-item via RovingFocus focusable, keeping disabled items out of the candidate set consistently. Minor because robonen does filter [data-disabled] items, but a fully-disabled root should arguably not roam focus. Recommendations to make robonen strictly better: Add ARIA wiring: generate stable ids (e.g. via config-provider useId) for triggers and panels; set id+aria-controls on TabsTrigger and id+aria-labelledby on TabsContent. Mirror reka's contentIds registry (registerContent/unregisterContent in TabsRoot) so aria-controls is only emitted when a matching TabsContent is mounted (reka covers this exact case in Tabs.test.ts). Implement Enter/Space activation on TabsTrigger (add @keydown.enter.space calling ctx.select(value), or add Enter/Space handling to onTriggerKeyDown). Without it, activationMode='manual' is broken for keyboard users. Add a TabsIndicator sub-component exposing --robonen-tabs-indicator-size/position CSS vars, tracking the active tab via ResizeObserver, with an exposed updateIndicatorStyle() method (port reka TabsIndicator.vue). Wrap TabsContent in the existing Presence primitive (src/presence) and add an unmountOnHide prop on TabsRoot so panels can play exit animations and optionally keep elements mounted-but-hidden; add a mount-animation guard (animationDuration:'0s' on first frame) like reka's isMountAnimationPreventedRef. Inherit direction from ConfigProvider: in TabsRoot resolve dir via useConfig().dir when the dir prop is omitted (robonen already has config-provider/context.ts with a reactive dir). Extend rovingKeyToAction to map PageUp->home and PageDown->end (and consider Enter/Space) so Tabs gets PageUp/PageDown navigation for free. Make the components generic over T extends string | number (StringOrNumber) for value/defaultValue/model to support numeric tab values and proper v-model inference. Add guarded pointer activation: switch TabsTrigger to @mousedown.left with an event.ctrlKey === false guard (and preventDefault on disabled) to match reka and avoid ctrl-click activation on macOS. Optionally add group entry-focus behavior (focus the active/highlighted item when Tab enters the tablist, preventScrollOnEntryFocus, shift+Tab tab-out) — robonen already has a roving-focus/ RovingFocusGroup that could be reused by TabsList instead of the hand-rolled root keydown. Guard onTriggerKeyDown with if (disabled) return so a fully disabled root does not roam focus; and use a focusFirst-style helper with {preventScroll} and a next-candidate fallback when the target refuses focus. Where robonen is already better: ✅ DOM-order navigation via Collection: TabsRoot derives tabElements from useCollectionProvider().getItems(true) and filters [data-disabled] at keydown time, so reordered/portaled triggers and dynamic disabled state are handled from live DOM order. reka relies on RovingFocus + Collection too, but robonen keeps it self-contained without the extra RovingFocus indirection. ✅ Keyboard handler centralized in TabsRoot.onTriggerKeyDown with explicit resolveNextIndex(... , loop) clamp-vs-wrap logic and a single source of truth, vs reka splitting logic across RovingFocusGroup/RovingFocusItem/utils. ✅ Root-level disabled prop genuinely gates selection (select() early-returns when ctx.disabled) and merges into each trigger's isDisabled/data-disabled; reka Tabs has no root-level disabled prop at all (only per-trigger disabled). ✅ loop is configured on TabsRoot (root-owned) and threaded through context; reka places loop on TabsList only, which is arguably less discoverable. ✅ Uses toRef(() => prop) identity passthroughs for orientation/dir/loop/disabled/activationMode instead of computed, avoiding effect/cache overhead for plain pass-throughs. toolbar — reka-better Parts reka has that robonen lacks: ToolbarLink, ToolbarToggleGroup, ToolbarToggleItem reka-ui's Toolbar is currently more complete and more correct than robonen's. Sub-component coverage: reka ships ToolbarLink, ToolbarToggleGroup, and ToolbarToggleItem in addition to Root/Button/Separator; robonen has only Root/Button/Separator and exports no integration for its existing toggle-group, and naive nesting would create two competing roving controllers. Critically, robonen's arrow navigation does not skip disabled items (it builds the item list with includeDisabled=true and calls .focus() on disabled buttons, which silently fails and traps focus) - robonen's own ToggleGroupRoot already filters disabled, so the toolbar simply omitted it. robonen is also missing PageUp/PageDown navigation, Shift+Tab tab-back-out tracking + group entry-focus (so a toolbar with all-disabled items can fall out of the tab order entirely), modifier-key and event.target guards, preventScrollOnEntryFocus, ConfigProvider dir inheritance, controlled current-tab-stop, and ToolbarLink's Space-to-click. robonen does have genuine wins: its ToolbarSeparator auto-inverts orientation (horizontal toolbar -> vertical separator) whereas reka passes orientation straight through, and its standalone Separator (not yet reused by the toolbar) already supports decorative + conditional aria-orientation. Verdict: reka-better; the disabled-skip bug is the highest-priority fix, followed by adding the missing toggle/link parts and the group-level focus/tab-out semantics. Critical gaps: ⛔ (keyboard) robonen arrow navigation does NOT skip disabled items and will silently fail to move focus when the next item is disabled. ToolbarRoot builds items = getItems(true) (includeDisabled=TRUE), and onItemKeyDown/focusIndex operate over that full list with no data-disabled filter, then call el.focus() on a disabled which cannot receive focus, so focus stays stuck. Reka's RovingFocusItem.handleKeydown filters getItems().map(i => i.ref).filter(i => i.dataset.disabled !== '') before navigating. robonen's own ToggleGroupRoot DOES filter (items.value.filter(x => !x.hasAttribute('data-disabled'))) - the Toolbar simply forgot to. evidence: reka RovingFocusItem.vue line 68 .filter(i => i.dataset.disabled !== ''); robonen ToolbarRoot.vue line 27 getItems(true) + lines 39-48 onItemKeyDown have no disabled filter, contrast robonen ToggleGroupRoot.vue line 106 Major gaps: 🔶 (keyboard) Missing PageUp/PageDown keys. Reka maps PageUp->'first' and PageDown->'last' (MAP_KEY_TO_FOCUS_INTENT), so they jump to first/last item. robonen's rovingKeyToAction only handles Arrow*/Home/End and returns null for PageUp/PageDown. 🔶 (features) No ToolbarToggleGroup / ToolbarToggleItem sub-components. Reka ships both, wrapping ToggleGroupRoot/ToggleGroupItem with :roving-focus="false" so the toolbar's roving group owns navigation while toggle single|multiple v-model still works. robonen has a standalone toggle-group primitive but the toolbar exports nothing to integrate it, and naively nesting robonen's ToggleGroupRoot inside ToolbarRoot would create TWO competing roving-focus controllers (both register their own Collection + arrow handlers), breaking navigation. 🔶 (features) No ToolbarLink sub-component. Reka ToolbarLink renders an as a roving item AND adds Space-to-click behavior (if (event.key === ' ') currentTarget.click()) because anchors don't activate on Space natively. robonen has no Link part, so toolbar links lose roving participation and Space activation. 🔶 (keyboard) No Shift+Tab tab-back-out handling and no group-level entry tabindex. Reka's RovingFocusGroup tracks isTabbingBackOut (set by RovingFocusItem on Shift+Tab) and renders the GROUP element with :tabindex="isTabbingBackOut || focusableItemsCount === 0 ? -1 : 0", plus an @focus entry-focus handler that focuses the active/highlighted/current candidate via focusFirst. robonen ToolbarRoot has no group tabindex, no entryFocus, no Shift+Tab tracking. 🔶 (accessibility) Toolbar can become unreachable when the active item is disabled or all items are disabled. Reka keeps the group focusable (tabindex 0 with entry focus to first enabled item) and filters disabled from candidates. robonen pins tabindex=0 to the isActive item only; if that item is disabled it renders tabindex=-1, so the toolbar can have ZERO tabbable elements and drop out of the tab sequence with no fallback. Minor gaps (7): ▫️ (accessibility) Separator: no decorative prop and always emits aria-orientation even for horizontal. Reka routes through BaseSeparator which supports decorative (role='none', removed from a11y tree) and only sets aria-orientation when vertical (horizontal is the ARIA default). robonen ToolbarSeparator hardcodes role="separator" and always renders :aria-orientation. robonen's OWN standalone Separator.vue already does both correctly - the toolbar separator just doesn't reuse it. ▫️ (keyboard) No modifier-key guard. Reka RovingFocusItem ignores arrow nav when metaKey/ctrlKey/altKey (or shiftKey unless allowShiftKey) is held: if (event.metaKey || event.ctrlKey || event.altKey || (props.allowShiftKey ? false : event.shiftKey)) return. robonen calls preventDefault and navigates on any matching arrow key regardless of modifiers, hijacking e.g. Ctrl+Home. ▫️ (keyboard) No event.target === event.currentTarget guard before handling keys. Reka RovingFocusItem returns early if the keydown originated from a descendant (lines 55-56), so keys typed inside a nested input aren't hijacked for roving. robonen ToolbarButton.onKeyDown forwards every keydown to ctx regardless of target. ▫️ (edge-cases) No preventScrollOnEntryFocus; focus uses default scroll behavior. Reka exposes preventScrollOnEntryFocus and calls .focus({ preventScroll }) via focusFirst. robonen focusIndex calls plain el.focus() with no preventScroll option and no prop to control it. ▫️ (api-surface) No currentTabStopId v-model / defaultCurrentTabStopId / entryFocus emit. Reka's RovingFocusGroup supports controlled+uncontrolled current tab stop and emits entryFocus. robonen toolbar exposes no way to control or observe which item currently holds the tab stop. ▫️ (api-surface) ToolbarButton does not forward arbitrary PrimitiveProps/asChild as deliberately as reka. Reka ToolbarButton does v-bind="props" spreading all PrimitiveProps (incl. asChild) onto the Primitive; robonen ToolbarButton only wires as/type/tabindex/disabled/data-disabled, so the asChild composition pattern reka relies on for ToolbarToggleItem is not surfaced. ▫️ (rtl-i18n) No ConfigProvider/global dir inheritance. Reka resolves dir via useDirection(propDir) which falls back to the global ConfigProvider direction when dir is omitted. robonen takes dir only as a local prop defaulting to 'ltr', so app-wide RTL config won't reach the toolbar automatically. Recommendations to make robonen strictly better: Filter disabled items out of roving navigation: in ToolbarRoot use getItems() (which already drops data-disabled) for the navigable list, or filter items.value.filter(el => el.dataset.disabled !== '') inside onItemKeyDown before computing the next index - mirror what robonen's own ToggleGroupRoot already does. This fixes the critical 'focus stuck on disabled' bug. Add PageUp/PageDown to rovingKeyToAction: map PageUp -> {absolute:'home'} and PageDown -> {absolute:'end'} so the shared util matches reka (also benefits ToggleGroup/RadioGroup reusing it). Add a ToolbarLink component (default as:'a', participates in roving as a Collection item, with a keydown handler if (e.key === ' ') currentTarget.click() to give anchors Space activation). Add ToolbarToggleGroup + ToolbarToggleItem that wrap robonen's existing ToggleGroupRoot/ToggleGroupItem with roving-focus=false (forwarding orientation/dir from toolbar context), so toggle state works without spawning a second competing roving controller; wrap ToolbarToggleItem as-child so it joins the toolbar's Collection. Make the toolbar reachable when items are disabled: track focusableItemsCount and isTabbingBackOut at Root, render group tabindex=(tabbingBackOut || focusableCount===0) ? -1 : 0, add an entry-focus handler that focuses the first enabled/active/current item, and re-point the active tab stop to the first ENABLED item rather than a fixed index 0. Add modifier-key and target guards in keydown: bail when metaKey/ctrlKey/altKey (and shiftKey unless an allowShiftKey opt) is held, and when event.target !== event.currentTarget, so nested inputs and browser shortcuts aren't hijacked. Reuse robonen's own Separator.vue (already supports decorative + conditional aria-orientation) for ToolbarSeparator while keeping the orientation auto-inversion, adding a decorative prop, and omitting aria-orientation when horizontal. Add preventScrollOnEntryFocus and call el.focus({ preventScroll }); consider exposing controlled/uncontrolled current tab stop (modelValue) and an entryFocus emit for parity with reka's RovingFocusGroup. Resolve dir through ConfigProvider (a useDirection equivalent) instead of a hardcoded 'ltr' default so global RTL config propagates. Support asChild on ToolbarButton (and document the pattern) so consumers can compose custom interactive elements, matching reka's v-bind=props + as-child usage required by ToolbarToggleItem. Where robonen is already better: ✅ Separator orientation auto-inversion: robonen ToolbarSeparator computes effective = orientation ?? (ctx.orientation === 'horizontal' ? 'vertical' : 'horizontal'), so a horizontal toolbar yields a vertical separator automatically. Reka's ToolbarSeparator passes rootContext.orientation.value straight through to BaseSeparator (NO inversion), so a horizontal reka toolbar renders a horizontal separator with aria-orientation matching the toolbar axis, which is less correct visually/semantically. Genuine robonen correctness win. ✅ toRef-based context passthroughs (toRef(() => orientation)) avoid the extra effect/cache overhead of computed, a minor reactivity-perf nicety over reka's toRefs(props). ✅ robonen keeps the roving math in a small pure, testable util (resolveNextIndex, rovingKeyToAction) rather than reka's per-keystroke array reverse + wrapArray slicing; slightly cheaper per keydown (no array clone/reverse). ✅ robonen ToolbarButton sets data-disabled='' and correct aria/tabindex for the active roving item, matching reka conventions. checkbox — reka-better Parts reka has that robonen lacks: CheckboxGroupRoot.vue — multi-checkbox group with array model, roving-focus keyboard nav, group-level disabled/name/required, utils.ts — shared isIndeterminate/getState helpers (robonen inlines this logic in both components, duplicated) reka-ui's Checkbox is a meaningfully larger primitive: it ships a full CheckboxGroupRoot with array v-model and roving-focus keyboard navigation, generic value typing via trueValue/falseValue, Presence-backed indicator exit animations, SSR-safe form-control gating, native-event-dispatching hidden inputs (VisuallyHiddenInput), and auto aria-label derivation from associated labels. robonen's Checkbox covers only the standalone single-boolean case competently (correct role/aria-checked=mixed/data-state, controlled+uncontrolled via defineModel, defaultChecked, disabled, Enter-preventDefault, hidden input for name, and forceMount prop) and is slightly leaner in reactivity for that case, but it is missing the group component, roving focus, arbitrary value modeling, working exit animations, SSR/form-event fidelity, and label-based aria-label. Notably robonen already owns RovingFocusGroup, Presence, and VisuallyHidden primitives but does not wire any of them into Checkbox. Verdict: reka-better; closing the listed gaps (especially CheckboxGroupRoot + roving focus, trueValue/falseValue, and Presence in the indicator) would move robonen to parity or better. Critical gaps: ⛔ (features) No CheckboxGroupRoot component at all. reka ships CheckboxGroupRoot.vue providing a v-model array of selected values, group-level disabled, name, required, and roving-focus wiring (injectCheckboxGroupRootContext). robonen has zero group concept, so consumers cannot build a managed checkbox group with shared state/keyboard nav. evidence: reka CheckboxGroupRoot.vue (provideCheckboxGroupRootContext with modelValue: Ref<AcceptableValue[]>, rovingFocus, disabled); CheckboxRoot.vue lines 80-122 read checkboxGroupContext to push/splice into the array. robonen/checkbox has no CheckboxGroupRoot file or context. Major gaps: 🔶 (keyboard) No roving-focus / arrow-key navigation. In a group, reka renders RovingFocusItem instead of Primitive (CheckboxRoot.vue line 138, :focusable bound at line 151) inside RovingFocusGroup, giving ArrowLeft/Right/Up/Down, Home/End, loop, and dir/RTL-aware navigation. robonen's checkbox is always a standalone Primitive with no roving focus, so a set of robonen checkboxes has no managed arrow-key traversal even though robonen HAS a RovingFocusGroup/RovingFocusItem primitive available it could wire in. 🔶 (api-surface) No trueValue/falseValue and no generic value typing. reka's CheckboxRoot is generic <T = boolean> with trueValue/falseValue (props lines 27-31, defaults lines 64-65) so v-model can hold arbitrary values (e.g. 'yes'/'no', objects) using isEqual comparison (line 89). robonen hardcodes CheckedState = boolean | 'indeterminate' and toggles only true/false; the value prop is merely the hidden-input value, never the model value. 🔶 (features) Indicator does not use Presence, so the documented forceMount-for-exit-animations use case does not actually work. reka wraps the indicator in (CheckboxIndicator.vue lines 29-31) enabling enter/exit animation hooks; robonen uses a bare v-if (CheckboxIndicator.vue line 25), which unmounts instantly with no exit animation, even though robonen ships a Presence primitive and even though its forceMount prop's JSDoc says 'for CSS exit animations'. 🔶 (forms) Hidden form input does not emit native input/change events on programmatic value change, so listeners on the hidden input / some form libraries relying on real DOM events will not fire. reka's VisuallyHiddenInputBubble dispatches bubbling input+change Events via the native value setter when the value changes (watch in VisuallyHiddenInputBubble.vue). robonen binds :checked declaratively only. Minor gaps (6): ▫️ (ssr) No SSR-safe form-control gating. reka uses useFormControl(currentElement) which returns true during SSR and only renders the hidden input when inside a (CheckboxRoot.vue line 163, useFormControl defaults true so events bubble without JS on SSR). robonen renders the hidden input whenever name is set regardless of being in a form, and has no SSR-aware default — minor functional divergence and a stray hidden input outside forms. ▫️ (accessibility) No automatic aria-label derivation from an associated label. reka derives aria-label from document.querySelector([for="${id}"]).innerText when an id is provided (CheckboxRoot.vue lines 125-127, bound line 147), improving SR naming when a visual is used. robonen has no id prop and no aria-label wiring. ▫️ (api-surface) No id prop on the root. reka exposes id (props line 23) and binds it (template line 140), which both anchors the aria-label lookup and gives a stable hook for external . robonen's CheckboxRoot has no id prop (relies entirely on fall-through attrs). ▫️ (types) value prop is narrowly typed to string. reka types value as AcceptableValue (string | number | boolean | Record | null) (props line 21) and the hidden input serializes arrays/objects/required-empty-arrays via VisuallyHiddenInput. robonen types value?: string only and cannot serialize non-string values. ▫️ (api-surface) Slot contract is thinner. reka's default slot exposes both modelValue and state (defineSlots lines 69-76; provided at template lines 157-160). robonen exposes only checked (CheckboxRoot.vue line 85, CheckboxIndicator line 30). With trueValue/falseValue, reka's modelValue slot prop is meaningfully distinct from state; robonen cannot express that. ▫️ (code-quality) Duplicated indeterminate/state logic instead of shared util. reka centralizes isIndeterminate/getState in utils.ts and reuses across Root + Indicator. robonen inlines the same ternary in CheckboxRoot.vue (lines 76,79) and CheckboxIndicator.vue (line 26), risking drift. Recommendations to make robonen strictly better: Add a CheckboxGroupRoot component mirroring reka: provide a group context with an array modelValue (v-model), group-level disabled/name/required, and rovingFocus toggle; have CheckboxRoot inject it and, when present, render robonen's existing RovingFocusItem instead of Primitive (wrapped by RovingFocusGroup in the group root) to get arrow/Home/End/loop/RTL navigation for free. Make CheckboxRoot generic over the value type and add trueValue/falseValue props (defaults true/false) so v-model can hold arbitrary values; use a structural-equality helper (or shallow compare) to derive isChecked and toggle between trueValue/falseValue, with 'indeterminate' -> trueValue on click as reka does. Wrap CheckboxIndicator's content in robonen's Presence component (present = forceMount || checked is indeterminate || checked === true) so the existing forceMount prop actually enables CSS exit animations, instead of the current instant-unmount v-if. Replace the inline hidden with robonen's VisuallyHidden as=input (or a VisuallyHiddenInput-style helper) that dispatches native bubbling input+change events on value change via the native value setter, and gate it behind an SSR-safe useFormControl(currentElement) check (default true on SSR; otherwise el.closest('form')).
- Add aria-label auto-derivation: accept an id prop, bind it on the root, and compute aria-label from document.querySelector(
[for=id]).innerText when no explicit aria-label is supplied (guard for SSR). - Widen the value prop type to an AcceptableValue equivalent and serialize array/object/required-empty values in the hidden input the way reka's VisuallyHiddenInput does.
- Extract isIndeterminate/getState into a checkbox utils.ts and reuse in Root + Indicator to remove the duplicated state ternaries.
- Expand the default slot to expose both modelValue and state (not just checked) to match reka's richer slot contract, especially once trueValue/falseValue land.
Where robonen is already better:
- ✅ No runtime dependency on ohash isEqual for the common boolean case; robonen's toggle is a plain boolean flip, so it is lighter when only boolean checkboxes are used.
- ✅ Micro-optimization: provides disabled via toRef(() => disabled) instead of a computed, and forwards the existing localChecked Ref directly to context without an extra computed wrapper (CheckboxRoot.vue lines 60-65) — slightly fewer reactive effects than reka's two computeds (disabled, checkboxState).
- ✅ Hidden input is rendered with a concrete inline style that visually hides without an extra component layer; the markup is simpler than reka's VisuallyHiddenInput -> VisuallyHiddenInputBubble -> VisuallyHidden chain for the trivial single-boolean case.
toggle-group — reka-better
Parts reka has that robonen lacks: VisuallyHiddenInput (form-submission hidden input rendered by Root), Composed RovingFocusGroup/RovingFocusItem wrappers, Composed Toggle base component for items
reka's ToggleGroup is currently better. Both cover the core single/multiple toggle behavior, click/Arrow/Home/End navigation, loop, orientation, disabled, RTL-aware Left/Right, controlled+uncontrolled state, and roving tab-stop. But reka is meaningfully ahead on: (1) native form integration via a VisuallyHiddenInput + useFormControl (robonen has none, package-wide) - critical; (2) AcceptableValue (string/number/bigint/object/null) with deep equality vs robonen's string-only identity comparison; (3) automatic single/multiple type inference + dev validation vs robonen's explicit-only, unsoundly-typed type; (4) inherited global dir from ConfigProvider; and (5) far deeper roving focus (PageUp/PageDown, modifier-key guards, Shift+Tab tab-out, entry-focus prioritization, Safari mousedown, group tabindex) because reka composes its RovingFocusGroup/RovingFocusItem and Toggle primitives. Ironically robonen already ships an equivalent RovingFocusGroup/RovingFocusItem and Toggle primitive but the toggle-group reimplements a thinner version inline and uses none of them. robonen is genuinely better in two narrow areas: it uses proper radiogroup/radio ARIA semantics for single-select (reka uses group + aria-pressed even in single mode), and it adds a convenience valueChange emit and a normalized array slot value. Closing the form, value-type, type-inference, dir-inheritance, and roving-focus gaps (largely by composing robonen's own existing primitives) would make it strictly better.
Critical gaps:
- ⛔ (forms) No form integration whatsoever. reka renders a VisuallyHiddenInput (gated by useFormControl) with the selected value(s) when
nameis set, so the toggle group participates in native form submission (including array-name encoding name[0], name[1] and a required-empty-array fallback input). robonen's ToggleGroupRootProps has noname/requiredprops, no hidden input, and there is no useFormControl/VisuallyHiddenInput utility anywhere in the primitives package.- evidence: reka ToggleGroupRoot.vue lines 96-101 render guarded by
isFormControl = useFormControl(currentElement); FormFieldProps adds name/required (shared/types.ts). robonen ToggleGroupRoot.vue has no name/required props and grep for useFormControl/VisuallyHiddenInput/closest('form') across src returns nothing.
- evidence: reka ToggleGroupRoot.vue lines 96-101 render guarded by
Major gaps:
- 🔶 (api-surface) Values are restricted to
stringonly and compared by identity. reka supports AcceptableValue = string | number | bigint | Record<string,any> | null for both single and multiple, and compares with deepisEqual(ohash) via isValueEqualOrExist, so object/number/bigint values and value-based equality work. - 🔶 (types) No automatic single/multiple type inference or coherence validation. reka infers
typefrom whether modelValue/defaultValue is an array and emits dev-time errors when type and value shape disagree; robonen requires an explicittypeprop (defaults to 'single') and performs no validation. Worse, robonen's typing letsmodelValue: string | string[]be passed with type 'single', which silently breaks. - 🔶 (rtl-i18n) Global/inherited direction is ignored. reka resolves
dirvia useDirection(propDir) so it inherits from ConfigProvider when the prop is omitted; robonen hardcodes the default to 'ltr' and never consults useConfig().dir even though a config-provider with a reactivedirexists in the package. - 🔶 (code-quality) robonen does not reuse its own RovingFocusGroup/RovingFocusItem primitive (which already implements entry-focus, Shift+Tab, mousedown, PageUp/PageDown, modifier guards, data-active prioritization). The toggle-group reimplements a thinner roving algorithm inline, so it permanently lags the dedicated primitive. reka composes RovingFocusGroup/RovingFocusItem and gets all of that for free.
Minor gaps (6):
- ▫️ (keyboard) Keyboard navigation is missing PageUp/PageDown (jump to first/last). reka's RovingFocus maps PageUp->first and PageDown->last in MAP_KEY_TO_FOCUS_INTENT; robonen's inline rovingKeyToAction only handles Arrow keys + Home/End.
- ▫️ (keyboard) Navigation does not guard against modifier keys, so Ctrl/Meta/Alt/Shift + Arrow still hijack focus and call preventDefault. reka returns early on event.metaKey/ctrlKey/altKey (and shiftKey unless allowShiftKey).
- ▫️ (keyboard) No Shift+Tab tab-out handling or group-level entry focus. reka's RovingFocusGroup tracks isTabbingBackOut (onItemShiftTab) and sets the group container tabindex to -1 while tabbing out, plus an onEntryFocus mechanism that focuses the active/highlighted/current item with preventScrollOnEntryFocus support when focus enters the group. robonen has none of this — focus only moves between items, the group container is not part of the tab sequence, and there is no entry-focus prioritization.
- ▫️ (edge-cases) Mousedown focus handling for Safari is absent. reka's RovingFocusItem focuses on mousedown (Safari does not focus buttons on click) and prevents focusing non-focusable items on mousedown; robonen's Item only has @click and @keydown.
- ▫️ (code-quality) ToggleGroupItem is not composed from a Toggle primitive, so it does not inherit Toggle's slot props or future Toggle features. reka's item wraps the Toggle component and forwards its slot props (modelValue/state/pressed/disabled). robonen's item only exposes
:pressedand reimplements the toggle button inline, duplicating logic and diverging from its own existing Toggle primitive. - ▫️ (edge-cases) The deep watch on modelValue does a manual shallow array diff and returns early when v is undefined, which means an external reset to undefined cannot clear the controlled value. reka uses useVModel (passive/deep) which correctly tracks controlled undefined and deep object changes.
Recommendations to make robonen strictly better:
- Add form integration: introduce
name/requiredprops (FormFieldProps-equivalent) on ToggleGroupRoot, auseFormControl(currentElement)composable (closest('form'), default true for SSR), and a VisuallyHiddenInput bubble component that emits the selected value(s) with array-name encoding and a required-empty fallback. GrabcurrentElementfrom useForwardExpose in the Root to feed useFormControl. - Generalize value type from
stringto an AcceptableValue (string | number | bigint | Record<string,any> | null) and replace identity comparisons (.includes / ===) with a deep-equality helper (port reka's isValueEqualOrExist + isEqual). Update ToggleGroupItemProps.value and the context value type accordingly. - Replace the explicit-only
typewith discriminated SingleOrMultipleProps typing and add a useSingleOrMultipleValue-style composable that infers type from modelValue/defaultValue and logs dev-time coherence errors; this also fixes the unsoundmodelValue: string | string[]typing for single mode. - Consume the global direction: default
dirto useConfig().dir (via a useDirection-style wrapper) instead of the hardcoded 'ltr' literal so RTL inherits from ConfigProvider. - Refactor ToggleGroupRoot/Item to compose the existing RovingFocusGroup/RovingFocusItem primitives (conditionally on
rovingFocus) instead of the inline utils/roving-focus algorithm. This instantly adds PageUp/PageDown, modifier-key guards, Shift+Tab tab-out, entry-focus prioritization (data-active/highlighted/current), Safari mousedown focus, preventScrollOnEntryFocus, and group-level tabindex management, and removes duplicated logic. - Compose ToggleGroupItem from the existing Toggle primitive and forward its slot props (modelValue/state/pressed/disabled) so the item stays in sync with Toggle and exposes richer slot data.
- Fix the controlled watch so an external reset of modelValue to undefined clears the value (do not early-return on undefined), and use deep equality when diffing object values.
- Add PageUp/PageDown handling (and modifier-key guards) to utils/roving-focus.rovingKeyToAction if the inline path is retained instead of migrating to the primitive.
Where robonen is already better:
- ✅ Items are sourced from a real Collection (useCollectionProvider/getItems(true)) so DOM order survives v-for reorders, and the Root computes the enabled-items list once and reuses it for both navigation and tab-stop resolution. reka's RovingFocus also uses a Collection, so this is parity, but robonen keeps it self-contained without the extra RovingFocusGroup component overhead.
- ✅ ToggleGroupRoot exposes a
valueChangeemit in addition toupdate:modelValue. reka only emitsupdate:modelValue(ToggleGroupRoot.vue ToggleGroupRootEmits), so robonen gives consumers a dedicated change event without needing a watcher. - ✅ The Root default slot is given
:value="localValue"(always the normalized string[] of selected values), and each Item slot receives:pressed. reka's Root slot givesmodel-value(raw) and the Item slot just forwards Toggle's slot props; robonen's normalized array slot is slightly more predictable to consume. - ✅ robonen sets
role="radiogroup"on the Root androle="radio"for single-type items, which is the more semantically correct ARIA pattern for single-select. reka hardcodesrole="group"on the Root for both single and multiple and relies on Toggle'saria-pressedeven in single mode (no radio semantics), so robonen's single-mode accessibility semantics are actually better here.
number-field — reka-better
Parts reka has that robonen lacks: VisuallyHiddenInput (form submission, rendered inside Root), usePressedHold composable (press-and-hold auto-repeat), useNumberFormatter / useNumberParser (Intl formatting/parsing), handleDecimalOperation (decimal-safe arithmetic), injectNumberFieldRootContext exported from index for external composition Parts robonen has that reka lacks: Exposed imperative methods (increment/decrement/setValue) on Root via defineExpose
Both libraries ship the same four parts (Root, Input, Increment, Decrement) and cover core keyboard handling (Arrow/Page/Home/End) and ARIA spinbutton wiring. robonen is genuinely leaner in its reactivity model, exposes imperative methods on Root, and has a cleaner null empty-state contract. However reka-ui is currently materially ahead on substance: it has real form integration via a VisuallyHiddenInput plus name/required/id FormFieldProps; full Intl number formatting/parsing with locale and formatOptions; beforeinput keystroke validation; press-and-hold auto-repeat steppers; mouse-wheel support with invert/disable options; step snapping with decimal-safe arithmetic; at-boundary isIncrease/DecreaseDisabled with per-button disabled; commit-on-blur/Enter reformatting; focusOnChange; role='group'; labeled (not aria-hidden) stepper buttons; and proper Primitive/asChild polymorphism plus inheritAttrs handling. robonen also drops asChild on Root and hardcodes the Input as a literal , and it hides the stepper buttons from assistive tech. Net: reka-better today, with a clear, concrete path to make robonen strictly better.
Major gaps:
- 🔶 (forms) No form integration / hidden input. reka renders when isFormControl && name, submitting the value with the surrounding form. robonen's NumberFieldInput is the actual focusable spinbutton with role=spinbutton, type defaults to nothing/input — but it puts name/required on a role=spinbutton input, and there is NO hidden native number input for reliable form submission, and Root has no name/required/id FormFieldProps at all.
- 🔶 (features) No internationalized number formatting/parsing. reka supports formatOptions: Intl.NumberFormatOptions and locale, using @internationalized/number NumberFormatter/NumberParser for currency, percent, grouping separators, decimal localization, and computing inputMode (numeric vs decimal) from resolved fraction digits.
- 🔶 (edge-cases) No input validation on keystroke. reka uses @beforeinput with numberParser.isValidPartialNumber(nextValue, min, max) to reject invalid characters before they enter the field; robonen lets any text be typed and only nulls it out after the fact.
- 🔶 (features) No press-and-hold auto-repeat on increment/decrement buttons. reka's usePressedHold repeats every 60ms after a 400ms initial delay while the pointer is held, and exposes data-pressed and userSelect:none. robonen buttons only fire once per click.
- 🔶 (features) No mouse-wheel support. reka increments/decrements on wheel scroll when focused, with disableWheelChange and invertWheelChange props and trackpad axis-magnitude heuristics. robonen has no wheel handling.
- 🔶 (edge-cases) No step snapping. reka snaps the value to the nearest valid step boundary (snapValueToStep) with a stepSnapping prop to disable it; robonen only clamps to min/max and never snaps off-grid values to the step grid.
- 🔶 (edge-cases) Decimal-safe arithmetic missing. reka uses handleDecimalOperation to avoid floating-point drift (e.g. 0.1+0.2) when stepping and when computing isIncrease/DecreaseDisabled. robonen does base + delta directly, so step=0.1 accumulates 0.30000000000000004.
- 🔶 (features) Increment/Decrement buttons cannot reflect at-boundary disabled state and have no per-button disabled prop. reka computes isIncreaseDisabled/isDecreaseDisabled (value at max/min) and accepts a local disabled prop, disabling the button and exposing data-disabled when the next step would exceed the bound. robonen buttons are only disabled by global disabled/readonly.
- 🔶 (accessibility) Buttons are aria-hidden='true' in robonen, removing them entirely from the accessibility tree, whereas reka exposes them with aria-label='Increase'/'Decrease'. Hiding interactive controls from AT is worse for SR users who navigate by control.
- 🔶 (api-surface) as-child not forwarded on Root in robonen. reka passes :as-child="asChild" to its Primitive; robonen Root passes :as only and never forwards asChild from PrimitiveProps, so polymorphic asChild rendering on the group is broken.
- 🔶 (api-surface) Input/Increment/Decrement do not forward asChild either, and the Input is a hardcoded element rather than Primitive, so as/asChild on the Input is ignored. reka's Input is a Primitive (as='input' default) supporting polymorphism.
Minor gaps (6):
- ▫️ (accessibility) Input is not natively focusable/tabbable with explicit tabindex=0, and lacks autocorrect/spellcheck/aria-roledescription. reka sets tabindex=0, autocorrect='off', spellcheck='false', aria-roledescription='Number field' for better SR/typing behavior.
- ▫️ (accessibility) Root is missing role='group'. reka sets role='group' on the Root container so the spinbutton + buttons are exposed as a labeled group. robonen Root renders a bare Primitive with only data-* attributes.
- ▫️ (edge-cases) No commit-on-blur / commit-on-Enter reformatting. reka calls applyInputValue on @change, @keydown.enter and @blur to re-clamp, snap, and reformat the displayed text; robonen commits live on every input and never reformats/snaps on blur.
- ▫️ (keyboard) No focusOnChange behavior. reka refocuses the input when value changes via buttons (focusOnChange default true) so keyboard users keep context after clicking a stepper. robonen does not refocus.
- ▫️ (edge-cases) contextmenu suppression on hold missing. reka adds @contextmenu.prevent on the stepper buttons so a long press does not pop the context menu mid-hold. robonen buttons have no such handler.
- ▫️ (api-surface) Root does not forward $attrs with inheritAttrs:false, and Input does not v-bind merge external props cleanly. reka sets defineOptions({inheritAttrs:false}) and v-bind="$attrs" on Root so attrs land on the primitive group rather than being lost/duplicated. robonen Root uses default attr inheritance with a manual slot only.
Recommendations to make robonen strictly better:
- Render a VisuallyHiddenInput (robonen already has a VisuallyHidden component to build on) inside Root gated on form presence, and add name/required/id FormFieldProps to NumberFieldRoot so the value participates in native form submission; move name/required off the spinbutton input.
- Add a usePressedHold-style composable (400ms initial, ~60ms repeat, pointerdown/up/cancel) to NumberFieldIncrement/Decrement, expose data-pressed and set userSelect:none while held, and add @contextmenu.prevent.
- Add beforeinput validation that parses the prospective value and preventDefaults invalid characters, plus commit-on-blur and commit-on-Enter that re-clamp, snap, and reformat the display.
- Introduce decimal-safe arithmetic (handleDecimalOperation equivalent) for increment/decrement and add snapValueToStep with a stepSnapping prop so values align to the step grid.
- Compute isIncreaseDisabled/isDecreaseDisabled in Root (value at/over bound for next step), provide them via context, add a per-button disabled prop, and disable/data-disabled the buttons accordingly.
- Add wheel support with disableWheelChange and invertWheelChange props and trackpad axis-magnitude heuristics on the input.
- Add Intl number formatting/parsing: formatOptions and locale props, a textValue derived from a formatter, and derive inputmode (numeric vs decimal) from resolved maximumFractionDigits instead of hardcoding 'decimal'. Pair with a useLocale that respects RTL/dir from ConfigProvider.
- Improve a11y: set role='group' on Root; stop aria-hidden on the stepper buttons and give them aria-label='Increase'/'Decrease' (configurable); add aria-roledescription='Number field', autocorrect='off', spellcheck='false', and explicit tabindex='0' on the input.
- Make NumberFieldInput a Primitive (as default 'input') and forward asChild on Root/Input/Increment/Decrement; set inheritAttrs:false + v-bind=$attrs on Root so attrs land on the group.
- Add focusOnChange (default true) so clicking the steppers returns focus to the input.
Where robonen is already better:
- ✅ Leaner reactivity: toRef identity passthroughs and a single guarded value ref, with no @vueuse/core useVModel dependency.
- ✅ Exposes increment/decrement/setValue as an imperative API via defineExpose on Root (reka exposes nothing on its Root).
- ✅ Deterministic null empty-state contract (number | null) instead of reka's undefined/NaN mixing.
- ✅ Auto-generates and wires a stable inputId via useId so label association works without consumer-supplied id.
- ✅ Root slot exposes increment/decrement action callbacks for render-prop consumers (reka exposes only modelValue/textValue/readonly).
radio-group — reka-better
Parts reka has that robonen lacks: Radio.vue (standalone radio usable outside a group, with its own value/name/required/checked v-model + form input + select event)
reka-ui's RadioGroup is currently more capable than robonen's on several real axes. Biggest gaps: (1) value typing — reka supports AcceptableValue (number/bigint/object/null with structural isEqual) while robonen is string-only with ===; (2) keyboard — reka handles PageUp/PageDown (first/last), robonen does not; (3) RTL/i18n — reka resolves dir via ConfigProvider (useDirection) while robonen hardcodes 'ltr' and ignores its own config-provider; (4) focus management — reka composes a battle-tested RovingFocusGroup/RovingFocusItem (entry-focus prioritization, Shift+Tab tab-out, Safari mousedown workaround) whereas robonen re-implements roving focus inline and does NOT reuse the RovingFocusGroup it already ships; (5) selection extensibility — reka emits a cancelable select event and routes selection through the element's native click (so change/click events fire), robonen calls setValue directly; (6) forms — reka uses VisuallyHiddenInput with synthetic input/change dispatch, useFormControl gating, and array/object serialization, while robonen renders a plain styled input with no change events and only when name is set; (7) Indicator animation — reka wraps in Presence, robonen uses bare v-if despite having Presence available; plus minor a11y gaps (group-level required not merged into items, no aria-label derivation, Enter not suppressed) and DX gaps (no exported context injectors, thinner defineSlots, no standalone Radio). robonen is genuinely better in a few places: modern defineModel v-model, group-level data-orientation and aria-disabled on the root, a flatter single-element item (no RovingFocusItem+Radio wrapping), and fewer runtime deps / no per-item global window listeners. Net verdict: reka-better until the gaps (especially value types, RovingFocusGroup reuse, dir/ConfigProvider, PageUp/Down, select event, and Presence indicator) are closed.
Major gaps:
- 🔶 (api-surface) reka supports non-string values (string | number | bigint | Record<string,any> | null via AcceptableValue) for both Root modelValue/defaultValue and Item value, using ohash isEqual for comparison. robonen hard-codes value: string on the Item and string|undefined on Root, so number/boolean/object option values are impossible.
- 🔶 (keyboard) reka handles PageUp -> first and PageDown -> last keyboard navigation. robonen's roving-focus util (rovingKeyToAction) only maps ArrowUp/Down/Left/Right + Home/End and returns null for PageUp/PageDown, so those keys do nothing in robonen's RadioGroup.
- 🔶 (rtl-i18n) reka's dir resolves through useDirection(propDir) which falls back to the global ConfigProvider dir; robonen's RadioGroupRoot hardcodes dir = 'ltr' default and never consults useConfig().dir, even though robonen HAS a config-provider exposing dir (and its own RovingFocusGroup already uses useConfig().dir). So an app-wide RTL ConfigProvider is ignored by RadioGroup.
- 🔶 (api-surface) reka emits a cancelable 'select' CustomEvent (RADIO_SELECT) per item before checking, allowing consumers to preventDefault and block selection; robonen has no select emit/hook on RadioGroupItem at all.
- 🔶 (forms) reka wires form integration through VisuallyHiddenInput which dispatches synthetic input/change events on value change (VisuallyHiddenInputBubble watch -> native setter + dispatch 'input'/'change'), enabling native form validation and event-driven listeners; it also supports array/object/required-empty value serialization. robonen renders a plain via inline styles, mirrors value but dispatches no input/change events and cannot serialize non-primitive values.
- 🔶 (features) reka's RadioGroupIndicator wraps content in with the present prop, enabling exit/enter animations and forceMount-controlled animated unmount. robonen's RadioGroupIndicator uses a plain v-if (forceMount || item.checked.value) with no Presence, so it cannot animate the indicator out (it unmounts immediately) despite robonen having a Presence component available.
- 🔶 (features) reka's roving focus is provided by a shared RovingFocusGroup/RovingFocusItem that handles entry-focus candidate prioritization (active/highlighted/current/first), Shift+Tab tabbing-out (isTabbingBackOut), focusableItemsCount-driven group tabindex, and a Safari mousedown focus workaround. robonen's RadioGroup re-implements roving focus inline (onItemKeyDown + isTabStop computed) and does NOT use its own RovingFocusGroup, so it lacks entry-focus prioritization, Shift+Tab tab-out handling, and the Safari mousedown focus workaround that reka inherits.
Minor gaps (9):
- ▫️ (accessibility) reka's RadioGroupItem.required = computed(() => rootContext.required.value || props.required) so the group-level required propagates to each radio's aria-required and the native input. robonen's RadioGroupItem applies only its own props.required (:aria-required="required || undefined") and never reads ctx.required, so a group-level required does NOT mark items as required for AT.
- ▫️ (accessibility) reka computes an aria-label for the radio from an associated
- ▫️ (forms) reka guards the hidden form input behind useFormControl(currentElement) so it only renders when actually inside a (and bubbles correctly under SSR); robonen renders the hidden input whenever name is set regardless of whether the group is inside a form, adding a stray focusable-by-name radio input to the document.
- ▫️ (edge-cases) reka's RadioGroupItem only fires selection-on-focus when an arrow key actually drove the focus (isArrowKeyPressed gate + handleFocus -> click), and routes selection through the element's native .click() so the radio change/click event fires for downstream listeners. robonen's focusIndex calls setValue directly and never routes through the element's click, so arrow selection bypasses the native click/change pathway entirely.
- ▫️ (features) reka provides a standalone Radio.vue component (a self-contained radio with its own value/name/required/checked v-model and form input) usable independently; robonen exposes no standalone Radio part — only Root/Item/Indicator.
- ▫️ (code-quality) reka's RadioGroupIndicator and RadioGroupItem set inheritAttrs:false and forward $attrs onto the Primitive, giving precise attribute placement; robonen's RadioGroupIndicator/Item rely on default attr inheritance and do not declare inheritAttrs handling, which can mis-place attrs when the Primitive renders fragments or multiple roots.
- ▫️ (api-surface) reka exposes injectRadioGroupRootContext and injectRadioGroupItemContext from the package index for advanced composition; robonen's context (provideRadioGroupContext/useRadioGroupContext, item context) is defined in context.ts but NOT re-exported from radio-group/index.ts, so consumers cannot build custom parts against the context.
- ▫️ (types) reka declares typed defineSlots for Root (modelValue), Item (checked/required/disabled), and Indicator, giving full slot-prop type inference. robonen's Root slot exposes only value, Item slot exposes only checked, and neither declares defineSlots, so slot props are less typed and reka surfaces required/disabled to the item slot which robonen omits.
- ▫️ (keyboard) reka's RadioGroupItem suppresses Enter from submitting/activating via @keydown.enter.prevent (radios should not respond to Enter per WAI-ARIA). robonen's RadioGroupItem has no Enter handling, so when as='button', pressing Enter triggers the native button click -> selection, which is non-conformant radio behavior.
Recommendations to make robonen strictly better:
- Generalize value types to an AcceptableValue-style union (string | number | bigint | boolean | Record<string,any> | null) on both RadioGroupRootProps (modelValue/defaultValue) and RadioGroupItemProps.value, and compare with a deep/structural equality helper instead of === so object and numeric option values work.
- Add PageUp -> first and PageDown -> last to src/utils/roving-focus.ts rovingKeyToAction (return { delta: 0, absolute: 'home' } / 'end'), then they will flow through onItemKeyDown automatically.
- Make RadioGroupRoot resolve dir via useConfig().dir as a fallback (toRef(() => dir ?? config.dir.value)) so a global ConfigProvider RTL setting is honored, matching the existing RovingFocusGroup behavior.
- Refactor RadioGroupRoot/Item to compose the existing RovingFocusGroup + RovingFocusItem (as-child) instead of re-implementing roving focus inline; this gains entry-focus prioritization, Shift+Tab tab-out handling, focusableItemsCount group tabindex, PageUp/PageDown, and the Safari mousedown workaround for free.
- Add a cancelable 'select' event to RadioGroupItem (emit before setValue, honor defaultPrevented) so consumers can intercept/veto selection.
- Merge group-level required into each item: in RadioGroupItem compute required = ctx.required.value || props.required and use it for aria-required and the form input.
- Add @keydown.enter.prevent (or explicit Enter no-op) to RadioGroupItem so Enter does not activate radios when as='button'.
- Wrap RadioGroupIndicator content in the existing Presence component (:present="forceMount || item.checked.value") instead of v-if so the indicator can animate out.
- Gate the hidden form input behind a useFormControl(currentElement)-style check so it only renders inside a , and dispatch native input/change events (or reuse a VisuallyHiddenInput-style synthetic-setter approach) so native validation and change listeners fire; support non-primitive value serialization.
- Add an optional id prop to RadioGroupItem and derive aria-label from an associated
- Set inheritAttrs:false and forward $attrs in RadioGroupItem and RadioGroupIndicator for precise attribute placement.
- Re-export the context injectors (useRadioGroupContext/useRadioGroupItemContext) and add defineSlots typing exposing checked/required/disabled (item) and modelValue (root) for advanced composition and better slot types.
- Drop the redundant custom valueChange emit (or keep it but also document update:modelValue) — defineModel already emits update:modelValue; align the emits declaration so the public event surface is clear, and consider offering a standalone Radio part for use outside a group.
Where robonen is already better:
- ✅ Root uses defineModel<string|undefined>() for v-model, which is the modern idiomatic Vue 3.4+ API; reka still wires v-model manually through useVModel from @vueuse/core.
- ✅ Root provides data-orientation on the radiogroup element (:data-orientation="orientation"); reka's RadioGroupRoot only sets data-orientation on items (via RovingFocusItem), not on the group element itself, so robonen gives more styling hooks at the group level.
- ✅ Root sets aria-disabled on the radiogroup element when disabled; reka's RadioGroupRoot only emits data-disabled and never sets aria-disabled on the group (it relies on per-item disabled), so robonen exposes group-level disabled state to AT slightly better at the root.
- ✅ Item sets data-disabled on the radio element itself (:data-disabled="isDisabled ? '' : undefined"); reka's RadioGroupItem delegates disabled rendering to the inner Radio.vue but the RovingFocusItem layer adds extra DOM/indirection — robonen keeps a flatter single-element structure (no wrapping RovingFocusItem + Radio split), which is less DOM and fewer components per item.
- ✅ No external runtime dependency on ohash (isEqual) or @vueuse/core useVModel/useEventListener for core behavior; robonen's keydown handling is a single delegated handler rather than reka attaching global window keydown/keyup listeners per item (reka's RadioGroupItem registers useEventListener('keydown'/'keyup') on window for every item, which is O(n) global listeners).
aspect-ratio — reka-better
Both expose a single AspectRatio component (no sub-parts) with the same ratio prop and identical wrapper/inner box technique (relative wrapper with paddingBottom, absolutely-positioned inner). robonen is marginally leaner in two micro-ways: it hoists the static inner style to a shared module constant and adds a data-aspect-ratio hook on the content element. However reka is currently better on substance: (1) reka actually forwards the element ref via :ref="forwardRef" while robonen leaves forwardRef unbound and dead; (2) reka supports asChild/composition while robonen's AspectRatio neither types nor forwards asChild; (3) reka routes $attrs (class/style/id/handlers/ARIA) onto the inner content element via inheritAttrs:false + v-bind, whereas robonen leaks fallthrough attrs onto the outer wrapper; (4) reka's aspect is a computed so runtime ratio changes work, while robonen builds wrapperStyle as a one-time non-reactive object; (5) reka exposes an aspect slot prop and (6) ships an axe a11y test, both absent in robonen. The ref-forwarding, attrs routing, and reactivity issues are functional regressions, so the honest verdict is reka-better until those are addressed.
Major gaps:
- 🔶 (api-surface) robonen does NOT forward the underlying DOM element ref to consumers. It calls useForwardExpose() and destructures forwardRef (AspectRatio.vue:17) but never binds :ref="forwardRef" on the . The forwardRef is dead code, so the component's exposed $el/currentElement chain is never populated from the inner element and template-ref consumers cannot reach the content node.
- 🔶 (api-surface) No asChild support. robonen's PrimitiveProps (primitive/Primitive.ts:7-9) only declares
asand never declares/forwardsasChild, so consumers cannot do to merge props onto a provided child. (robonen's Primitive technically supports as="template" slot-merging, but AspectRatio neither documents nor forwards an asChild prop, and the type surface lacks it.) - 🔶 (edge-cases) Fallthrough attributes ($attrs: class, style, id, event handlers, ARIA) are applied to the OUTER wrapper div instead of the inner content element. robonen sets no inheritAttrs:false and never v-binds $attrs onto the inner Primitive, so Vue auto-forwards consumer attrs to the plain wrapper , not to the element wrapping the actual content.
- 🔶 (performance) Dynamic ratio is not reactive. robonen computes wrapperStyle as a plain object literal once during setup using the destructured
ratio(AspectRatio.vue:21-25); the paddingBottom string is evaluated a single time and never recomputes, so changing :ratio at runtime will not update the box.
Minor gaps (2):
- ▫️ (api-surface) No slot prop exposing the resolved aspect value. reka exposes the computed aspect percentage to the default slot so consumers can react to it; robonen renders a bare with no slot props.
- ▫️ (accessibility) No accessibility regression test. reka ships a vitest-axe assertion (toHaveNoViolations) guarding the rendered structure; robonen's tests only check padding/position/data-attr and never run an a11y audit.
Recommendations to make robonen strictly better:
- Bind the inner Primitive with :ref="forwardRef" so useForwardExpose actually forwards the content element and exposed API; right now the call is dead code. (AspectRatio.vue: add :ref="forwardRef" on the .)
- Make the wrapper style reactive: replace the const wrapperStyle object with a computed (e.g. computed(() => ({ position: 'relative', width: '100%', paddingBottom:
${(1 / ratio) * 100}%}))) so runtime ratio changes re-render the box. - Add asChild to PrimitiveProps (or at minimum forward an asChild prop on AspectRatio that maps to the inner Primitive's template/Slot path) so consumers can merge the ratio container onto a provided child element, matching reka's composition model.
- Set inheritAttrs: false (via defineOptions) and v-bind="$attrs" on the inner Primitive so consumer class/style/id/event/ARIA attrs land on the content element rather than the outer wrapper div.
- Expose the resolved aspect percentage as a slot prop (defineSlots + ) for parity and to let consumers react to the computed ratio.
- Add a vitest-axe accessibility test mirroring reka's, asserting toHaveNoViolations on a typical img-in-AspectRatio render.
Where robonen is already better:
- ✅ Hoists the inner element's static style into a single module-level constant INNER_STYLE (AspectRatio.vue:29-32), so the absolute/inset:0 style object is allocated once and shared across all instances instead of being re-created per render. reka inlines the inner style as a string literal each render (AspectRatio.vue:48).
- ✅ Adds a data-aspect-ratio="true" attribute on the inner content element (AspectRatio.vue:37), giving a styling/test hook on the actual content box. reka only emits a data attribute on the outer wrapper (data-reka-aspect-ratio-wrapper) and nothing on the inner element.
- ✅ Uses an object style binding (:style="wrapperStyle") for the wrapper rather than reka's hand-built CSS string template literal (
position: relative; width: 100%; padding-bottom: ${aspect}%), which avoids string-to-style re-parsing and is less error prone — though see the reactivity caveat in the gaps.
scroll-area — robonen-better-with-gaps
Parts reka has that robonen lacks: ScrollAreaScrollbarGlimpse.vue (glimpse visibility behavior + state machine), ScrollAreaScrollbarX.vue and ScrollAreaScrollbarY.vue (axis-specific layers that register scrollbar element to root and set thumb-size + RTL position CSS vars), ScrollAreaCornerImpl.vue (RTL-aware corner impl, split from Corner) Parts robonen has that reka lacks: ScrollAreaScrollbarImpl.vue carries the ARIA scrollbar role/state and onKeyDown keyboard handler (reka's Impl has neither), context.ts second context (ScrollAreaScrollbarContext) with onThumbChange/onThumbPointerDown/onThumbPositionChange exported publicly, viewportId in root context for aria-controls wiring (no reka equivalent)
robonen's scroll-area is architecturally close to reka-ui and is meaningfully more accessible: it adds the full WAI-ARIA scrollbar pattern (role=scrollbar, aria-orientation/valuemin/max/now, aria-controls to the viewport, aria-disabled, roving tabindex) and a complete keyboard handler (Arrow ±5%, PageUp/Down, Home/End, RTL-aware horizontal) — reka has zero ARIA and no keyboard support, only tabindex=0 on the viewport. However robonen currently ships several real functional regressions versus reka: (1) the thumb has no size along the track because nothing sets the --scroll-area-thumb-width/height CSS vars (getThumbSize is exported but never used for sizing); (2) the Corner can never render because ctx.scrollbarX/scrollbarY are never populated; (3) wheel-over-scrollbar can't stop page scroll because the listener is @wheel.passive while the code calls preventDefault; (4) no RTL flip for scrollbar position; (5) no ref forwarding through scrollbar wrappers; plus missing 'glimpse' type, no viewport tabindex, no scrollBehavior handling during drag, broken text-ellipsis case, static thumb data-state, and no useNonce inheritance. Verdict: robonen-better-with-gaps — it wins decisively on a11y/keyboard but must fix the thumb-size, corner-wiring, wheel, and RTL bugs (the first two are essentially broken core behavior) before it can be called strictly better.
Critical gaps:
- ⛔ (features) Thumb size along the scroll axis is never set. ScrollAreaThumb.vue styles width/height with var(--scroll-area-thumb-width)/var(--scroll-area-thumb-height), but NO component ever defines those CSS vars (getThumbSize in utils.ts is exported yet never called to set them). In reka, ScrollAreaScrollbarX.vue sets --reka-scroll-area-thumb-width and ScrollAreaScrollbarY.vue sets --reka-scroll-area-thumb-height from getThumbSize(sizes) on the scrollbar element. Result: robonen's thumb collapses to 0 length along the track unless the user hand-styles it.
- evidence: reka ScrollAreaScrollbarX.vue/ScrollAreaScrollbarY.vue style binding
--reka-scroll-area-thumb-width/height = getThumbSize(sizes); robonen ScrollAreaThumb.vue uses var(--scroll-area-thumb-width/height) but grep shows getThumbSize is never invoked to set them.
- evidence: reka ScrollAreaScrollbarX.vue/ScrollAreaScrollbarY.vue style binding
- ⛔ (features) ScrollAreaCorner never renders because its required inputs are never populated. Corner.measure() reads ctx.scrollbarX.value / ctx.scrollbarY.value to compute width/height, but nothing ever assigns ctx.scrollbarX / ctx.scrollbarY in robonen (they stay null forever; the registerScrollbar emit only feeds a LOCAL scrollbarEl in ScrollAreaScrollbarVisible.vue, never the root context). So width/height stay 0, hasSize is always false, and the corner is never displayed.
- evidence: reka ScrollAreaScrollbarX.vue onMounted calls rootContext.onScrollbarXChange(el) and ScrollbarY calls onScrollbarYChange(el); robonen context.ts defines scrollbarX/scrollbarY refs and Root provides them, but grep shows no assignment of ctx.scrollbarX/scrollbarY anywhere. ScrollAreaCorner.vue measure() depends on them.
Major gaps:
- 🔶 (edge-cases) Wheel-over-scrollbar cannot prevent page scroll. ScrollAreaScrollbarImpl.vue binds @wheel.passive, so the listener is passive:true and the event.preventDefault() inside onWheelScroll (ScrollAreaScrollbarVisible.vue, guarded by isScrollingWithinScrollbarBounds) is a no-op and logs a console warning. reka adds the wheel listener on document with { passive: false } and gates on scrollbar.contains(target), so it can actually preventDefault and stop the window from scrolling.
- 🔶 (rtl-i18n) RTL scrollbar positioning is not handled. robonen ScrollAreaScrollbarImpl.vue hardcodes horizontal { left:0, right:var(--corner-width) } and vertical { right:0 } regardless of dir. reka's ScrollAreaScrollbarX.vue and ScrollAreaScrollbarY.vue flip left/right based on rootContext.dir (vertical bar moves to the left in RTL; horizontal corner gap swaps sides). robonen keeps the vertical scrollbar on the right even in RTL, which is visually wrong.
- 🔶 (api-surface) Template ref on does not forward to the DOM element. reka forwards a ref through every wrapper (forwardRef + :ref on ScrollbarHover/Scroll/Auto/Glimpse/Visible/X/Y), so a ref on ScrollAreaScrollbar resolves to the real scrollbar element. In robonen none of the wrappers (ScrollAreaScrollbar.vue, ScrollAreaScrollbarHover/Auto/Scroll/Visible.vue) call useForwardExpose/forwardRef — only the leaf Impl does — so consumers placing a ref on ScrollAreaScrollbar get nothing useful.
Minor gaps (7):
- ▫️ (features) No 'glimpse' visibility type. reka adds ScrollType 'glimpse' (types.ts), a dedicated ScrollAreaScrollbarGlimpse.vue (state machine that briefly reveals scrollbars on pointer enter then auto-hides), and a branch in ScrollAreaScrollbar.vue. robonen's ScrollAreaType is only 'auto'|'always'|'scroll'|'hover' and has no Glimpse component.
- ▫️ (keyboard) Viewport is not keyboard-focusable. reka ScrollAreaViewport.vue sets :tabindex="0" on the scrollable viewport so keyboard users can focus the scroll region and use native arrow-key scrolling (works even when no visible scrollbar is rendered). robonen's ScrollAreaViewport.vue sets no tabindex; robonen instead makes the scrollbar focusable, but when a scrollbar is hidden/non-interactive there is no focusable element to drive scrolling, and the common 'focus the panel and scroll' pattern is lost.
- ▫️ (edge-cases) During thumb drag reka sets viewport.style.scrollBehavior='auto' (and restores it on pointerup) so a user's CSS scroll-behavior: smooth doesn't lag/jank the thumb. robonen instead sets viewport.style.pointerEvents='none' and never touches scrollBehavior, so dragging a smooth-scroll viewport will be janky.
- ▫️ (edge-cases) Viewport content wrapper does not enable text-overflow: ellipsis when horizontal scrolling is off. reka conditionally applies minWidth: scrollbarXEnabled ? 'fit-content' : undefined so a no-horizontal-scroll viewport can ellipsize text. robonen hardcodes the inner wrapper to { minWidth: '100%', display: 'table' } always, which forces width and breaks text-overflow: ellipsis in the common vertical-only case.
- ▫️ (features) ScrollAreaThumb data-state is static. reka ScrollAreaThumb.vue binds :data-state="hasThumb ? 'visible' : 'hidden'"; robonen ScrollAreaThumb.vue hardcodes data-state="visible", so styling/animation hooks that key off the hidden state on the thumb won't work.
- ▫️ (ssr) reka inherits nonce from ConfigProvider via useNonce(propNonce). robonen ScrollAreaViewport.vue accepts a nonce prop but uses it raw (:nonce="nonce") with no ConfigProvider fallback, so a globally-configured CSP nonce is not picked up automatically.
- ▫️ (edge-cases) reka's Corner respects type: hasCorner = type !== 'scroll' && bothScrollbarsVisible, so no corner is shown for type='scroll' (where scrollbars overlay). robonen ScrollAreaCorner.vue gates only on hasBoth (both enabled) && hasSize, with no type check, so it would attempt a corner even for type='scroll'.
Recommendations to make robonen strictly better:
- Set the thumb-size CSS vars: in the scrollbar layer (split into ScrollAreaScrollbarX/Y or compute in ScrollAreaScrollbarVisible/Impl), bind style --scroll-area-thumb-width =
${getThumbSize(sizes)}pxfor horizontal and --scroll-area-thumb-height for vertical, so ScrollAreaThumb actually gets a length along the track. This is the single most important fix. - Wire scrollbar elements into the root context: have the scrollbar (Visible/X/Y or via Impl's registerScrollbar) call ctx-level onScrollbarXChange/onScrollbarYChange (add these to ScrollAreaRootContext like reka) so ScrollAreaCorner.measure() can read real elements and the corner renders.
- Fix wheel: remove @wheel.passive and instead attach a document-level wheel listener with { passive: false } gated on scrollbar.contains(target) (like reka ScrollAreaScrollbarImpl), so onWheelScroll's preventDefault actually stops page scroll. Remove the no-op preventDefault under a passive listener.
- Make scrollbar positioning RTL-aware in ScrollAreaScrollbarImpl: branch left/right on ctx.dir.value for both vertical (bar on left in RTL) and horizontal (swap the corner gap side), mirroring reka ScrollAreaScrollbarX/Y.
- Add forwardRef chaining: call useForwardExpose() and bind :ref="forwardRef" on ScrollAreaScrollbar and each wrapper (Hover/Auto/Scroll/Visible) so a template ref on ScrollAreaScrollbar resolves to the underlying scrollbar element, matching reka.
- Add tabindex=0 to the viewport (and/or expose a configurable tabindex) so keyboard users can focus the scroll region and arrow-scroll natively even when no scrollbar is interactive — keep the existing scrollbar ARIA/keyboard support on top of it.
- Add a 'glimpse' ScrollAreaType and a ScrollAreaScrollbarGlimpse component (port reka's state machine) for feature parity, plus a small useStateMachine helper to clean up the ad-hoc state in ScrollAreaScrollbarScroll.
- During thumb drag, set viewport.style.scrollBehavior='auto' on pointerdown and restore on pointerup (in addition to or instead of toggling pointerEvents) to avoid jank when consumers use scroll-behavior: smooth.
- Make the inner viewport wrapper minWidth conditional (fit-content only when scrollbarXEnabled, otherwise undefined) and drop the unconditional display:table so text-overflow: ellipsis works in vertical-only scroll areas.
- Bind ScrollAreaThumb :data-state to hasThumb ('visible'/'hidden') instead of hardcoding 'visible'.
- Route the Viewport nonce through useNonce / ConfigProvider so a globally configured CSP nonce is inherited.
- Add a type !== 'scroll' guard to ScrollAreaCorner's render condition to match reka and avoid a corner under overlay-style scroll.
Where robonen is already better:
- ✅ Full WAI-ARIA scrollbar pattern: ScrollAreaScrollbarImpl.vue sets role="scrollbar", aria-orientation, aria-valuemin=0, aria-valuemax=100, dynamic aria-valuenow (computed from scrollPos/maxScroll), aria-disabled, and aria-controls pointing to the viewport id. reka-ui has NONE of these (grep of reka ScrollArea/.vue shows zero role/aria- attributes) — robonen is dramatically more accessible to screen readers here.
- ✅ Complete keyboard support on the scrollbar (ScrollAreaScrollbarImpl.vue onKeyDown): ArrowUp/Down (vertical), RTL-aware ArrowLeft/Right (horizontal), PageUp/PageDown (full viewport jump), Home/End (extremes), Arrow step = 5% of viewport, all with preventDefault. reka-ui implements NO keyboard handler at all for scrollbars.
- ✅ viewportId + aria-controls relationship (context.ts viewportId, Root assigns useId, Viewport applies it, Impl references it) explicitly links scrollbar to the region it controls. reka has no such id wiring.
- ✅ Roving/conditional tabindex on the scrollbar: tabindex=0 only when isInteractive (hasThumb && maxScroll>0), else -1, so non-overflowing scrollbars are not tab stops. reka has nothing equivalent.
- ✅ More robust utils: toInt() returns
|| 0so NaN can't leak (reka's toInt can return NaN); getThumbRatio guards viewport>=content and Number.isFinite (reka can return Infinity when content is 0). - ✅ debounceCallback exposes a .cancel() and every component cleans it up in onScopeDispose (ScrollbarAuto/Scroll), giving deterministic teardown; reka relies on VueUse useDebounceFn without explicit cancel-on-dispose in some paths.
- ✅ Exposes a typed scrollbar context (onThumbChange/onThumbPointerDown/onThumbPositionChange) and ScrollAreaSizes type publicly via index.ts, plus context provide/inject helpers — slightly richer public surface for extension.
dropdown-menu — robonen-better-with-gaps
Parts reka has that robonen lacks: DropdownMenuFilter (filterable menu searchbox part)
Both libraries implement DropdownMenu as a thin wrapper over an internal Menu primitive with the same part set (Root, Trigger, Portal, Content, Item, CheckboxItem, RadioGroup/RadioItem, ItemIndicator, Label, Separator, Group, Arrow, Sub/SubTrigger/SubContent). robonen covers the core behavior well: controlled/uncontrolled open, modal/non-modal content, roving focus with Arrow/Home/End/PageUp/PageDown, RTL-aware sub open/close keys, type-ahead, dismissable layer, body scroll lock, focus guards, item select->close via cancelable ITEM_SELECT, and it even exposes a couple of extra Popper props (sideFlip/alignFlip). However reka is currently better in several concrete, verifiable ways. Most important: (1) robonen's @select preventDefault contract is broken because the emitted event (raw MouseEvent) is not the cancelable ITEM_SELECT event it inspects, so consumers cannot prevent close; (2) the trigger lacks type="button", risking form submission inside a ; (3) there is no DropdownMenuFilter and the Menu layer lacks the content-context plumbing for one; (4) typeahead Space handling prematurely selects multi-word items; (5) the submenu safe-triangle has no pointer-direction intent; (6) focus-return to the trigger is unconditional rather than suppressed on outside/right-click interaction. Minor gaps: conditional aria-controls, Sub defaultOpen + open slot prop, dropdown-level CSS sizing vars, useForwardExpose on sub-components, a richer root context, and pointerup drag-select. The only missing PART is Filter; the rest are behavioral/forms/a11y refinements. Net: robonen is close and in places ahead, but not yet strictly better.
Critical gaps:
- ⛔ (api-surface) @select preventDefault does not prevent menu close because the emitted event is not the inspected cancelable event
- evidence: robonen menu/MenuItem.vue handleSelect: dispatches a CustomEvent ITEM_SELECT, then emit('select', event) with the raw MouseEvent, then checks selectEvent.defaultPrevented; reka MenuItem.vue emits the same itemSelectEvent it later inspects
Major gaps:
- 🔶 (forms) DropdownMenuTrigger missing type=button -> form submission risk
- 🔶 (features) No DropdownMenuFilter part nor Menu content-context filter hooks
- 🔶 (keyboard) Space selects multi-word items during typeahead
- 🔶 (edge-cases) Submenu safe-triangle lacks pointer-direction intent
- 🔶 (accessibility) Focus-return to trigger is unconditional, ignoring outside/right-click interaction
Minor gaps (6):
- ▫️ (accessibility) Unconditional aria-controls even when menu is closed
- ▫️ (api-surface) DropdownMenuSub lacks defaultOpen and open slot prop
- ▫️ (features) No dropdown-level CSS sizing/animation vars (trigger-width/height/transform-origin)
- ▫️ (api-surface) Sub-component wrappers don't call useForwardExpose
- ▫️ (edge-cases) No pointerup drag-select / Firefox text-selection fix on items
- ▫️ (api-surface) Root context exposes less than reka
Recommendations to make robonen strictly better:
- Add :type="as === 'button' ? 'button' : undefined" to DropdownMenuTrigger's Primitive (and verify MenuSubTrigger) to prevent accidental form submission when the trigger is a default inside a .
- Fix the select-cancel contract in menu/MenuItem.vue: construct a single cancelable ITEM_SELECT CustomEvent, emit THAT via emit('select', selectEvent), then check selectEvent.defaultPrevented before rootCtx.onClose(). Mirror for keyboard selection so event.preventDefault() in @select reliably keeps the menu open; apply equivalently in MenuCheckboxItem/MenuRadioItem.
- Implement trigger focus-return in DropdownMenuContent (port reka's handleCloseAutoFocus + handleInteractOutside): focus the trigger on closeAutoFocus only when the user did not interact outside / right-click / non-modal-dismiss, otherwise preventDefault and let the UA keep focus. Track a hasInteractedOutsideRef flag wired through @interact-outside.
- Add a typing-ahead Space guard to MenuItemImpl.handleKeyDown (skip selection when searchRef !== '' and key === ' ') so item labels containing spaces remain searchable via typeahead.
- Add directional safe-triangle intent for submenus: track pointer X movement (pointerDirRef/lastPointerXRef) via @pointermove on MenuContentImpl and gate onItemEnter/onTriggerLeave on 'pointer moving toward submenu side' in addition to isPointerInGraceArea.
- Add a DropdownMenuFilter part. First extend menu/MenuContentImpl's content-context with highlightedElement, onKeydownNavigation, onKeydownEnter, filterElement and onFilterElementChange (plus pointerenter-to-focus-filter), then add a Filter wrapper with role=searchbox, v-model, autoFocus, disabled, aria-activedescendant and Escape-to-clear.
- Make DropdownMenuTrigger's aria-controls conditional on open (set contentId only while open) to match the WAI-ARIA menu-button pattern.
- Add defaultOpen (uncontrolled) support and an
openslot prop to DropdownMenuSub, mirroring DropdownMenuRoot's controlled/uncontrolled pattern. - Expose dropdown-level CSS custom properties on DropdownMenuContent and DropdownMenuSubContent: --primitives-dropdown-menu-trigger-width/-height mapped from --popper-anchor-width/-height (already computed in PopperContent), plus transform-origin and available-width/height, so consumers can size menus to the trigger and animate.
- Call useForwardExpose() (and forward the ref) in all DropdownMenu* sub-component wrappers so template refs/exposed methods of the inner element are accessible, matching reka.
- Enrich DropdownMenuRootContext to also expose open, onOpenChange, onOpenToggle, modal and dir (derived from MenuContext/MenuRootContext) for parity and easier composition.
- Add pointerdown/pointerup drag-select handling to MenuItem(Impl) (re-dispatch click on pointerup when pointerdown started on a different item) to fix drag-across-items selection and the Firefox text-selection edge case.
Where robonen is already better:
- ✅ robonen's DropdownMenuTrigger opens on ArrowUp in addition to ArrowDown/Enter/Space, one more opening affordance than reka.
- ✅ robonen surfaces sideFlip/alignFlip (and prioritizePosition) Popper props through MenuContentImplProps that reka's Menu does not expose.
- ✅ robonen cleans up the typeahead search buffer via onScopeDispose plus an explicit content @blur handler, not just a timer.
tree — robonen-better-with-gaps
Parts reka has that robonen lacks: TreeVirtualizer (TanStack vue-virtual windowing with keyboard + type-ahead hooks)
Both libraries implement a flatten-on-root tree: TreeRoot flattens visible items and TreeItem renders role=treeitem with aria-level/expanded/selected, click + Arrow/Home/End + RTL-aware Left/Right + Enter/Space. robonen's core is leaner and faster (O(1) Set lookups, iterative flatten, hoisted roving opts, stable string keys, data-state/data-level hooks, cleaner v-model). However reka is currently better on several real, verifiable axes that block 'strictly better': it uses RovingFocusGroup for the WAI-ARIA single-tabstop roving tabindex (robonen wrongly puts tabindex=0 on every item - the biggest gap, critical), implements type-ahead, emits aria-setsize/aria-posinset, handles PageUp/PageDown, supports Shift+arrow range selection, selectionBehavior toggle/replace, bubbleSelect with indeterminate tri-state, cancelable select/toggle emits + defineExpose + slot action callbacks, and ships a virtualizer. Fixing the roving tabindex, type-ahead, set/pos a11y attrs, PageUp/Down, range selection, intercept events, and indeterminate/bubble support would make robonen strictly better while keeping its performance edge.
Critical gaps:
- ⛔ (keyboard) No single-tabstop roving tabindex. robonen hardcodes tabindex = isDisabled ? -1 : 0 on EVERY treeitem, so Tab steps through every node and there is no single tab entry point - violating the WAI-ARIA tree pattern. reka wraps items in RovingFocusGroup/RovingFocusItem which sets tabindex 0 only on the current tab stop and -1 on all others, plus handles Shift+Tab to leave the group.
- evidence: robonen TreeItem.vue :tabindex="isDisabled ? -1 : 0"; reka TreeItem.vue uses and RovingFocusItem.vue :tabindex="isCurrentTabStop ? 0 : -1" with context.onItemShiftTab().
Major gaps:
- 🔶 (keyboard) No type-ahead: robonen has no type-to-focus. reka wires useTypeahead/getNextMatch on the root keydown (TreeRoot.vue handleKeydown -> handleTypeaheadSearch, useTypeahead.ts getNextMatch) so pressing letters focuses the next item whose textContent/textValue matches. Type-ahead is part of the WAI-ARIA Tree View keyboard pattern.
- 🔶 (accessibility) Missing aria-setsize and aria-posinset on treeitems. reka computes them per item (aria-setsize = siblings length, aria-posinset = index+1) and binds them. robonen's TreeItem sets aria-level only, so screen readers cannot announce "item N of M" within a group.
- 🔶 (keyboard) No Shift+Arrow / Shift+Home/End range selection. reka maintains a firstValue anchor and implements handleMultipleReplace + findValuesBetween to extend a multi-selection across a contiguous range with Shift+arrows. robonen has no anchor and no range selection at all.
- 🔶 (features) No bubbleSelect (child->parent state propagation) and no indeterminate state. reka supports bubbleSelect (selecting all children selects the parent, deselecting unsets it) and exposes isIndeterminate to render tri-state checkboxes. robonen has propagateSelect (parent->child) but no upward bubbling and no indeterminate concept.
- 🔶 (api-surface) TreeItem emits no cancelable select/toggle events. reka dispatches cancelable CustomEvents (tree.select/tree.toggle) and emits 'select'/'toggle' that callers can event.preventDefault() to intercept before state changes. robonen's TreeItem has no defineEmits, so consumers cannot intercept or veto selection/expansion.
Minor gaps (6):
- ▫️ (keyboard) PageUp/PageDown not handled. reka maps PageUp->first and PageDown->last via MAP_KEY_TO_FOCUS_INTENT, so those keys jump to first/last item. robonen's rovingKeyToAction only recognizes Home/End (Arrow*, Home, End) and never PageUp/PageDown.
- ▫️ (api-surface) No selectionBehavior 'toggle' | 'replace' prop. reka lets consumers choose whether selecting replaces the current selection or toggles. robonen only supports toggle semantics.
- ▫️ (api-surface) TreeItem exposes nothing via defineExpose, and the item slot provides no action callbacks. reka exposes isExpanded/isSelected/isIndeterminate/handleToggle/handleSelect and passes handleSelect/handleToggle into the slot, so a custom node (e.g. a chevron or checkbox) can trigger select/toggle independently. robonen's item slot only passes read-only state.
- ▫️ (performance) No virtualization. reka ships TreeVirtualizer (TanStack vue-virtual) with overscan/estimateSize/textContent props and keyboard/type-ahead integration for huge trees. robonen has no virtualizer, so very large flat lists render every visible node.
- ▫️ (code-quality) Root keydown navigation in robonen relies on reading the DOM aria-level attribute for Left/Right parent/child resolution (next.getAttribute('aria-level')), which is fragile if a consumer overrides as/markup or aria-level. reka resolves parent/child via the in-memory FlattenedItem.level/data-indent collection, decoupling navigation from rendered ARIA attributes.
- ▫️ (keyboard) No focus-entry guard / onEntryFocus behavior. reka's RovingFocusGroup centralizes which item receives focus on group entry; robonen has no entry-focus management, so initial Tab/click focus is whatever the browser picks among the many tabindex=0 items.
Recommendations to make robonen strictly better:
- Adopt single-tabstop roving tabindex for treeitems: reuse the existing robonen RovingFocusGroup/RovingFocusItem (src/roving-focus) or maintain a currentTabStopId so only one item has tabindex=0 and the rest get -1; handle Shift+Tab to exit the tree. This is the most important correctness/a11y fix (currently every item is tabbable).
- Add type-ahead: on root keydown for printable single characters, match against item textContent (or an opt-in getLabel/textValue accessor) using a getNextMatch-style wrap-around search with a 1s reset, then focus the match. Add an optional getLabel?: (item) => string prop for non-text nodes/virtualized cases.
- Emit aria-setsize and aria-posinset: extend FlatItem (utils.ts) with setsize and posinset (siblings length and 1-based index within the sibling group) and bind them in TreeItem.vue so SRs announce position within group.
- Add PageUp/PageDown to rovingKeyToAction (map to absolute 'home'/'end') so the Tree gets first/last jump on those keys; or handle them explicitly in onItemKeyDown.
- Add Shift+Arrow and Shift+Home/End range selection for multiple mode: keep a firstValue/anchor key and a findValuesBetween-style helper over flatItems to set the selected range; respect disabled items.
- Add selectionBehavior?: 'toggle' | 'replace' to TreeRootProps and thread it through select() so consumers can choose replace-on-click semantics.
- Add bubbleSelect (child->parent) plus an indeterminate concept: compute and expose is-indeterminate in the TreeItem slot for tri-state checkbox trees (parent partially selected). Pair it with the existing propagateSelect.
- Make TreeItem emit cancelable select/toggle events (or expose handleSelect/handleToggle and pass them into the slot) so consumers can intercept/preventDefault and trigger actions from custom sub-nodes (chevron/checkbox). Also defineExpose isExpanded/isSelected/(is-indeterminate)/handleToggle/handleSelect.
- Decouple Left/Right navigation from aria-level: add a private data-indent (or read FlatItem.level via the collection) for parent/child resolution instead of parsing the public aria-level attribute, so consumer markup overrides don't break keyboard nav.
- Optionally add a TreeVirtualizer companion (TanStack vue-virtual) with overscan/estimateSize/getLabel and keyboard+type-ahead integration to match reka for very large trees.
- Add a focus-entry strategy (onEntryFocus) so first focus into the tree lands on the selected item or the first item, consistent with the roving tabstop.
Where robonen is already better:
- ✅ Performance: robonen builds an O(1) selectedSet/expandedSet (computed Set) so each TreeItem's isSelected/isExpanded is a Set.has() instead of reka's per-item Array.includes() on selectedKeys.value (TreeItem.vue isSelected/isExpanded), avoiding O(n^2) on selection change. propagateSelect in robonen also collects cascade keys into a Set and filters with Set.has (TreeRoot.vue select()), whereas reka uses repeated Array.some/find (O(n*m)).
- ✅ Flattening is iterative (explicit stack in flattenVisible/utils.ts) so it cannot blow the call stack on deeply nested trees; reka's flattenItems and flatten() are recursive (TreeRoot.vue flattenItems, utils.ts flatten).
- ✅ Roving-key handling reuses hoisted ROVING_OPTS_LTR/RTL constants per keydown to keep the rovingKeyToAction call site monomorphic and avoid per-event object allocation (TreeRoot.vue lines 48-49) - a deliberate perf detail reka does not have.
- ✅ Richer data-* hooks for styling/animation: robonen exposes data-state="open|closed" and data-level on each item (TreeItem.vue), while reka only exposes data-expanded/data-selected/data-indent and no open/closed state attribute.
- ✅ TreeRoot default slot exposes a clean, typed payload { flatItems, selectedKeys, expandedKeys } and the FlatItem type is minimal and well-documented (utils.ts). reka's FlattenedItem carries an extra bind object the consumer must spread.
- ✅ More explicit, readable controlled/uncontrolled handling with a normalize() helper and equality-guarded watchers (TreeRoot.vue), versus reka leaning on useVModel with @ts-expect-error casts and passive flags.
- ✅ reka's single-select onSelectItem does modelValue.value = { ...val } (shallow-clones the item object), which breaks referential identity of the selected item; robonen stores stable string keys and emits the key, avoiding identity loss.
popover — robonen-better-with-gaps
Both libraries implement the same set of Popover parts (Root, Trigger, Anchor, Content + Impl/Modal/NonModal, Portal, Arrow, Close) with essentially identical architecture (Popper + DismissableLayer + FocusScope + Presence, modal/non-modal split, controlled+uncontrolled open via open/defaultOpen and an {open, close} slot, role=dialog, aria-haspopup=dialog/aria-expanded/aria-controls/aria-labelledby, data-state, identical close/interact-outside trigger-reentry guards, and the same CSS custom-property exposure). robonen is genuinely better in several areas: a superior reference-counted scrollbar-compensating useBodyScrollLock, always-valid aria-controls via eager IDs, cleaner forceMount-vs-present separation in Presence, and a larger forwarded Popper positioning API (reference/positionStrategy/hideShiftedArrow/disableUpdateOnLayoutShift). However robonen has real, fixable gaps versus reka: (1) modal popover omits useHideOthers, so background content is not hidden from screen readers, despite robonen shipping that util and using it in Dialog/Menu (critical a11y inconsistency); (2) no focus guards in content (reka uses useFocusGuards) despite robonen having useFocusGuard and using it in Menu; (3) the content element is never exposed via template ref (reka threads forwardRef through the whole content chain); (4) PopoverArrow drops all props except width/height and exposes no ref; (5) the default arrow renders an empty span with no triangle and no rounded variant, while reka renders a working SVG triangle out of the box. Net: robonen wins on plumbing/perf but is currently NOT strictly better — it has a critical modal-a11y omission, a major focus-guard omission, missing ref/prop forwarding, and an invisible default arrow. Fixing the listed items (most reuse utilities robonen already has) would make it strictly better.
Critical gaps:
- ⛔ (accessibility) Modal popover does NOT hide sibling/background content from assistive tech. reka's PopoverContentModal calls useHideOthers(currentElement) to aria-hide everything outside the content; robonen's PopoverContentModal.vue omits this entirely (only calls useBodyScrollLock()). robonen already ships an equivalent useHideOthers (src/utils/useHideOthers.ts) and uses it in DialogContentModal.vue and MenuRootContentModal.vue, so this is an inconsistent omission specific to popover.
- evidence: reka PopoverContentModal.vue line 19
useHideOthers(currentElement)vs robonen PopoverContentModal.vue (never imports/calls useHideOthers); cf. robonen DialogContentModal.vue:20 and MenuRootContentModal.vue:21 which DO call it.
- evidence: reka PopoverContentModal.vue line 19
Major gaps:
- 🔶 (accessibility) No focus guards injected around content. reka's PopoverContentImpl calls useFocusGuards() to insert tabbable [data-reka-focus-guard] spans at the body edges so focusin/focusout are caught consistently (important for Tab-out behavior and focus-scope boundary detection). robonen's PopoverContentImpl.vue never calls a focus-guards util, even though robonen exports useFocusGuard from @robonen/vue and uses it in MenuRootContentModal.vue:19.
- 🔶 (api-surface) Content element/instance is not exposed via template ref. reka threads forwardRef end-to-end: PopoverContent (forwardRef) -> ContentModal/NonModal (:ref=forwardRef) -> ContentImpl (forwardRef) -> PopperContent (:ref=forwardRef), so
<PopoverContent ref=...>yields the content DOM element. robonen's PopoverContent, PopoverContentModal, PopoverContentNonModal, and PopoverContentImpl never call useForwardExpose nor bind a ref, so consumers cannot obtain the content element/instance. - 🔶 (api-surface) PopoverArrow does not forward its props and has no exposed ref. reka's PopoverArrow does
v-bind="props"(forwarding as/asChild/rounded/width/height) and calls useForwardExpose(). robonen's PopoverArrow.vue only binds :width and :height to PopperArrow and drops everything else (as, custom attrs), and never exposes a ref. - 🔶 (features) Default arrow renders nothing visible. reka's Arrow (shared/component/Arrow.vue) defaults as='svg', sets viewBox '0 0 12 6', preserveAspectRatio, and renders a default triangle
plus a rounded variant. robonen's PopperArrow renders a plain wrapping a Primitive as='span' with no default geometry, so out-of-the-box <PopoverArrow/>is an empty/invisible element unless the consumer supplies their own SVG via the slot.
Minor gaps (3):
- ▫️ (features) No rounded arrow option. reka exposes ArrowProps.rounded to switch to a rounded arrow path; robonen PopperArrowProps only has width/height, so PopoverArrow can never render the rounded variant.
- ▫️ (api-surface) PopoverAnchor does not expose its element via ref. reka calls useForwardExpose() in PopoverAnchor; robonen's PopoverAnchor.vue does not, so a template ref on PopoverAnchor won't resolve to the anchor element/instance.
- ▫️ (edge-cases) interactOutside handler is synchronous. reka's PopoverContentNonModal uses an async interact-outside handler (allowing awaited guards before deciding to prevent); robonen's is synchronous. Minor, but reka's signature is more future-proof for async dismissal guards.
Recommendations to make robonen strictly better:
- In PopoverContentModal.vue, add
import { useHideOthers } from '../utils/useHideOthers', obtain the content element via useForwardExpose's currentElement, then calluseHideOthers(currentElement)so modal popovers aria-hide background content for screen readers (match DialogContentModal/MenuRootContentModal). - In PopoverContentImpl.vue, import and call
useFocusGuardfrom '@robonen/vue' (as MenuRootContentModal does) so edge focus guards are injected for consistent focusin/focusout handling and Tab-out behavior. - Thread a forwardRef through the content chain: call useForwardExpose() in PopoverContent and bind
:ref="forwardRef"on PopoverContentModal/NonModal; forward it to PopoverContentImpl; in PopoverContentImpl call useForwardExpose() and bind:ref="forwardRef"on PopperContent — so<PopoverContent ref>exposes the content element (also needed to wire useHideOthers in the modal). - Fix PopoverArrow.vue to
v-bind="props"onto PopperArrow (forward as/attrs), add useForwardExpose(), and keep the width/height defaults; this restores the standard arrow API surface. - Give PopperArrow a real default rendering: default
as='svg', set viewBox/preserveAspectRatio, render a default triangleas slot fallback, and add a rounded?: booleanprop with the rounded path — so<PopoverArrow/>is visible out of the box (and default PopoverArrow as='svg' like reka). - Add useForwardExpose() to PopoverAnchor.vue so the anchor element is accessible via template ref.
- Optional: make PopoverContentNonModal's interact-outside handler async to match reka and allow awaited dismissal guards.
- Keep robonen's eager IDs (always-valid aria-controls) and the separate forceMount/present Presence wiring — these are genuine improvements over reka; document them as intentional.
Where robonen is already better:
- ✅ Superior body scroll lock: robonen's useBodyScrollLock (toolkit/.../useBodyScrollLock/index.ts) is reference-counted, auto-disposes via tryOnScopeDispose, preserves original overflow/paddingRight/touchAction, and compensates for scrollbar width to prevent layout shift. reka's PopoverContentModal just calls useBodyScrollLock(true) with a simpler shared util.
- ✅ Eager, always-valid id wiring: robonen generates triggerId/contentId in PopoverRoot via useId, so PopoverTrigger's aria-controls always references a valid contentId. reka lazily sets rootContext.contentId only when PopoverContent mounts, so the trigger's aria-controls is '' (empty) until content has mounted at least once.
- ✅ Cleaner forceMount semantics: robonen PopoverContent passes forceMount separately to , whereas reka conflates them with , which makes present===true permanently when forceMount is set (Presence then can't distinguish forced-mount from real-open for exit transitions).
- ✅ Richer Popper positioning surface forwarded through content: robonen PopperContentProps adds hideShiftedArrow, disableUpdateOnLayoutShift, reference (custom ReferenceElement), and exposes positionStrategy 'absolute'|'fixed' (default fixed) with documented defaults — a strictly larger positioning API than what flows through reka's PopoverContent.
- ✅ Documented, typed context and props with JSDoc (PopoverRootProps.modal/defaultOpen, all DismissableLayerEmits); useContextFactory gives a typed inject with a clear provider-missing error.
- ✅ Performance-minded reactivity: PopoverRoot uses toRef(() => modal) for identity passthrough instead of computed, PopperArrow/useHideOthers constants are hoisted to module scope, and isInClosedPopover is isolated so its try/catch doesn't deopt the watcher callback.
menubar — robonen-better-with-gaps
Both libraries expose an identical set of 17 menubar parts and the wrapper sub-components (Item/CheckboxItem/RadioGroup/RadioItem/Indicator/Group/Label/Separator/Arrow/Portal/Sub*) are equivalent thin forwarders. Robonen is genuinely ahead on a few points: it adds trigger-level typeahead (reka has none), opens the menu on ArrowUp at the trigger, types the modelValue emit correctly (reka's is a buggy boolean), and uses safer eager id generation. However robonen has several real gaps versus reka: (1) CRITICAL — no cross-menu ArrowLeft/ArrowRight navigation while a menu's content is open (reka's MenubarContent/SubContent handleArrowNavigation), the central APG menubar interaction; (2) MAJOR — no roving tabindex, so every trigger is a tab stop instead of the menubar being a single tab stop (reka uses RovingFocusGroup/Item; robonen even has the module but doesn't use it); (3) MAJOR — MenubarSub has no defaultOpen/uncontrolled mode (MenuSub is controlled-only, default false); (4) MAJOR — closeAutoFocus always yanks focus back to the trigger even after clicking outside (reka guards with hasInteractedOutsideRef). Minor gaps: align is hard-forced and can't be overridden, aria-controls dangles when closed, no data-highlighted/data-value on the trigger, no macOS ctrl+click guard, missing trigger-width/height CSS vars, and slots don't expose modelValue/open. Net: robonen is better in places but not yet strictly better; fixing the roving tabindex, cross-menu arrow nav, sub uncontrolled mode, and focus-return behavior would make it strictly superior.
Critical gaps:
- ⛔ (keyboard) No cross-menu Arrow navigation while a menu is open: when a MenubarContent (or SubContent) is open and focus is inside it, pressing ArrowRight/ArrowLeft should open the adjacent menubar menu (core APG menubar behavior). Robonen's MenubarContent has no such handler and MenuContentImpl's RovingFocusGroup is orientation=vertical so horizontal arrows just bubble and do nothing. Reka implements handleArrowNavigation on MenubarContent (@keydown.arrow-right.arrow-left) and MenubarSubContent (@keydown.arrow-right), computing the next menu value from collection data-value (RTL-aware via dir, loop-aware via wrapArray) and calling rootContext.onMenuOpen.
- evidence: reka MenubarContent.vue handleArrowNavigation + @keydown.arrow-right.arrow-left, MenubarSubContent.vue @keydown.arrow-right. robonen MenubarContent.vue has no arrow handler; grep arrow/onMenuOpen in robonen MenuContentImpl/MenuContent = none.
Major gaps:
- 🔶 (keyboard) No roving tabindex on menubar triggers: robonen's MenubarTrigger renders a plain with no tabindex management, so EVERY trigger is in the tab order. This violates the WAI-ARIA menubar pattern where the menubar is a single tab stop and Arrow keys move between triggers. Reka wraps each trigger in RovingFocusItem (:tab-stop-id=value, :focusable=!disabled) under a RovingFocusGroup with v-model:current-tab-stop-id, so RovingFocusItem.vue sets :tabindex="isCurrentTabStop ? 0 : -1" — exactly one tabbable trigger. Robonen even already has a roving-focus module (src/roving-focus) but does not use it here.
- 🔶 (api-surface) MenubarSub has no uncontrolled mode / defaultOpen. Robonen MenubarSub forwards to MenuSub, whose only prop is
open(controlled, default false) with no defaultOpen and no internal useVModel — so a submenu cannot be uncontrolled. Reka's MenubarSub adds defaultOpen and wires useVModel(open, ..., passive) so the submenu works uncontrolled out of the box and exposes :open to the slot. - 🔶 (edge-cases) closeAutoFocus always yanks focus back to the trigger. Robonen's MenubarContent close-auto-focus handler unconditionally calls menuCtx.triggerRef.value?.focus() on every close — including when the user clicked/focused outside the menu (e.g. into an input). Reka tracks hasInteractedOutsideRef (set on interact-outside) and only refocuses the trigger when (!menubarOpen && !hasInteractedOutsideRef), letting focus stay where the user clicked. Robonen also has no interact-outside tracking for this.
Minor gaps (7):
- ▫️ (api-surface) align prop cannot be overridden. In robonen MenubarContent the template does v-bind="props" THEN align="start"; the static attribute wins, so any consumer-supplied align is ignored and content is always start-aligned. Reka uses withDefaults(defineProps, { align: 'start' }) and forwards via useForwardPropsEmits, so consumers can override align.
- ▫️ (accessibility) aria-controls is a dangling reference when the menu is closed. Robonen MenubarTrigger always sets :aria-controls="menuCtx.contentId.value", but the content is conditionally rendered via Presence (only when open), so when closed aria-controls points to a non-existent id. Reka guards :aria-controls="open ? menuContext.contentId : undefined".
- ▫️ (features) No data-highlighted styling hook on the focused trigger. Reka tracks isFocused via @focus/@blur and sets :data-highlighted on the trigger, letting consumers style the keyboard-focused trigger. Robonen's MenubarTrigger has no data-highlighted/focus tracking.
- ▫️ (edge-cases) No macOS ctrl+click guard on the trigger pointerdown. Robonen handlePointerDown only checks event.button !== 0. Reka additionally checks event.ctrlKey === false to avoid treating a macOS ctrl+click (context-menu) as a left click that opens the menu.
- ▫️ (features) No data-value on the trigger and no subtrigger marker attribute. Reka sets :data-value on the trigger (used by content arrow navigation to identify the next menu) and data-reka-menubar-subtrigger on MenubarSubTrigger (used to suppress cross-menu nav while opening a submenu). Robonen omits both, which is also why the cross-menu nav (above) cannot work without adding them.
- ▫️ (features) Missing trigger-width/height CSS custom properties on content. Reka sets --reka-menubar-trigger-width: var(--reka-popper-anchor-width) and -trigger-height on MenubarContent/SubContent for convenient content sizing. Robonen's content only exposes --primitives-menu-content-transform-origin/-available-width/-available-height (no anchor/trigger width alias at the menubar layer). The underlying --popper-anchor-width exists, so this is only a convenience-API gap.
- ▫️ (api-surface) Root slot does not expose modelValue and Sub slot does not expose open. Reka MenubarRoot exposes and MenubarSub exposes , letting consumers reactively read the open menu / submenu state in the template. Robonen MenubarRoot's passes nothing and MenubarSub's slot passes nothing.
Recommendations to make robonen strictly better:
- Add roving tabindex to MenubarTrigger by wrapping it in the existing src/roving-focus RovingFocusItem (focusable=!disabled, tabStopId=menuValue) under a RovingFocusGroup in MenubarRoot (orientation=horizontal, loop, dir, v-model:currentTabStopId). Drive currentTabStopId from onMenuOpen/onMenuToggle (as reka does) so the menubar is a single tab stop. This also lets you delete the hand-rolled focusByIndex/Home/End logic.
- Implement cross-menu Arrow navigation: add @keydown.arrow-left.arrow-right on MenubarContent (and @keydown.arrow-right on MenubarSubContent) that reads the trigger collection (add :data-value to MenubarTrigger), filters disabled, computes the next/prev menu value (RTL-aware via rootCtx.dir, loop-aware), and calls rootCtx.onMenuOpen. Add a data-* marker on MenubarSubTrigger so opening a submenu is not hijacked by the next-menu navigation.
- Give MenubarSub an uncontrolled mode: add a defaultOpen prop and use the repo's useVModel-equivalent so open is passive when
openis undefined; pass :open to the default slot. Propagate the same into MenuSub or handle it in the menubar wrapper. - Stop unconditionally refocusing the trigger on close: track a hasInteractedOutside ref (set in @interact-outside) and only call triggerRef.focus() in close-auto-focus when the menubar is fully closed and the user did NOT interact outside (mirror reka). Always preventDefault to control focus deterministically.
- Let consumers override align: switch MenubarContent to withDefaults({ align: 'start' }) and forward props (place v-bind AFTER, or merge align into props) instead of a static align="start" that clobbers the user value.
- Guard aria-controls on MenubarTrigger so it is only set when the menu is open (e.g. :aria-controls="open ? contentId : undefined") to avoid a dangling IDREF while the Presence-gated content is unmounted.
- Add :data-highlighted on MenubarTrigger driven by @focus/@blur (isFocused) for keyboard-focus styling parity.
- Add the macOS guard: ignore pointerdown when event.ctrlKey is true in handlePointerDown.
- Expose --primitives-menubar-trigger-width/height (aliasing --popper-anchor-width/height) on MenubarContent/SubContent, and expose modelValue from the MenubarRoot slot and open from the MenubarSub slot for parity and DX.
- Keep robonen's genuine wins (trigger typeahead, ArrowUp-opens-menu, correct emit types, toggle-on-click) and add tests covering the new roving tabindex single-tab-stop, cross-menu arrow switching, and submenu uncontrolled defaultOpen.
Where robonen is already better:
- ✅ Trigger-level typeahead: MenubarRoot wires a keydown.capture handler that fills a shared searchRef, and MenubarTrigger watches it + uses getNextMatch to jump focus to the matching trigger (e.g. type 'v' -> focus 'View'). Reka's menubar has NO trigger typeahead at all (it only imports wrapArray, an array-rotation helper, not useTypeahead). This is a WAI-ARIA APG recommended behavior reka omits.
- ✅ MenubarTrigger opens the menu on ArrowUp in addition to Enter/Space/ArrowDown (MenubarTrigger.vue handleKeyDown checks 'ArrowUp'). Reka's trigger only handles Enter/Space/ArrowDown (@keydown.enter.space.arrow-down), so ArrowUp does nothing on a reka trigger.
- ✅ Correct emit typing: MenubarRootEmits is typed as ['update:modelValue': [value: string | undefined]]. Reka's MenubarRootEmits is mistyped as [value: boolean] (a real bug in reka-ui MenubarRoot.vue), which would mislead consumers using v-model.
- ✅ contentId/triggerId are generated eagerly and deterministically via useId in MenubarMenu and provided through context; reka lazily mutates menuContext.contentId via ||= inside MenubarContent and even assigns triggerId = value (the user-supplied menu value), which can collide/duplicate ids if two menus share a value. Robonen's id wiring is cleaner and collision-safe.
- ✅ Clicking an already-open trigger closes it: handlePointerDown calls rootCtx.onMenuToggle. Reka's pointerdown calls onMenuOpen (not toggle), so clicking the open trigger again does not close it via pointer (only via dismiss), which is arguably worse UX for a menubar.
accordion — robonen-better-with-gaps
Parts reka has that robonen lacks: AccordionHeader
Both libraries cover the core accordion behavior (single/multiple, collapsible, controlled+uncontrolled v-model, disabled root+item, RTL/orientation-aware Arrow/Home/End keyboard nav, role=region + aria-labelledby + aria-expanded/controls, data-state/data-disabled/data-orientation). robonen is genuinely better in a few areas: it uses a real Collection instead of querySelectorAll('[data-reka-collection-item]'), it exposes a configurable loop prop the reka Accordion lacks, its keyboard logic is a clean testable util, and it avoids the ohash/useVModel deps with a cheap Set-equality guard. However reka is currently better in several concrete, verifiable ways that block 'strictly better': (1) reka has an AccordionHeader part required by the ARIA accordion pattern — robonen has none (critical a11y gap); (2) reka supports asChild everywhere, robonen only has as; (3) reka exposes measured --reka-accordion-content-height/width CSS vars enabling animate-to-auto, robonen exposes none; (4) reka supports hidden=until-found + beforematch find-in-page reveal, robonen uses plain hidden; (5) reka has Root+per-item unmountOnHide, type inference/validation of single vs multiple, a modelValue Root slot prop, item defineExpose, and type-generic emits — all absent in robonen. Verdict: robonen-better-with-gaps; closing the Header, asChild, content-dimension-vars, and beforematch gaps would make it strictly better.
Critical gaps:
- ⛔ (accessibility) reka ships an AccordionHeader part (AccordionHeader.vue, defaults to
as: 'h3', wires data-state/data-disabled/data-orientation) so the trigger is wrapped in a real heading element — the WAI-ARIA Accordion pattern REQUIRES each trigger button to live inside a heading element (h1-h6) for screen-reader heading navigation. robonen has NO Header part; consumers must hand-roll the heading and lose the data-* wiring.- evidence: reka Accordion/AccordionHeader.vue + index.ts exports AccordionHeader; robonen src/accordion/index.ts exports only Root/Item/Trigger/Content — no Header, violating the ARIA accordion heading requirement.
Major gaps:
- 🔶 (api-surface) reka supports
asChild(render-as-child / polymorphic merge onto an existing child element) on every part via PrimitiveProps. robonen's PrimitiveProps only hasas(primitive/Primitive.ts:export interface PrimitiveProps { as?: ... }) with noasChild/as-child. Consumers cannot merge accordion behavior onto an arbitrary child component; they are limited to theaselement swap. - 🔶 (features) reka exposes
--reka-accordion-content-heightand--reka-accordion-content-widthCSS custom properties (AccordionContent.vue style block, fed by CollapsibleContent measuring getBoundingClientRect). This is what lets users animate height/width from 0 to auto with pure CSS. robonen's AccordionContent renders directly and provides NO measured dimension vars — animating to auto-height is impossible without user JS. - 🔶 (accessibility) reka supports
hidden="until-found"+beforematchfind-in-page expansion: when the browser's in-page find lands on collapsed content, CollapsibleContent firescontentFoundand AccordionContent.vue auto-opens the item (@content-found="rootContext.changeModelValue(...)"). robonen AccordionContent uses:hidden="!item.open.value || undefined"(plain boolean hidden) and has no beforematch listener, so collapsed panels are invisible to Ctrl+F.
Minor gaps (6):
- ▫️ (api-surface) reka offers
unmountOnHideat the Root (default true) AND a per-Item override (AccordionItem.vue:unmount-on-hide="props.unmountOnHide ?? rootContext.unmountOnHide.value"), letting consumers keep closed content in the DOM globally. robonen only has a per-ContentforceMountboolean; there is no Root-levelunmountOnHideand no per-item inheritance/override. - ▫️ (edge-cases) reka infers
typefrom the shape of modelValue/defaultValue and validates coherence, logging a dev error on mismatch (useSingleOrMultipleValue.ts validateProps: single+array → error, multiple+non-array → error). robonen trusts the explicittypeprop and silently coerces via toArray; passing defaultValue: ['a','b'] with type:'single' will not warn and will misbehave on emit (toEmitValue returns only the first value). - ▫️ (api-surface) reka's Root default slot exposes
modelValue(AccordionRoot.vue defineSlots +<slot :model-value="modelValue" />), so consumers can render based on current open value(s) without re-deriving. robonen's Root slot exposes nothing (AccordionRoot.vue:150<slot />). - ▫️ (api-surface) reka's AccordionItem calls
defineExpose({ open, dataDisabled })so template refs / parent code can read an item's open state. robonen's AccordionItem (and Root) expose nothing beyond the forwarded element ref — no programmatic open/disabled introspection. - ▫️ (types) reka explicitly types Root emits generic on single vs multiple (
AccordionRootEmits<T extends SingleOrMultipleType>→ value isstring | undefinedfor single,string[] | undefinedfor multiple). robonen's emit is the looser unionupdate:modelValue: [value: string | string[] | undefined]regardless of type, so v-model is less type-safe in single mode. - ▫️ (edge-cases) reka guards the no-collapse case inside the Trigger as well as Root (AccordionTrigger.vue:21
triggerDisabled = isSingle && open && !collapsible) ensuring the click is a true early no-op. robonen relies solely on Root.toggle's nextOpenSet returning the same Set reference to skip commit (AccordionRoot.vue:82,108) — functionally correct but the trigger still always invokes ctx.toggle.
Recommendations to make robonen strictly better:
- Add an AccordionHeader.vue part (default
as: 'h3', forwarding data-state/data-disabled/data-orientation) and export it from index.ts, then update docs/stories to wrap AccordionTrigger inside AccordionHeader — this is required by the WAI-ARIA accordion pattern for heading-level screen-reader navigation. - Add
asChild/as-childsupport to PrimitiveProps and the Primitive/Slot implementation so all accordion parts can merge onto a consumer-provided child element, matching reka's polymorphism. - Make AccordionContent measure its content box (getBoundingClientRect with animations temporarily disabled, like reka's CollapsibleContent) and expose
--accordion-content-height/--accordion-content-width(or reuse collapsible vars) so consumers can animate open/close to auto height in pure CSS. - Implement
hidden="until-found"+ abeforematchlistener that auto-opens the item, so collapsed panels are reachable by browser find-in-page; ideally route AccordionContent through CollapsibleContent and add the contentFound emit there. - Add a Root-level
unmountOnHideprop (default true) with a per-item override that falls back to the root value, and have AccordionContent honor it instead of relying only on per-content forceMount. - Expose
modelValuefrom the Root default slot, anddefineExpose({ open, disabled })from AccordionItem for programmatic introspection. - Tighten the Root emit type to be generic over
type(string|undefined for single, string[]|undefined for multiple) and add dev-time validation/inference oftypefrom the modelValue/defaultValue shape (warn on single+array or multiple+non-array), mirroring reka's useSingleOrMultipleValue. - Add an explicit trigger-side no-op guard for the single+open+!collapsible case so clicks short-circuit before reaching Root.toggle (parity with reka's triggerDisabled), and consider routing each item through the existing Collapsible primitive to share content/animation logic instead of using Presence directly.
Where robonen is already better:
- ✅ Uses a real internal Collection (useCollectionProvider/CollectionInjector) for DOM-ordered trigger discovery. AccordionRoot.vue:111-112
getItems(true).map(i => i.ref). reka instead doesrootContext.parentElement.value?.querySelectorAll('[data-reka-collection-item]')on every keydown (AccordionItem.vue:95), which is slower, leaks a magic DOM attribute, and breaks if the trigger is teleported/portaled out of the root subtree. - ✅ Exposes a configurable
loopprop on the Root (AccordionRoot.vue:28, threaded into rovingKeyToAction/resolveNextIndex). reka's Accordion does NOT expose loop at all — it always inherits useArrowNavigation's defaultloop: truewith no way to disable wrap-around at first/last trigger. - ✅ Keyboard handling is centralized in the Root via a pure, unit-testable roving-focus util (utils/roving-focus.ts: rovingKeyToAction + resolveNextIndex). reka scatters nav into each Item's
handleArrowKeyand re-queries the DOM each time; robonen computestriggerElementsonce reactively. - ✅ openSet membership uses a Set with an O(n) early-exit
setEqualsArrayguard before reassigning (AccordionRoot.vue:63-74), avoiding the deepisEqual/ohashdependency reka pulls in via useSingleOrMultipleValue, and avoiding redundant writes on no-op model updates. - ✅ No external dependency on @vueuse/core useVModel or ohash for the controlled/uncontrolled logic — robonen implements it with a single shallowRef + watch, which is lighter weight.
editable — robonen-better-with-gaps
Both libraries implement the same 7-part Editable (Root, Area, Preview, Input, Edit/Submit/Cancel triggers) with the same activationMode/submitMode/selectOnFocus/autoResize/maxLength/disabled/readonly/startWithEditMode props, identical placeholder resolution, the same Enter-submit / Escape-cancel keyboard model, and the same controlled+uncontrolled (modelValue/defaultValue) + update:state/submit emits. robonen is genuinely better in several places: a unified data-state hook across all parts (reka has none), correct data-placeholder-shown semantics tied to emptiness rather than edit mode, an edit() guard that also respects readonly, change-gated v-model emission, stricter string-only typing, and lighter reactivity (no deep watch). However robonen has real gaps versus reka: (1) CRITICAL — zero form integration; reka renders a VisuallyHiddenInput driven by name/required/useFormControl so the field submits with a form, while robonen has no name/required and no hidden input. (2) No dir/RTL support, despite robonen having a ConfigProvider used elsewhere. (3) Root does not defineExpose edit/cancel/submit (reka does), so the imperative API is context-only. (4) No id prop. (5) Outside-dismiss relies on a single focusout.capture rather than reka's pointerdown-outside + focus-outside DismissableLayer wiring, missing pointer-outside dismissal and nested-layer coordination. (6) Triggers lack aria-disabled. (7) No axe a11y test. Closing the form-integration, dir, and defineExpose gaps would make robonen strictly better, since it already wins on data-attribute design, semantics, and types.
Critical gaps:
- ⛔ (forms) No form integration at all. reka's EditableRootProps extends FormFieldProps (name, required) and renders a inside the Root when useFormControl detects a surrounding form (EditableRoot.vue:241-248). robonen's EditableRootProps has no name/required props and renders no hidden input, so an Editable cannot participate in native form submission. robonen already ships a VisuallyHidden component (src/visually-hidden/) and a ConfigProvider, so the primitive is simply not wired up.
- evidence: reka EditableRoot.vue:32 (FormFieldProps), :142 useFormControl(currentElement), :241-248 VisuallyHiddenInput; robonen EditableRoot.vue has no name/required/VisuallyHiddenInput
Major gaps:
- 🔶 (rtl-i18n) No RTL / direction support. reka accepts a dir prop, resolves it via useDirection(propDir) (falling back to ConfigProvider), and binds :dir on the Root Primitive (EditableRoot.vue:39,132,224). robonen's EditableRoot has no dir prop and binds no dir attribute, even though other robonen primitives (ListboxRoot.vue) already use config.dir. Editable cannot inherit or override reading direction.
- 🔶 (api-surface) Root does not expose imperative methods. reka calls defineExpose({ submit, cancel, edit }) (EditableRoot.vue:188-195) so a template ref / parent can drive the editable programmatically. robonen's EditableRoot relies on useForwardExpose (which only forwards props and $el, not local functions) and never calls defineExpose, so edit/cancel/submit are reachable only via the injected context, not from a ref to .
- 🔶 (edge-cases) Outside-dismiss is weaker. reka commits/cancels via BOTH usePointerDownOutside and useFocusOutside wired through the DismissableLayer system with data-dismissable-layer and layer-nesting awareness (EditableRoot.vue:183-184,227-230). robonen only listens to a single native @focusout.capture and checks root.contains(relatedTarget) (EditableRoot.vue:114-121). This misses pointerdown-outside dismissal (e.g. clicking a non-focusable area outside, or browsers where blur relatedTarget is null), and has no nested-layer coordination.
Minor gaps (4):
- ▫️ (api-surface) No id prop. reka exposes an id prop and threads it through context (EditableRoot.vue:57-58, context id: Ref<string|undefined>), enabling stable ids / aria wiring. robonen has neither an id prop nor id in its context (context.ts).
- ▫️ (accessibility) Triggers omit aria-disabled. reka sets BOTH :aria-disabled and :disabled on Edit/Submit/Cancel triggers (e.g. EditableEditTrigger.vue:20-21). robonen sets only :disabled and :data-disabled (EditableEditTrigger.vue:24-25). When a consumer renders a trigger as a non-button element (as prop), the disabled attribute is inert and there is no aria-disabled to convey state to assistive tech.
- ▫️ (keyboard) Input keydown also binds Space. reka's input handler is @keydown.enter.space (EditableInput.vue:68) so the submit path is evaluated on Space too (still gated by submitMode + key check inside). robonen only listens for Enter/Escape (EditableInput.vue:47-59). This is a minor behavioral divergence; reka's intent is to also intercept Space-driven submit semantics on non-input as targets.
- ▫️ (accessibility) No documented axe accessibility test. reka's Editable.test.ts:29-32 runs axe(root) and asserts no violations, guarding the a11y contract. robonen's test/Editable.test.ts covers behavior but has no automated accessibility assertion.
Recommendations to make robonen strictly better:
- Add form integration to EditableRoot: extend props with name and required, add useFormControl(currentElement) (robonen already has a ConfigProvider/VisuallyHidden), and render a VisuallyHiddenInput type="text" :value="modelValue" :name :disabled :required inside the Root when inside a form and name is set. Mirror reka EditableRoot.vue:241-248. This is the only critical gap.
- Add a dir prop to EditableRoot, resolve it against the existing ConfigProvider (computed(() => dir ?? config.dir.value), the pattern already used in ListboxRoot.vue:63), and bind :dir on the Root Primitive so reading direction is inheritable/overridable.
- Call defineExpose({ edit, cancel, submit }) in EditableRoot so the imperative API is reachable from a template ref, matching reka EditableRoot.vue:188-195. (useForwardExpose alone does not forward local functions.)
- Add an id prop to EditableRoot and thread it through context for stable ids; consider using useId fallback from config-provider/useId.
- Strengthen outside-dismiss: in addition to focusout.capture, also handle pointerdown-outside (or adopt robonen's DismissableLayer equivalent if one exists) so clicking a non-focusable region outside while editing still commits/cancels per submitMode. Guard the focusout path for relatedTarget === null.
- Add :aria-disabled="ctx.disabled.value || undefined" alongside :disabled on EditableEditTrigger/EditableSubmitTrigger/EditableCancelTrigger so disabled state is conveyed when rendered as a non-button element.
- Tighten the modelValue type union: robonen already uses string-only which is good; keep it, but ensure the submit emit and v-model stay string (do not regress to reka's string|null|undefined).
- Add an axe accessibility assertion to test/Editable.test.ts to lock in the a11y contract, matching reka Editable.test.ts:29.
- Optional: also intercept Space in EditableInput keydown if you want parity with reka's @keydown.enter.space, though for a real Enter-only is arguably more correct.
Where robonen is already better:
- ✅ Richer data-attribute surface on Root and sub-components: robonen exposes data-state="edit"|"preview" on Root (EditableRoot.vue:149), Area, Preview AND Input, plus data-disabled/data-readonly/data-empty consistently. reka only ever emits data-placeholder-shown / data-focus / data-focused / data-empty / data-readonly / data-disabled and never a unified data-state, and its Input lacks any state attribute. robonen's data-state is a better styling hook.
- ✅ EditablePreview adds data-placeholder-shown using a dedicated showPlaceholder computed driven by isEmpty (EditablePreview.vue:19,36), so it is only set when the value is genuinely empty. reka sets data-placeholder-shown purely from !isEditing (EditablePreview.vue:32), so a non-empty preview that is not editing still reports data-placeholder-shown='' — robonen's semantics are more correct.
- ✅ robonen's edit() guards against both disabled AND readonly before entering edit mode (EditableRoot.vue:95), preventing edit activation while readonly. reka's edit() has no such guard and will enter edit mode even when readonly (it only blocks the actual text mutation via the input's readonly attribute).
- ✅ commitModel only emits update:modelValue when the value actually changed (EditableRoot.vue:88-92), avoiding redundant v-model emissions and a spurious submit-with-no-change write. reka's submit() always assigns modelValue.value = inputValue.value unconditionally (EditableRoot.vue:167).
- ✅ Cleaner reactivity: robonen uses toRef(() => prop) getters for context refs and a shallowRef for the input element. reka's watch on modelValue uses { immediate: true, deep: true } (EditableRoot.vue:150-152) — deep:true on a plain string ref is unnecessary overhead.
- ✅ modelValue typed strictly as string end-to-end (context.ts:14, EditableRoot.vue:7). reka types it as string | null | undefined (EditableRoot.vue:15-16) which leaks null/undefined into modelValue, inputValue, and the submit emit, weakening type guarantees for consumers.
tooltip — robonen-better-with-gaps
Parts reka has that robonen lacks: TooltipContentHoverable.vue (grace-area hoverable content wrapper)
robonen's tooltip is a faithful, slightly cleaner port (better SSR guards, explicit timer disposal, richer JSDoc, and it uniquely surfaces sideFlip/alignFlip props that reka's tooltip hides). However it has one critical regression and several real API gaps versus reka. CRITICAL: there is no TooltipContentHoverable equivalent, so the entire hoverable-content / grace-area feature is dead — isPointerInTransitRef is read in the trigger but never set true, and with disableHoverableContent defaulting to false the content cannot be safely hovered and the skip-delay handoff between adjacent triggers is broken. robonen already ships src/utils/useGraceArea (used by hover-card), so this is purely un-wired. Additional gaps: the trigger does not accept/forward a reference anchor prop (reka extends PopperAnchorProps), the provider lacks the content default-props mechanism, the trigger uses data-tooltip-trigger instead of data-grace-area-trigger, no TooltipRootEmits type is exported, and controlled-only open changes bypass provider bookkeeping/broadcast. Verdict: robonen-better-with-gaps — strong foundation but NOT yet strictly better until at least the hoverable-content and reference/provider-content gaps are closed.
Critical gaps:
- ⛔ (features) robonen tooltip has NO hoverable-content support at all. reka ships a dedicated TooltipContentHoverable.vue that wires useGraceArea(trigger, content): it sets providerContext.isPointerInTransitRef and calls onClose() on pointer-exit of the safe-area polygon. robonen's TooltipContent.vue ALWAYS renders TooltipContentImpl directly and never branches on disableHoverableContent. Consequently
isPointerInTransitRef(read in TooltipTrigger.vue:30) is initialized false in TooltipProvider.vue:54 and is NEVER set to true anywhere in the tooltip, and when disableHoverableContent is false (the DEFAULT), onTriggerLeave only does clearOpenTimer() (TooltipRoot.vue:145-147) with no grace-area tracking and no mechanism to close the tooltip once the pointer actually leaves toward/past the content. The whole hoverable feature and the skip-delay-between-adjacent-triggers protection are dead. Note: robonen ALREADY has src/utils/useGraceArea.ts and uses it in hover-card, so this is purely un-wired.- evidence: reka Tooltip/TooltipContent.vue:33
:is="rootContext.disableHoverableContent.value ? TooltipContentImpl : TooltipContentHoverable"+ TooltipContentHoverable.vue:15-19 (useGraceArea + onPointerExit->onClose + isPointerInTransitRef assignment). robonen TooltipContent.vue:24-31 always mounts TooltipContentImpl; TooltipProvider.vue:54 isPointerInTransitRef never set true; useGraceArea exists at src/utils/useGraceArea.ts but tooltip never imports it.
- evidence: reka Tooltip/TooltipContent.vue:33
Major gaps:
- 🔶 (api-surface) TooltipTrigger does not accept or forward a custom
reference(anchor) element. reka's TooltipTriggerProps extends PopperAnchorProps and passes:reference="reference"to PopperAnchor, letting users position the tooltip against an arbitrary virtual/real element instead of the trigger DOM node. robonen's TooltipTriggerProps extends only PrimitiveProps and renders<PopperAnchor as="template">with no reference passthrough, even though robonen's own PopperAnchor supports areferenceprop. - 🔶 (api-surface) TooltipProvider has no
contentdefault-props mechanism. reka's TooltipProviderProps exposescontent?: TooltipContentProps, stored in provider context and merged into every tooltip's PopperContent via defu (provider defaults < per-content props). This lets an app set e.g. default side/sideOffset for all tooltips once. robonen's provider has nocontentprop or any provider-level content defaults. - 🔶 (edge-cases) The trigger marker attribute is
data-tooltip-triggerinstead of the conventiondata-grace-area-triggerthat robonen's own useGraceArea looks for (target.closest('[data-grace-area-trigger]')). Even once hoverable content is wired, moving the pointer from one tooltip trigger directly onto an adjacent tooltip trigger would not be detected as 'another grace-area trigger', so the in-transit grace area would not be torn down/handed off correctly. reka tags its triggerdata-grace-area-triggerprecisely so useGraceArea recognizes it.
Minor gaps (2):
- ▫️ (types) TooltipRoot does not export a TooltipRootEmits type. reka exports
TooltipRootEmits = { 'update:open': [value: boolean] }for explicit typing of the emit. robonen relies solely on defineModel('open') with no exported emits type, so consumers typing@update:openhandlers have no named type to import. - ▫️ (edge-cases) robonen's open watcher / provider notification is driven imperatively inside handleOpen/handleClose/handleDelayedOpen, whereas reka centralizes provider onOpen/onClose + the TOOLTIP_OPEN dispatch in a single
watch(open)so any external/controlled change toopenalso triggers provider skip-delay bookkeeping and the single-tooltip-open broadcast. In robonen, a purely controlledopenflip (e.g. parent sets v-model true without going through onOpen) will NOT call providerCtx.onOpen() nor dispatch TOOLTIP_OPEN, so controlled-mode opens skip the skip-delay window update and won't auto-close other open tooltips.
Recommendations to make robonen strictly better:
- CRITICAL: Add a TooltipContentHoverable.vue (mirroring src/hover-card/HoverCardContentImpl.vue's usage of src/utils/useGraceArea) and make TooltipContent.vue render
<component :is="ctx.disableHoverableContent.value ? TooltipContentImpl : TooltipContentHoverable">. Inside the hoverable variant, calluseGraceArea(ctx.trigger, currentElement), push isPointerInTransit into ctx.isPointerInTransitRef via watchEffect, and call ctx.onClose() in onPointerExit. This activates the existing-but-dead isPointerInTransitRef read in TooltipTrigger.vue:30 and makes disableHoverableContent meaningful. - Change the trigger marker from
data-tooltip-triggertodata-grace-area-trigger(or add it alongside) so useGraceArea's[data-grace-area-trigger]lookup detects adjacent tooltip triggers; update the test selector in test/Tooltip.test.ts:80 accordingly. - Make TooltipTriggerProps extend PopperAnchorProps and forward
:referenceto PopperAnchor (the robonen PopperAnchor already supports it), enabling custom/virtual anchor elements. - Add a
content?: TooltipContentPropsprop to TooltipProvider, store it in TooltipProviderContext, and merge it as defaults in TooltipContentImpl (provider defaults < per-content props), matching reka's defu behavior for app-wide tooltip positioning defaults. - Export a
TooltipRootEmitstype ({ 'update:open': [value: boolean] }) from TooltipRoot.vue and re-export it from index.ts for typed @update:open handlers. - Drive provider.onOpen/onClose and the TOOLTIP_OPEN broadcast from a single
watch(open, ...)(in addition to or instead of the imperative handlers) so externally/controlled open changes also update the skip-delay window and close other open tooltips.
Where robonen is already better:
- ✅ Exposes additional Popper positioning props in TooltipContentImplProps that reka's tooltip does NOT surface:
sideFlipandalignFlip(reka's PopperContent supports them, but reka's TooltipContentImplPick<PopperContentProps, ...>omits them, so reka tooltip users cannot independently toggle main/cross-axis flipping). robonen forwards both to PopperContent (TooltipContentImpl.vue:9-21,116-120). - ✅ More robust SSR/teardown safety: TooltipRoot.dispatchOpenEvent guards
typeof document === 'undefined', TooltipContentImpl guardstypeof globalThis === 'undefined'for scroll listeners, and both TooltipRoot/TooltipProvider use explicit onScopeDispose to clear open/skip timers (TooltipRoot.vue:91, TooltipProvider.vue:63). reka relies on useTimeoutFn/useEventListener auto-cleanup but does not guard thedocument.dispatchEventin its open watcher beyond a lifecycle comment. - ✅ Cleaner uncontrolled-state model: TooltipRoot keeps a dedicated
localref and a defineModel get/set bridge (TooltipRoot.vue:53-61) instead of reka's useVModelpassivecast trick (passive: (props.open === undefined) as false), which is more type-honest. - ✅ Richer JSDoc on every prop in TooltipProviderProps/TooltipRootProps describing a11y consequences and timing semantics, improving DX over reka's terser docs.
- ✅ TooltipPortal forwards full TeleportPrimitiveProps via
v-bind="props", on par with reka.
pin-input — robonen-better-with-gaps
Parts reka has that robonen lacks: VisuallyHiddenInput (hidden form input rendered inside PinInputRoot for native form submission)
Both libraries implement the same two parts (Root + Input) with the same core behavior: render-your-own N inputs, type-to-advance, Backspace-to-previous, paste-fills-across, mask (password), otp autocomplete, and a complete event. robonen is genuinely cleaner in some areas — it sets role="group" on the root (reka sets no role anywhere), exposes isComplete in the slot, emits complete as a joined string, owns a length prop as a single source of truth with defensive single-char normalization, and avoids reka's deep watchers. However robonen has real gaps that prevent it from being strictly better. The critical one is the total absence of native form integration: reka renders a focusable VisuallyHiddenInput and supports name/required/id, while robonen cannot be submitted in a form. Other notable misses: no dir/RTL-aware arrow navigation, no per-input aria-label (a11y), no per-input disabled prop, no disabled-skipping navigation, no OTP sequential-fill focus guard, no placeholder-hide-on-focus, no as/asChild on the input, no numeric (number[]) typing, and no data-complete styling hooks. None of these are invented — each maps to a concrete reka symbol. Verdict: robonen-better-with-gaps; closing the form-integration, RTL, and aria-label items (the critical/major ones) is the path to strictly-better.
Critical gaps:
- ⛔ (forms) No native form integration at all: robonen renders no hidden input and PinInputRoot exposes no name/required/id props. reka renders <VisuallyHiddenInput :id :name :value=currentModelValue.join('') :disabled :required feature="focusable" tabindex=-1 @focus=focus-first-input> (PinInputRoot.vue:143-153) and extends FormFieldProps (name, required). A robonen pin-input cannot be submitted in a plain HTML , cannot participate in required validation, and has no id for label association.
- evidence: reka PinInputRoot.vue:28 (extends FormFieldProps), :47 (id prop), :143-153 (VisuallyHiddenInput with name/required/disabled/value); robonen PinInputRoot.vue has no name/required/id props and no hidden input.
Major gaps:
- 🔶 (rtl-i18n) No RTL/dir support. robonen hardcodes ArrowLeft=>previous and ArrowRight=>next (PinInputInput.vue:74-85) regardless of reading direction. reka passes dir into useArrowNavigation so in RTL ArrowRight moves to the previous logical input and ArrowLeft to the next (correct RTL behavior).
- 🔶 (accessibility) Inputs have no aria-label, so screen readers announce N anonymous edit fields. reka sets a per-input label.
Minor gaps (10):
- ▫️ (api-surface) PinInputInput has no per-input
disabledprop; only the root-level disabled is honored. reka lets each input be individually disabled and merges it with context disabled. - ▫️ (keyboard) Arrow navigation does not skip disabled inputs. robonen focusIndex(i±1) lands on an adjacent input even if it is disabled. reka's findNextFocusableElement recursively skips disabled elements.
- ▫️ (keyboard) No OTP sequential-fill focus guard. In otp mode robonen lets the user focus and fill any input out of order. reka's handleFocus redirects focus to the first empty input so OTP is entered left-to-right without gaps.
- ▫️ (features) Placeholder is always shown; it is not hidden on the focused/active input. reka clears the placeholder of the focused empty input for a cleaner UX.
- ▫️ (api-surface) PinInputInput is a hardcoded raw with no as/asChild polymorphism, so it cannot be rendered as a custom element or composed with asChild. reka's input extends PrimitiveProps (as:'input', asChild) and renders .
- ▫️ (types) No typed numeric model. reka uses a generic PinInputValue producing number[] when type==='number' and stores numbers in the model. robonen always stores string[] regardless of type.
- ▫️ (features) No data-complete state attribute exposed for styling on root or inputs, and inputs lack data-disabled. reka exposes data-complete on root (PinInputRoot.vue:138) and on each input plus data-disabled on each input (PinInputInput.vue:216-217).
- ▫️ (edge-cases) Missing input hardening attributes for numeric/mobile UX: reka sets autocapitalize="none" and pattern="[0-9]*" (helps numeric soft-keyboards and iOS). robonen sets neither.
- ▫️ (edge-cases) Caret/selection on user focus is not normalized. reka calls target.setSelectionRange(1,1) on focus so typing replaces the existing char predictably; robonen only calls el.select() during programmatic focusIndex and does nothing on real user focus/click.
- ▫️ (code-quality) Root does not control attribute forwarding (no inheritAttrs:false). reka sets inheritAttrs:false and v-bind="$attrs" on the Primitive so attrs land on the wrapper predictably while the hidden form input is excluded; robonen relies on default attr inheritance with no explicit placement control.
Recommendations to make robonen strictly better:
- Add native form support: extend PinInputRoot props with name, required, id; render a focusable VisuallyHiddenInput (robonen already ships VisuallyHidden in src/visually-hidden) with :value=value.join(''), :name, :required, :disabled, tabindex=-1, and @focus forwarding to inputs.value[0].focus(). This closes the only critical gap.
- Add
dirprop + useDirection to PinInputRoot, expose dir in PinInputContext, and make ArrowLeft/ArrowRight direction-aware in PinInputInput.onKeyDown (in RTL, ArrowRight => previous, ArrowLeft => next). - Add :aria-label="
pin input ${index + 1} of ${length}" to PinInputInput; keep the root role="group" advantage and consider an aria-label/aria-labelledby on the group. - Add a per-input
disabledprop to PinInputInput merged with context.disabled, and make focusIndex skip disabled inputs (recursively find the next non-disabled index). - Add an @focus handler on PinInputInput implementing OTP sequential-fill (redirect focus to first empty input when otp is true) and setSelectionRange(1,1) for predictable caret/overwrite on user focus.
- Hide the placeholder on the focused empty input (clear it on focus, restore on blur / when value changes).
- Convert PinInputInput to render with PrimitiveProps so it supports as/asChild polymorphism instead of a hardcoded .
- Make the model generic over type: PinInputValue => number[] when type==='number', and store numbers accordingly (or explicitly document string[]-only as a deliberate choice).
- Expose styling hooks: add :data-complete on the root and :data-disabled/:data-complete on each input.
- Add autocapitalize="none" and :pattern="[0-9]*" (numeric) to the input element for better mobile/soft-keyboard behavior.
- Set inheritAttrs:false + v-bind="$attrs" on the root Primitive so attribute forwarding is explicit and does not leak onto the (future) hidden form input.
Where robonen is already better:
- ✅ Root sets role="group" (PinInputRoot.vue:145) — reka sets NO role on its root or inputs, so robonen groups the inputs semantically for screen readers where reka does not.
- ✅ Exposes isComplete to the default slot (PinInputRoot.vue:138/146: slot props { value, isComplete }) — reka's slot only exposes { modelValue } (PinInputRoot.vue:82-87), so robonen consumers can render completion UI without recomputing.
- ✅ 'complete' event emits a ready-to-use joined string (PinInputRoot.vue:84 emit('complete', v.join(''))) — reka emits the raw array (PinInputRoot.vue:114), so robonen is slightly more ergonomic for the common case.
- ✅ First-class
lengthprop with normalize() clamping each cell to a single char and truncating overflow, plus a watcher that re-sizes the value array when length changes (PinInputRoot.vue:39-67). reka has no length prop and derives count purely from how many PinInputInput are mounted, so it has no single source of truth and no defensive clamping of incoming model values. - ✅ Defensive single-char enforcement: setAt slices to 1 char and re-syncs the DOM input after overwrite (PinInputInput.vue:48) preventing the native input from showing a stale/extra character.
- ✅ Cleaner, lighter reactivity: no deep watcher on modelValue for completion. reka uses watch(modelValue, ..., { deep: true }) (PinInputRoot.vue:112-115) plus passive deep useVModel (deep:true) — robonen computes isComplete cheaply and emits complete inline in emitValue (PinInputRoot.vue:81-85).
collapsible — robonen-better-with-gaps
Both libraries ship the same three parts (Root, Trigger, Content) with controlled+uncontrolled open state, disabled handling, aria-expanded/aria-controls wiring, data-state/data-disabled, and Presence-based mounting, so they are at structural parity on the basics. robonen is genuinely better in several places: its Trigger slot exposes open, its context offers onToggle/onOpen/onClose (vs reka's toggle-only), it publicly exports the provide/use context and context type for extensibility, and it only applies disabled to real button triggers. However, reka is clearly ahead on the animation and accessibility surface that defines a production Collapsible: it measures content and exposes --reka-collapsible-content-height/width CSS variables (critical for CSS height animations), supports unmountOnHide with hidden=\"until-found\" plus a beforematch listener and contentFound emit for find-in-page discoverability, suppresses the initial-mount animation, exposes open via defineExpose, and types its slots/emits. robonen lacks all of these. Verdict: robonen-better-with-gaps — it has a cleaner, more extensible API but is not yet strictly better because it is missing the content-size CSS variables (critical), unmountOnHide/until-found + beforematch (major a11y), and mount-animation suppression. Closing the listed gaps while keeping robonen's existing advantages would make it strictly better.
Critical gaps:
- ⛔ (features) No content size CSS variables. reka measures the content element via getBoundingClientRect in a watcher and exposes
--reka-collapsible-content-heightand--reka-collapsible-content-widthas inline styles, which is the standard mechanism for CSS height/width transitions on collapse/expand. robonen's CollapsibleContent.vue exposes NO such variables and performs no measurement, so consumers cannot do the canonical CSS-driven height animation.- evidence: reka CollapsibleContent.vue lines 40-78,112-115 (width/height refs, getBoundingClientRect watcher,
--reka-collapsible-content-height/widthstyle binding). robonen CollapsibleContent.vue has nothing equivalent.
- evidence: reka CollapsibleContent.vue lines 40-78,112-115 (width/height refs, getBoundingClientRect watcher,
Major gaps:
- 🔶 (accessibility) No
unmountOnHideprop and nohidden="until-found". reka supports keeping content mounted while hidden and emitshidden="until-found"so the browser's find-in-page can locate and reveal collapsed text. robonen always unmounts via Presence and always sets plainhidden, breaking find-in-page discoverability and any keep-mounted use case. Notably robonen already implementsunmountOnHidein its navigation-menu primitive, so this is an inconsistency, not a missing capability. - 🔶 (accessibility) No
beforematchhandling /contentFoundemit. reka listens for the browserbeforematchevent (fired when find-in-page is about to reveal ahidden=until-foundelement) and auto-opens the collapsible plus emitscontentFound. robonen has neither the listener nor the event, so even if it added until-found, search would not sync the open state.
Minor gaps (5):
- ▫️ (edge-cases) No initial-mount animation suppression. reka prevents the open/close animation from firing on first mount (so a defaultOpen collapsible does not animate in) by tracking
isMountAnimationPreventedand computingskipAnimation, which nulls outdata-stateon the very first frame. robonen always rendersdata-stateimmediately, so a default-open collapsible can play its enter animation on page load. - ▫️ (api-surface) Root does not expose
openvia template ref. reka callsdefineExpose({ open })so a parent holding a ref to CollapsibleRoot can read the live open state. robonen's CollapsibleRoot does not defineExposeopen(it only forwards the element ref via useForwardExpose), so the open state is not reachable from a component ref. - ▫️ (types) Root default slot is untyped. reka declares
defineSlots<{ default?: (props: { open: boolean }) => any }>(), giving consumers typed slot props in templates. robonen's CollapsibleRoot has no defineSlots, so theopenslot prop is untyped for consumers. - ▫️ (types) No exported emit type for the open event. reka exports
CollapsibleRootEmits(update:open). robonen relies on defineModel('open'), which works at runtime and types the model, but exports no emit type from index.ts, so consumers wrapping the component (e.g. useForwardPropsEmits-style) have no named emit contract to import. - ▫️ (api-surface) No explicit asChild API on parts. reka threads
:as-child="props.asChild"to every Primitive (Root/Trigger/Content), offering the conventional asChild boolean for composition. robonen's Primitive (primitive/Primitive.ts) has noasChildprop at all and relies onas="template"instead. This is an architectural choice rather than a pure bug, but it is an API-surface and ecosystem-familiarity gap for users migrating from Radix/reka.
Recommendations to make robonen strictly better:
- Add content measurement and expose
--collapsible-content-height/--collapsible-content-widthCSS variables on CollapsibleContent, mirroring reka's getBoundingClientRect watcher (reka CollapsibleContent.vue lines 40-78,112-115). This is the highest-impact gap: without it consumers cannot do CSS height/width animations, the primary reason a Collapsible exists. - Add an
unmountOnHide?: booleanprop to CollapsibleRoot (reuse the exact pattern already in robonen NavigationMenuRoot) and thread it through context. WhenunmountOnHideis false, keep the content mounted and renderhidden="until-found"instead ofhidden=trueso browser find-in-page can reveal collapsed content. - Add a
beforematchlistener on the content element that auto-opens the collapsible (call onOpen/onToggle) and add acontentFoundemit to CollapsibleContent so find-in-page reveals stay in sync with open state. - Add initial-mount animation suppression: track an
isMountAnimationPreventedflag reset on requestAnimationFrame after mount and null outdata-statefor the first frame (reka skipAnimation) so a defaultOpen collapsible does not animate on load. - Call
defineExpose({ open })in CollapsibleRoot so parents can read open via a template ref, matching reka. - Add
defineSlots<{ default?: (props: { open: boolean }) => any }>()to CollapsibleRoot (and a typed slot for Trigger/Content) for typed slot props. - Export a
CollapsibleRootEmits(update:open) type from index.ts for consumers who wrap and forward emits, even though defineModel is used internally. - Consider adding an explicit
asChildboolean to Primitive (or document theas="template"equivalence prominently) to match the conventional Radix/reka composition API and ease migration. - Keep robonen's wins: retain the onOpen/onClose context methods, the open-aware Trigger slot, the public provide/use context exports, and the button-only :disabled guard — these are genuine improvements over reka and should not be regressed.
Where robonen is already better:
- ✅ Trigger slot is data-rich: robonen passes
<slot :open="ctx.open.value" />(CollapsibleTrigger.vue line 30), so consumers can render trigger UI based on open state. reka's CollapsibleTrigger.vue line 32 renders a bare<slot />with no slot props. - ✅ More granular context control surface: robonen's CollapsibleContext (context.ts) exposes
onToggle,onOpen, andonClose. reka's CollapsibleRootContext (CollapsibleRoot.vue) exposes onlyonOpenToggle. robonen lets advanced consumers force-open or force-close, not just toggle. - ✅ Public, extensible context API: robonen's index.ts exports
provideCollapsibleContext,useCollapsibleContext, and theCollapsibleContexttype. reka's index.ts exports onlyinjectCollapsibleRootContext(no provide function and no exported context type), so reka consumers cannot re-provide or strongly type the context for custom parts. - ✅ Safer disabled attribute on non-button triggers: robonen's CollapsibleTrigger.vue line 27 sets
:disabled="as === 'button' ? ctx.disabled.value : undefined", avoiding an invaliddisabledattribute whenasis a non-button element. reka's CollapsibleTrigger.vue line 29 sets:disabled="rootContext.disabled?.value"unconditionally, which would emit adisabledattribute on a div/anchor when disabled. - ✅ Lighter reactivity for disabled: robonen uses
toRef(() => disabled)(CollapsibleRoot.vue line 34, identity passthrough without computed caching). reka usestoRefs(props)which is comparable, but robonen's intent/comment is explicit and avoids any over-subscription.
primitive — robonen-better-with-gaps
Parts reka has that robonen lacks: usePrimitiveElement composable (element ref normalization), asChild prop, SELF_CLOSING_TAGS handling, exported AsTag type Parts robonen has that reka lacks: renderSlotChild shared helper (dedup between Slot and template path), getRawChildren with keyed-fragment BAIL handling, Primitive.bench.ts benchmark suite, DEV warning on multiple children
robonen's Primitive is leaner and faster on the common path (raw functional component, shared renderSlotChild, no extra instance for as="template"), adds a DEV multi-child warning, smarter keyed-fragment BAIL flattening, and a dedicated benchmark suite — genuine wins over reka. However it is NOT strictly better: it has a critical regression because it omits the asChild prop entirely while its own consuming components (Menubar/HoverCard/DropdownMenu) pass :as-child, which currently leaks as a literal DOM attribute and does nothing. It also lacks reka's self-closing/void-tag hydration handling (img/input/area), uses attrs-win merge order instead of reka's child-props-win mergeProps (failing reka's 'child overrides parent' contract), renders only the first slot child vs reka's multi-child passthrough, does not strip the child's incoming ref before cloning, ships no colocated element-ref composable (usePrimitiveElement), and exports a broader/less-curated as type with no AsTag alias. Verdict: robonen-better-with-gaps — fix asChild (critical), void-tag handling, and merge precedence to reach strictly-better.
Critical gaps:
- ⛔ (api-surface) robonen's Primitive does NOT declare an
asChildprop at all (Primitive.props only containsas). Yet robonen's own consuming components pass:as-child="asChild"to (MenubarTrigger.vue:129, MenubarRoot.vue:93, HoverCardTrigger.vue:38, HoverCardContentImpl.vue:141, DropdownMenuTrigger.vue, etc.). BecauseasChildis undeclared and inheritAttrs=false, it falls into ctx.attrs and is rendered via h(as!, ctx.attrs, ...) as a literalas-childDOM attribute whenasis a string tag, instead of switching to Slot/template merge mode. So asChild is silently broken across robonen's library and leaks an invalid attribute to the DOM.- evidence: reka Primitive.ts declares
asChild: { type: Boolean, default: false }and computesconst asTag = props.asChild ? 'template' : props.as. robonen Primitive.ts has no asChild prop; Primitive.props = { as: {...} } only; render isas === 'template' ? renderSlotChild(...) : h(as!, ctx.attrs, ctx.slots).
- evidence: reka Primitive.ts declares
Major gaps:
- 🔶 (ssr) No self-closing/void-tag handling. robonen always passes ctx.slots as the 3rd arg to h() for any non-template tag, including void elements like img/input/area. Passing children/default slot to void tags causes SSR hydration mismatches and invalid DOM.
- 🔶 (edge-cases) Slot attr/prop merge priority differs. reka's Slot deletes the child's incoming ref and uses mergeProps(attrs, childProps) so the CHILD's props (e.g. id, data-type) override the parent attrs, then cloneVNode(child, mergedProps). robonen's renderSlotChild does cloneVNode(only, attrs, true): Vue's cloneVNode gives the passed
attrspriority for plain props (id, data-*), so a parent id/data-attr would override the child's same-named prop rather than the child winning. reka's test 'should replace parent attributes with child's attributes' asserts the child wins (id=child, data-type=primary).
Minor gaps (5):
- ▫️ (api-surface) No element-ref forwarding composable colocated with the Primitive. reka exports usePrimitiveElement from the Primitive module, giving a normalized currentElement that resolves the real HTMLElement even when the root vnode is a #text/#comment node (uses nextElementSibling). robonen's Primitive folder exports only Primitive and Slot; consumers rely on useForwardExpose from a separate @robonen/vue package, so the Primitive package itself does not provide a ref-normalization primitive and there is no exported usePrimitiveElement equivalent here.
- ▫️ (edge-cases) Slot only ever renders ONE child. reka's Slot returns the full children array (cloning attrs onto the first non-comment child but keeping the rest), so with multiple siblings renders them all. robonen's renderSlotChild returns just children[0], discarding any subsequent siblings.
- ▫️ (edge-cases) Child incoming
refis not stripped before merge. reka explicitlydelete firstNonCommentChildren.props?.refso a ref already on the slotted child does not get double-applied/conflict when attrs (which may carry a forwarded ref) are merged. robonen's renderSlotChild does not delete the child's ref before cloneVNode, which can cause ref conflicts in asChild/template ref-forwarding scenarios. - ▫️ (types) Less curated
astype and no exported AsTag. reka exports anAsTagunion (a|button|div|form|h2|h3|img|input|label|li|nav|ol|p|span|svg|ul|template|({}&string)) giving good autocomplete plus open-ended strings. robonen typesasaskeyof IntrinsicElementAttributes | Component, which is broader/noisier for autocomplete, has no exported reusable tag alias, and notably the tests note h() struggles with the broad union (@ts-expect-error in Primitive.test.ts line 334). - ▫️ (edge-cases) No onUpdated-driven ref re-sync for conditional rendering. reka's element resolution (useForwardExpose/usePrimitiveElement consumers) handles the case where $el changes across v-if/v-else while the component instance ref stays the same, by triggering a ref update on update. The Primitive package in robonen ships no such ref-normalization at all (delegated entirely to external @robonen/vue), so the Primitive module alone cannot guarantee a stable resolved element across conditional root swaps.
Recommendations to make robonen strictly better:
- Add an
asChild?: booleanprop to robonen's Primitive (declare it in Primitive.props as { type: Boolean, default: false } and in PrimitiveProps), and compute the effective tag asconst tag = props.asChild ? 'template' : props.as. This is urgent: MenubarTrigger/MenubarRoot/HoverCardTrigger/HoverCardContentImpl/DropdownMenuTrigger all pass :as-child today and it currently leaks as a literal DOM attribute / does nothing. - Add self-closing/void tag handling: define SELF_CLOSING_TAGS = ['area','img','input','br','hr','wbr','source','track','col','embed', ...] and when
asis one of them renderh(as, ctx.attrs)WITHOUT passing ctx.slots, to avoid SSR hydration mismatches (parity with reka, ideally a more complete void-element list). - In renderSlotChild, switch from
cloneVNode(child, attrs, true)to merging with child precedence: strip the child's incoming ref (delete child.props?.ref) and usemergeProps(attrs, child.props)so child props (id, data-*, etc.) override parent attrs — matching reka's documented behavior and its 'replace parent attributes with child's attributes' test. - Decide on multi-child semantics: either keep single-child + DEV warning (current) OR match reka by returning all children with attrs applied to the first non-comment child. If staying single-child, document it as an intentional stricter contract; if matching reka, return the full children array.
- Export an AsTag union type (curated common tags plus
({} & string)) from primitive/index.ts and use it for PrimitiveProps['as'] to improve autocomplete and remove the @ts-expect-error in the tests around h() + broad union. - Provide a colocated element-ref composable in the primitive package (e.g. usePrimitiveElement) that normalizes $el for #text/#comment roots via nextElementSibling and re-syncs on onUpdated/triggerRef for v-if/v-else root swaps, so the Primitive package is self-contained rather than depending solely on @robonen/vue's useForwardExpose.
- Add tests covering: asChild=true switching to Slot mode, void-tag rendering without slots (no hydration warning), child-prop-overrides-parent-attr precedence, child ref not double-applied, and multi-child behavior — to lock in the above fixes and prevent regressions like the current silent asChild leak.
Where robonen is already better:
- ✅ Lower runtime overhead on the common path: robonen's Primitive is a raw functional component (export function Primitive(...) with Primitive.props/inheritAttrs), so the non-template path is a single h(as, ctx.attrs, ctx.slots) call with NO extra component instance. reka uses defineComponent + a setup() that returns a render fn, and for template/asChild mode wraps in a second component h(Slot, attrs, ...), adding an extra instance. robonen's Slot.ts even shares renderSlotChild between and specifically to avoid an extra functional component instance on the template path (see comment in Slot.ts).
- ✅ Dedicated benchmark suite: robonen ships Primitive.bench.ts measuring Primitive vs raw h(), Slot scaling by attr count, edge cases, and mount+update via render(). reka has no perf benchmarks for Primitive.
- ✅ DEV-mode diagnostics: robonen's renderSlotChild warns via Vue's warn() when multiple valid children are passed to Slot/template mode (' can only be used on a single element or component.'), guarded by DEV. reka's Slot silently renders multiple children with no warning.
- ✅ More correct fragment flattening for keyed lists: robonen's getRawChildren detects multiple KEYED_FRAGMENTs and sets PatchFlags.BAIL on the resulting vnodes to force full diff (avoids stale keyed-list patching). reka's renderSlotFragments just flatMaps fragments recursively with no keyed-fragment BAIL handling.
- ✅ Explicit Fragment AND Comment filtering in the single-child fast path: robonen checks t !== Fragment && t !== Comment before the one-child cloneVNode shortcut, so a lone Fragment/Comment correctly falls through to flattening. reka's single-child handling relies only on findIndex(child.type !== Comment).
hover-card — robonen-better-with-gaps
Parts are 1:1 (Root, Trigger, Portal, Content, ContentImpl, Arrow) and core behavior (open/close delays, grace-area transit, selection containment, scroll-dismiss, DismissableLayer wiring, Popper positioning, controlled/uncontrolled open) matches reka closely. robonen is genuinely better in several spots: it clears Root timers on scope dispose (reka leaks a pending timer past unmount), short-circuits zero delays, actually forwards the interactOutside emit (reka declares it but never wires it), and has a cleaner first-class forceMount on Presence. The decisive gap is accessibility: reka strips its hover-card content out of the tab sequence by setting tabindex=-1 on tabbable descendants on mount, while robonen ships getTabbableNodes as dead code and never neutralizes tab order — a critical regression for keyboard users. Secondary gaps are API-surface: robonen's HoverCardContent Pick drops prioritizePosition, disableUpdateOnLayoutShift, reference, and hideShiftedArrow (all supported by its own PopperContent), does not forward a ref from HoverCardContent, and exports no HoverCardRootEmits type. Fixing the tabindex behavior and widening the content prop forwarding would make robonen strictly better than reka.
Critical gaps:
- ⛔ (accessibility) Hover-card content is NOT removed from the tab sequence. Reka, in HoverCardContentImpl onMounted (lines 65-71), calls getTabbableNodes(contentElement) and sets tabindex="-1" on every tabbable descendant, because a hover-triggered card must not become a keyboard tab stop (its focusable children would otherwise be reachable while the card is invisible/positioned off the reading flow). robonen DEFINES getTabbableNodes in utils.ts (lines 9-20) but NEVER calls it anywhere — it is dead code, and HoverCardContentImpl performs no tabindex neutralization. Result: focusable elements inside robonen's hover card stay in the tab order.
- evidence: reka HoverCardContentImpl.vue:65-71 (getTabbableNodes + setAttribute('tabindex','-1')); robonen utils.ts:9-20 getTabbableNodes is exported/unused, HoverCardContentImpl.vue has no equivalent onMounted tabindex handling
Major gaps:
- 🔶 (api-surface) Content omits the prioritizePosition positioning option. Reka's HoverCardContentImplProps extends the full PopperContentProps, so prioritizePosition (force content within viewport, overriding side/align) is passable. robonen's HoverCardContentImplProps Pick<PopperContentProps, ...> (HoverCardContentImpl.vue:5-21) does not include prioritizePosition, even though robonen's own PopperContent supports it (PopperContent.vue:40,87,169-185).
- 🔶 (api-surface) Content omits disableUpdateOnLayoutShift. Same root cause: reka forwards the whole PopperContentProps; robonen's Pick list does not include disableUpdateOnLayoutShift, although robonen PopperContent implements it (PopperContent.vue:38,86,208).
Minor gaps (4):
- ▫️ (api-surface) Content omits the custom reference (virtual/anchor) element prop. Reka's ContentImpl extends PopperContentProps which includes reference (PopperContent.vue:175), so a custom anchor can be set directly on the content. robonen's Content Pick does not include reference (only the Trigger exposes reference via PopperAnchor), so you cannot point the content at a custom reference element the way reka allows.
- ▫️ (api-surface) Content does not forward a ref / expose its element, and does not use a forward-props-emits helper. Reka's HoverCardContent uses useForwardExpose + :ref="forwardRef" on HoverCardContentImpl (HoverCardContent.vue:25,35) and useForwardPropsEmits (line 24), so consumers can templateRef the content. robonen's HoverCardContent (HoverCardContent.vue) neither forwards a ref to ContentImpl nor exposes currentElement, so the content element is not reachable from a parent ref on .
- ▫️ (types) No exported Root emits type. Reka exports HoverCardRootEmits ({ 'update:open': [boolean] }) from HoverCardRoot.vue and re-exports it from index.ts, giving consumers a named emit contract. robonen relies on defineModel('open') and exports no emits type, so there is no HoverCardRootEmits in its public index.ts.
- ▫️ (features) Content omits hideShiftedArrow. robonen's own PopperContent supports hideShiftedArrow (PopperContent.vue:'Hide arrow when it cant be centered'), but the HoverCardContentImpl Pick list does not forward it, so users cannot control that arrow behavior through the hover card. (reka uses a different arrow model, so this is a self-consistency gap rather than strict parity.)
Recommendations to make robonen strictly better:
- Wire up getTabbableNodes: in HoverCardContentImpl onMounted, walk getTabbableNodes(currentElement.value) and set tabindex="-1" on each, matching reka HoverCardContentImpl.vue:65-71. This removes hover-card content from the tab order (critical a11y) and eliminates the dead export in utils.ts.
- Replace the Pick<PopperContentProps, ...> in HoverCardContentImpl with
extends PopperContentProps(or extend the Pick to also include 'prioritizePosition', 'disableUpdateOnLayoutShift', 'reference', and 'hideShiftedArrow'), and forward all of them (or v-bind the forwarded props + $attrs) to . This restores full positioning surface parity with reka. - Add ref forwarding to HoverCardContent: use useForwardExpose() and bind :ref="forwardRef" on HoverCardContentImpl, and forward props/emits via a useForwardPropsEmits-style helper so a parent ref on resolves to the content element.
- Export a named HoverCardRootEmits type ({ 'update:open': [value: boolean] }) from HoverCardRoot.vue and index.ts so consumers have an explicit emit contract like reka.
- Simplify HoverCardTrigger.onPointerLeave: both branches call ctx.onClose() when !isPointerInTransit, so collapse to a single
if (!ctx.isPointerInTransit.value) ctx.onClose(); then decide deliberately whether to keep reka's extra&& !openguard (reka only cancels a pending open on trigger-leave and lets the content grace area handle close-while-open). Current robonen code is redundant and diverges from reka without a documented reason. - Keep robonen's wins (timer cleanup, <=0 fast path, interactOutside forwarding, first-class forceMount) — they already exceed reka; just close the gaps above to reach strictly-better.
Where robonen is already better:
- ✅ Root clears its open/close timers on teardown via onScopeDispose (HoverCardRoot.vue:84-87), preventing a late setOpen() firing after unmount. Reka's HoverCardRoot never clears openTimerRef/closeTimerRef on unmount (no onUnmounted/onScopeDispose), so a pending timer can call open.value=... after teardown.
- ✅ Root short-circuits the delay when openDelay<=0 / closeDelay<=0 by calling setOpen synchronously (HoverCardRoot.vue:55-58,68-71), avoiding a needless setTimeout(0) round-trip. Reka always schedules a window.setTimeout regardless of delay (HoverCardRoot.vue:73,79).
- ✅ robonen actually forwards the interactOutside event to consumers (HoverCardContentImpl.vue:135 emits('interactOutside') and HoverCardContent.vue:29 re-emits it). Reka declares interactOutside in HoverCardContentImplEmits (=DismissableLayerEmits) but its HoverCardContentImpl template (lines 88-95) only wires escapeKeyDown/pointerDownOutside/focusOutside/dismiss and never forwards interactOutside — a type-vs-runtime mismatch robonen avoids.
- ✅ Presence integration is cleaner: robonen passes a first-class :force-mount prop to (HoverCardContent.vue:23), whereas reka folds it into :present="forceMount || open" (HoverCardContent.vue:31-33).
- ✅ robonen adds a data-hover-card-trigger hook attribute on the trigger (HoverCardTrigger.vue:40) for easier querying/testing; reka only emits data-grace-area-trigger.
- ✅ useGraceArea is self-contained with an explicit POINTER_TRANSIT_TIMEOUT reset timer and onScopeDispose cleanup (useGraceArea.ts), and syncs state with a plain watchEffect (HoverCardContentImpl.vue:66-68) instead of pulling in @vueuse/shared syncRef like reka (HoverCardContentImpl.vue:28).
- ✅ Every prop carries JSDoc (HoverCardRootProps, PopperContentProps picks), improving editor hints over reka's sparser comments.
tags-input — robonen-better-with-gaps
Parts reka has that robonen lacks: VisuallyHiddenInput rendered inside Root for native form submission (imported from @/VisuallyHidden, supports primitive/array/object values + empty-required edge case)
Both libraries ship the same six parts (Root, Input, Item, ItemText, ItemDelete, Clear) with near-identical keyboard handling (Backspace/Delete to select-then-remove last tag, RTL-aware ArrowLeft/Right, Home/End jump within an active selection, ArrowUp/Down prevented while a tag is selected), composition (Enter/Tab/blur/paste/delimiter), v-model + uncontrolled defaultValue, duplicate/max/disabled handling, dir inheritance from ConfigProvider, convertValue/displayValue, and the same data-state/aria-labelledby/aria-current wiring on items. robonen is genuinely better on reactivity (shallowRef + manual equality watch vs reka's deep useVModel), display-aware duplicate detection, trimmed/empty-skipping paste, correct removeTag value emission (reka has a latent disabled-index desync bug), and tighter generics. However robonen has real, fixable gaps versus reka: it has NO native form integration (no name/required/id, no useFormControl, no VisuallyHiddenInput bubbling) — the single critical miss; object/complex tag values are unsafe (no convertValue guard, identity-based delete instead of deep isEqual); addOnBlur lacks the aria-controls relatedTarget guard needed for Combobox composition; and it lacks the data-focused state and the Root id-to-input wiring. Net: robonen is the stronger core implementation but is NOT yet strictly better because of the missing form layer and weaker object-value support.
Critical gaps:
- ⛔ (forms) No native form integration whatsoever. reka Root extends FormFieldProps (name, required), accepts an
idprop, calls useFormControl(currentElement), and renders when inside a form (TagsInputRoot.vue lines 10, 92, 103, 252-258). This bubbles the tag array (and even object/array-of-object values, plus an empty-required hidden input) into native form submission and constraint validation. robonen Root has no name/required/id props, no useFormControl, and renders no hidden input — tags cannot be submitted with a plain HTML form.- evidence: reka TagsInputRoot.vue FormFieldProps + VisuallyHiddenInput block (lines 252-258) and VisuallyHidden/VisuallyHiddenInput.vue parsedValue logic; robonen TagsInputRoot.vue has no name/required/id and no VisuallyHidden import.
Major gaps:
- 🔶 (edge-cases) Object/complex tag values are not safely supported. reka throws an explicit Error ('You must provide a
convertValuefunction when using objects as values.') when objects are used without convertValue (TagsInputRoot.vue lines 124-130), and TagsInputItemDelete compares with deepisEqualfrom ohash (TagsInputItemDelete.vue lines 3, 28). robonen has no such guard (convert just castsraw as unknown as T, line 100) and TagsInputItemDelete finds the index with strict identityv === item.value.value(TagsInputItemDelete.vue line 20), so deleting an object tag by clicking its delete button fails whenever the stored object is not the same reference, and duplicate-checking objects is broken. - 🔶 (features) addOnBlur does not skip blurs into associated popup content. reka's handleBlur reads the input's
aria-controlsid and, if the blur's relatedTarget is inside that controlled element, returns early so it does NOT commit the typed value (TagsInputInput.vue lines 36-41) — essential when TagsInput is composed with a Combobox/listbox so clicking an option adds the option, not the raw text. robonen's onBlur (TagsInputInput.vue lines 65-71) has no relatedTarget/aria-controls check and will wrongly commit the current input text when focus moves into popup content.
Minor gaps (3):
- ▫️ (features) No
data-focusedstate on the Root. reka uses useFocusWithin(currentElement) and sets:data-focused="focused ? '' : undefined"on the Root primitive (TagsInputRoot.vue lines 102, 248), letting consumers style the whole control while focus is anywhere inside. robonen exposes only data-disabled and data-invalid on Root (TagsInputRoot.vue lines 243-244) — no focus-within styling hook. - ▫️ (api-surface) No
idprop on Root forwarded to the input. reka accepts Rootidand passes it to the input element via context (:id="context.id?.value", TagsInputInput.vue line 147; Root id prop line 31). This is the wiring point for labelfor=/ aria-controls in composed widgets. robonen has no id prop on Root and never sets an id on the input, so associating an external - ▫️ (types) modelValue type is narrower for the controlled/null case. reka types modelValue as
Array<T> | nulland normalizes non-arrays to [] via currentModelValue (TagsInputRoot.vue lines 11, 110). robonen types modelValue asU[]only (TagsInputRoot.vue line 7); passing null/undefined as a controlled value is not part of the contract and there is no Array.isArray normalization guard, so a non-array slip-through would throw.
Recommendations to make robonen strictly better:
- Add native form integration to TagsInputRoot: extend props with FormFieldProps-style
name?: string,required?: boolean, andid?: string; detect form ancestry (a useFormControl equivalent) and render a hidden form-bubbling input (build/expose a VisuallyHiddenInput in src/visually-hidden that serializes arrays and object values like reka's VisuallyHiddenInputBubble, including the empty-required case). Forwardidfrom Root through context to the (:id). - Guard object/complex values: throw a descriptive error in onAddValue when the model contains objects but no convertValue is supplied, and switch TagsInputItemDelete's index lookup (and the root duplicate check) to a deep-equality helper (ohash isEqual or an internal structural compare) instead of strict
===, so object tags can be deleted and de-duplicated correctly. - Harden addOnBlur: in TagsInputInput.onBlur, read the input's aria-controls id and bail out when event.relatedTarget is inside that controlled element (use CSS.escape), matching reka, so composing TagsInput with a Combobox/listbox does not commit the raw text when an option is clicked.
- Add focus-within state: wire a useFocusWithin (VueUse) on the Root element and emit
:data-focused=""when focus is inside, giving consumers a styling hook parity with reka. - Widen the controlled contract: type modelValue as
U[] | nulland add an Array.isArray normalization in the watch/init so null/undefined controlled values resolve to [] instead of risking a throw. - Optional polish: skip emitting
update:modelValuefrom Clear/onRemoveValue when the array is already empty (already partly done in Clear) and consider exposing the focused/selected state and an imported VisuallyHiddenInput from the package index for downstream composition.
Where robonen is already better:
- ✅ Reactivity/perf: Root uses shallowRef + a manual length+identity equality watch on modelValue (TagsInputRoot.vue lines 72-88) instead of reka's useVModel with deep:true. reka's deep:true watcher on the tags array is more expensive on large lists; robonen avoids deep proxying entirely.
- ✅ Duplicate detection is display-aware: onAddValue dedups via
v === payload || display(v) === display(payload)(TagsInputRoot.vue line 114), so two distinct objects that render to the same display string are caught. reka only usesarray.includes(payload)(reference/primitive equality) and misses display-equal duplicates. - ✅ Paste handling is cleaner: onPaste trims each part and skips empty strings (
if (trimmed) ctx.onAddValue(trimmed), TagsInputInput.vue lines 96-99). reka's handlePaste calls onAddValue on every split segment including empty strings and untrimmed whitespace, which can create blank/whitespace tags. - ✅ Tag removal emits the correct value directly: onRemoveValue reads
cur[index]from the actual model array (TagsInputRoot.vue line 130). reka's handleRemoveTag emitscollection[index].valuewhere collection is the disabled-FILTERED list indexed by a modelValue index (TagsInputRoot.vue lines 112-118) — a latent desync bug when disabled tags exist. - ✅ Strict, generic-preserving types end-to-end: TagValue generic flows through Root, Item, emits and context, and onAddValue returns boolean with typed payloads. Slightly tighter than reka where AcceptableInputValue includes bigint but context is cast to AcceptableInputValue in places.
- ✅ Clear button guards against empty model (
if (ctx.modelValue.value.length === 0) return, TagsInputClear.vue line 19) and respects disabled before clearing, avoiding a redundant empty update emit that reka would still trigger by assigningmodelValue.value = [].
roving-focus — robonen-better-with-gaps
robonen's roving-focus is a close, high-quality port of reka-ui's RovingFocus with the same two parts (RovingFocusGroup, RovingFocusItem), identical keyboard model (Arrow/Home/End/PageUp/PageDown with orientation gating and RTL Left/Right swap via getDirectionAwareKey), identical roving-tabindex, isTabbingBackOut/isClickFocus Safari handling, entry-focus CustomEvent, loop/wrapArray, allowShiftKey, focusable/disabled handling, and collection-based DOM-ordered item discovery. robonen is genuinely better in a few places: it exports its utils/types/context publicly, its wrapArray guards the empty-array (NaN/undefined) edge case reka misses, and its docs/types are stronger. However there are three real gaps versus reka. (1) CRITICAL: robonen has no v-model / controlled support for currentTabStopId — it is a one-shot ref with no update:currentTabStopId emit and no re-sync from the prop, whereas reka uses useVModel with passive controlled/uncontrolled. (2) MAJOR: robonen's entry-focus candidate list omits the data-highlighted item that reka prioritizes, even though robonen's own listbox/combobox/menu items emit data-highlighted. (3) MINOR: robonen ships no tests for the primitive while reka does. Note: the missing :as-child binding in robonen templates is NOT a gap — robonen's Primitive intentionally uses an as="template" model instead of reka's asChild. Verdict: robonen-better-with-gaps; fixing the three items above makes it strictly better.
Critical gaps:
- ⛔ (api-surface) No two-way binding / controlled support for currentTabStopId. robonen initializes
currentTabStopId = ref(currentTabStopIdProp ?? defaultCurrentTabStopId)exactly once (RovingFocusGroup.vue:74-76) andonItemFocusmutates this local ref directly without ever emitting. Its RovingFocusGroupEmits only declaresentryFocus(RovingFocusGroup.vue:29-31). A parent passing:currentTabStopId(controlled mode) can neither push new values in after mount nor observe internal changes — the prop is read once and then ignored. reka usesuseVModel(props,'currentTabStopId',emits,{ defaultValue, passive })(RovingFocusGroup.vue:75-78) and declares'update:currentTabStopId': [value](RovingFocusGroup.vue:39-42), giving real v-model plus proper controlled vs uncontrolled (passive) behavior.- evidence: reka RovingFocusGroup.vue:39-42 (update:currentTabStopId emit) + :75-78 (useVModel passive controlled/uncontrolled); robonen RovingFocusGroup.vue:29-31 emits lack update:currentTabStopId, and :74-76 is a one-shot ref with no watch/emit.
Major gaps:
- 🔶 (edge-cases) Entry-focus does not consider highlighted items. reka's handleFocus builds candidates as
[activeItem, highlightedItem, currentItem, ...items], where highlightedItem matchesdata-highlighted=''(RovingFocusGroup.vue:105,109). robonen omits the highlighted lookup entirely: candidates are[activeItem, currentItem, ...items](RovingFocusGroup.vue:101-105). This is a real behavioral regression because robonen's own ListboxItem, ComboboxItem, and MenuItemImpl all renderdata-highlighted(listbox/ListboxItem.vue:75, combobox/ComboboxItem.vue:108, menu/MenuItemImpl.vue:82). When such a group regains focus, robonen will not prioritize focusing the highlighted item the way reka does.
Minor gaps (1):
- ▫️ (code-quality) No unit tests ship with the primitive. reka includes RovingFocus.test.ts covering single-entry tab focus, defaultValue selection, loop=false stop-at-last, loop=true wrap, and disabled-item skipping. robonen's roving-focus directory contains no .test.ts/.spec file at all, so none of these behaviors (especially the controlled/v-model and entry-focus paths above) are regression-guarded.
Recommendations to make robonen strictly better:
- Make currentTabStopId a real v-model: add
'update:currentTabStopId': [value: string | null | undefined]to RovingFocusGroupEmits, and back the state with a passive controlled/uncontrolled ref (e.g. the repo's useVModel/useControllableState equivalent) initialized fromcurrentTabStopId ?? defaultCurrentTabStopIdwithpassive: currentTabStopId === undefined. Have onItemFocus assign through it so it emits and respects an externally controlled prop. - Restore highlighted-item priority in handleFocus: compute
const highlightedItem = items.find(item => item.getAttribute('data-highlighted') === '')and build candidates as[activeItem, highlightedItem, currentItem, ...items].filter(Boolean)to match reka and to actually use the data-highlighted attribute that robonen's listbox/combobox/menu items already emit. - Add a RovingFocus test suite mirroring reka's: single tab-entry then tab-out, defaultCurrentTabStopId -> data-active, loop=false stops at last, loop=true wraps, disabled (data-disabled) items skipped, plus new coverage for currentTabStopId v-model (controlled push + update emit) and entry-focus highlighted prioritization.
- Optional robustness wins to keep robonen strictly ahead: drop the redundant
.filter(i => i.dataset['disabled'] !== '')in handleFocus/handleKeydown since getItems() already filters disabled by default (RovingFocusGroup.vue:98-100, RovingFocusItem.vue:75-77 vs collection getItems default filter), and consider exposing currentTabStopId on defineExpose alongside getItems for parity-plus.
Where robonen is already better:
- ✅ Richer public API surface from index.ts: robonen exports the utility helpers (focusFirst, getFocusIntent, getDirectionAwareKey, wrapArray) and types (FocusIntent, Orientation) plus the group context (RovingFocusGroupCtx, RovingFocusGroupContext type). reka-ui's index.ts only exports the two components and their Props/Emits types, keeping utils/context internal. This makes robonen more composable for downstream primitives.
- ✅ wrapArray is safer: robonen's wrapArray (utils.ts:74-78) guards
len === 0and returns the array unchanged, and uses a non-null assertion on a defined index. reka's wrapArray (utils.ts:61-63) doesarray[(startIndex + index) % array.length]with no length guard, so on an empty array it computes% 0=> NaN and would yieldundefinedentries; robonen avoids that NaN/undefined edge case. - ✅ Stronger documentation/types: every robonen prop and the entire context interface carry precise JSDoc (RovingFocusGroup.vue:8-46, RovingFocusItem.vue:4-19, utils.ts), and getItems/getFocusIntent/focusFirst have explanatory doc comments. reka has minimal prop docs and almost none on utils.
- ✅ getFocusIntent uses direct key equality (
key === 'ArrowLeft' || key === 'ArrowRight') instead of reka's['ArrowLeft','ArrowRight'].includes(key)array allocation per keystroke (utils.ts:47-50 vs reka utils.ts:38-41) — a minor hot-path allocation win. - ✅ RovingFocusItem extracts a named handleMousedown function (RovingFocusItem.vue:95-98) rather than reka's inline arrow handler in the template, slightly improving readability/testability.
collection — robonen-better-with-gaps
Parts reka has that robonen lacks: key option on useCollection to namespace/isolate multiple coexisting collections (Collection.ts:12, injectionKey = ${key}CollectionProvider``)
Parts robonen has that reka lacks: Exported public types CollectionContext<Value> and CollectionItemData<Value> (index.ts), Distinct useCollectionProvider / useCollectionInjector entry points instead of a single useCollection({isProvider}), Exposed collectionRef and itemMap on the returned context object (reka returns only getItems/reactiveItems/itemMapSize/CollectionSlot/CollectionItem; it keeps collectionRef/itemMap inside the context but does not surface itemMap typed publicly)
Both libraries implement collection identically in spirit: a CollectionSlot marks the root, CollectionItem registers each child via a data-* attribute and a markRaw'd element->data Map, and getItems() returns items sorted by live DOM order (querySelectorAll) with an optional disabled filter. robonen is the better implementation on code quality: shallowRef+triggerRef+markRaw avoids deep-proxying the Map and element keys (a real perf win over reka's plain ref(new Map())), it exports proper generic types (CollectionContext<Value>, CollectionItemData<Value>) with no @ts-expect-error, splits provider/injector with a missing-provider guard, and is well documented. However robonen has ONE critical functional gap: it lacks reka's key option for namespacing multiple coexisting collections. Because robonen provides under a single fixed Symbol, a nested second collection provider shadows the outer one — and this actually breaks robonen's own NavigationMenu, where the RovingFocusGroup collection inside NavigationMenuList shadows the NavigationMenuRoot collection, leaving NavigationMenuRoot.getItems() empty and breaking modelValue->activeTrigger activation. There is also a minor robustness gap: reka falls back to nextElementSibling when the resolved element is a comment/text node, which robonen does not. Verdict: robonen is better-engineered but has a critical scoping gap that must be closed (and the resulting NavigationMenu bug fixed) before it is strictly better.
Critical gaps:
- ⛔ (features) reka supports a
keyoption (useCollection({ key: 'NavigationMenu' })) to create multiple INDEPENDENT, disjoint collection contexts that can coexist in the same DOM/provide subtree. robonen's collection is provided under a single fixedSymbol('CollectionContext')(useContextFactory, useCollection.ts:160) with no per-call key, so two collection providers in the same subtree cannot coexist — the inner one shadows the outer for everything below it. This is not just theoretical: in robonen's own NavigationMenu,NavigationMenuRootcallsuseCollectionProvider()and wraps children in<CollectionSlot>, butNavigationMenuListrenders<RovingFocusGroup>which ALSO callsuseCollectionProvider()under the same key.NavigationMenuTrigger's<CollectionItem>sits inside the RovingFocusGroup subtree, so it registers into the RovingFocus collection — leavingNavigationMenuRoot.getItems()empty. As a result NavigationMenuRoot.vue:144-149 (watchEffectthat mapsmodelValuetoactiveTriggerviagetItems().find(... id.includes('-trigger-'+value))) never matches, breaking auto-activation of the trigger from the model value. reka avoids exactly this by keying the NavigationMenu collection separately (NavigationMenuRoot.vue:137key:'NavigationMenu', NavigationMenuTrigger.vue:35, NavigationMenuLink.vue:30) so it stays disjoint from the inner RovingFocus collection.- evidence: reka Collection.ts:12-29
key/injectionKeymechanism + NavigationMenu/Menubar usingkey:'NavigationMenu'/key:'Menubar'; robonen useCollection.ts:160 singleuseContextFactory('CollectionContext')with no key param, and the broken nesting in NavigationMenuList.vue:30 (RovingFocusGroup) + NavigationMenuTrigger.vue:117 ( inside it) + NavigationMenuRoot.vue:146 getItems().
- evidence: reka Collection.ts:12-29
Minor gaps (1):
- ▫️ (edge-cases) Comment/text-node fallback when resolving the registered element. reka resolves the collection element through
usePrimitiveElement().currentElement, a computed that detects when the ref's$elis a#textor#commentnode and falls back tonextElementSibling(usePrimitiveElement.ts:7). robonen's CollectionSlot/CollectionItem callunrefElement(el)which returnsinstance.$elverbatim (toolkit unrefElement/index.ts:32) and then only registersif (element instanceof HTMLElement)(useCollection.ts:88,136). When a slotted child is a component whose root renders as a Fragment/comment placeholder (e.g. conditional/async root),$elis a comment node, the instanceof check fails, and the item is silently never registered. reka still registers the correct sibling element. robonen's pre-filters a single Comment/Fragment vnode child, which mitigates the common case, so impact is partial.
Recommendations to make robonen strictly better:
- Add a
key/scope parameter touseCollectionProvider/useCollectionInjector(or accept an explicit injection key/Symbol) so multiple independent collections can coexist in one subtree. Mirror reka's{ key }-> per-key injection symbol. Concretely: changeCollectionCtxfrom a single module-leveluseContextFactory('CollectionContext')into a small registry that maps a string key to a memoizeduseContextFactory('Collection:'+key)(or pass a provided InjectionKey through). - Fix robonen's NavigationMenu (and audit Menubar/Toolbar/Listbox/Tabs/etc.) to use a dedicated keyed collection (e.g. key 'NavigationMenu') so the NavigationMenuRoot collection is not shadowed by the nested RovingFocusGroup collection. Add a test asserting
NavigationMenuRoot.getItems()returns the triggers when a RovingFocusGroup is nested, and that modelValue -> activeTrigger auto-activation (NavigationMenuRoot.vue:144-149) actually matches. - Harden element resolution to match reka: when
unrefElementyields a#comment/#textnode, fall back tonextElementSiblingbefore theinstanceof HTMLElementcheck in both CollectionSlot and CollectionItem (useCollection.ts:87-90, 135-138). Either inline the check or add the fallback to the sharedunrefElementhelper. - Add a thrown/dev-warning guard analogous to reka-less behavior so that calling
useCollectionInjectoroutside a provider gives a clear collection-specific error (already partly covered by useContextFactory, but ensure the message references collection). - Add dedicated tests for the collection primitive (the directory currently has none): cover DOM-order sorting via querySelectorAll across Teleport/v-for reorder, disabled filtering semantics (
dataset.disabled !== ''), re-registration onvaluechange, and coexistence of two keyed collections in one subtree. - Document the
dataset.disabled !== ''filter semantics in getItems (an item is treated as enabled only when it does NOT have an empty-string data-disabled attribute) — this is subtle and shared with reka; a comment/test prevents regressions.
Where robonen is already better:
- ✅ Performance: robonen uses
shallowRef(new Map())+ manualtriggerRef(useCollection.ts:56,118,123) andmarkRaw(el)on the map key (line 116), avoiding wrapping the Map and its element keys in a deep reactive Proxy. reka uses plainref(new Map())(Collection.ts:18) which makes the whole Map deeply reactive and proxies every HTMLElement key — measurably more expensive for large lists/menus/listboxes. - ✅ Public, typed API surface: robonen exports
CollectionContext<Value>andCollectionItemData<Value>as documented interfaces (index.ts) and threads a realValuegeneric throughgetItems,itemMap,reactiveItems. reka'sCollectionContext<ItemData = {}>is internal/not exported, and itemvalueis typedany(Collection.ts:7), with a@ts-expect-errorswallowing the assignment (line 74). robonen has no such suppressed type error. - ✅ Clearer provider/injector split: robonen splits into
useCollectionProvider()anduseCollectionInjector()(useCollection.ts:171,183) with an explicit throw when injected outside a provider (via useContextFactory). reka overloads a singleuseCollection({ isProvider })and silentlyinject(...) as CollectionContextwith no guard (Collection.ts:28) — a missing provider yieldsundefinedand a later runtime crash with no helpful message. - ✅ Documentation quality: robonen has thorough JSDoc on the context, each field, and both composables explaining the querySelectorAll-based DOM ordering, the shallowRef/triggerRef rationale, and markRaw intent; reka's file is essentially uncommented.
- ✅ Item registration watcher is explicit about value changes: robonen uses
watch([currentElement, () => props.value], ..., { immediate: true })with a typedonCleanup(useCollection.ts:110-126), which is easier to reason about than reka'swatchEffect((cleanupFn) => {...})(Collection.ts:71).
dismissable-layer — robonen-better-with-gaps
Parts reka has that robonen lacks: DismissableLayerBranch.vue (branch registration for non-dismissing related subtrees), usePointerDownOutside composable (exported, with touch handling), useFocusOutside composable (exported, with capture-flag inside-tracking), isLayerExist util (document-order layer-aware outside detection), handleAndDispatchCustomEvent-based cancelable CustomEvent dispatch, Exported PointerDownOutsideEvent / FocusOutsideEvent CustomEvent types Parts robonen has that reka lacks: dismissableLayerStack module (exported push/remove/isTopmost/anyDisabling/hasDisablingLayerAbove helper), data-dismissable-blocking attribute on body as a CSS hook
robonen's DismissableLayer is leaner and its module-level escape/dismiss stack is a genuinely cleaner, cheaper topmost-layer mechanism than reka's per-layer index-from-Set computation, and it adds a useful data-dismissable-blocking body hook. However it is NOT yet strictly better: it lacks reka's DismissableLayerBranch (no way to exclude portaled/related subtrees), has no iframe/ownerDocument support, no touch-device pointer deferral (radix #2171), no defer-on-mount guard against open-then-instant-self-dismiss, and weaker focus-outside debouncing (synchronous focusin with no inside-focus capture flags, risking spurious dismiss on internal focus moves). Most importantly its nested pointer-events handling is incomplete: it only sets pointer-events: auto on self-disabling layers, so a non-disabling layer stacked above a disabling one becomes non-interactive — the stack even defines hasDisablingLayerAbove but never uses it. It also drops reka's layer-order-aware outside detection (isLayerExist) and reka's cancelable CustomEvent contract (detail.originalEvent + exported event types). Closing the listed gaps — Branch support, ownerDocument, touch handling, listener defer, focus-outside debounce, per-layer pointer-events via the existing stack helper, layer-order-aware detection, and CustomEvent wrapping — would make robonen strictly better.
Major gaps:
- 🔶 (features) reka exposes a
DismissableLayerBranchcomponent (DismissableLayerBranch.vue) backed bycontext.branches: Set<HTMLElement>. Pointer-down and focus events whose target is inside a registered branch are NOT treated as outside interactions (DismissableLayer.vue lines 106-110 and 120-124). This lets a related-but-DOM-separate subtree (e.g. a portaled trigger, anchor, or sibling control) avoid dismissing the layer. robonen has NO Branch component and no branches concept; its useClickOutside has anignoreoption but DismissableLayer never forwards it, so there is no way for a consumer to register a non-dismissing related subtree. - 🔶 (edge-cases) reka supports iframe / multi-document via
ownerDocument = layerElement.value?.ownerDocument ?? globalThis.document(DismissableLayer.vue line 81-83; utils.ts line 52-53, 155-156) and attaches listeners and reads/writes body.style on that owner document. robonen hardcodes the globaldocument(focusin listener and body-blocking effect) and the globalwindow(useClickOutside/useEscapeKey via defaultWindow), so a layer rendered into an iframe document will not receive outside-pointer/focus/escape events nor block the correct body. - 🔶 (edge-cases) reka has dedicated touch-device handling for pointerDownOutside: on
event.pointerType === 'touch'it defers dispatch to a one-shotclicklistener to account for the ~350ms touch delay and to auto-cancel on scroll/drag/long-press, continuously removing the prior click listener (utils.ts lines 95-110). robonen reacts immediately onpointerdownfor all pointer types with no touch deferral, so a touch scroll/drag that starts outside can incorrectly dismiss and pointer-events may be re-enabled before the browser fires the deferred click (the exact radix-ui/primitives#2171 case reka cites). - 🔶 (features) Per-layer pointer-events granularity for nested layers. reka computes
isPointerEventsEnabledby comparing this layer's index against the index of the highest layer with outside-pointer-events disabled, and renderspointer-events: 'none'for layers below the disabling layer while keeping the body blocked (DismissableLayer.vue lines 97-103, 180-186). robonen only setspointer-events: 'auto'on a layer if THAT layer itself hasdisableOutsidePointerEvents(template line 128). Consequence: a non-disabling layer stacked ABOVE a disabling layer never getspointer-events: auto, so while the body isnonethat upper layer becomes non-interactive — a real nested-modal bug robonen's stack hashasDisablingLayerAbovefor but never uses in the template. - 🔶 (edge-cases) Focus-outside debounce against transient focus churn. reka's useFocusOutside awaits
nextTick()twice before resolving the target (utils.ts lines 166-167) and uses focus/blur capture flags (onFocusCapture/onBlurCapture) to know whether focus is currently inside, avoiding falsefocusOutsideduring internal focus reshuffles (e.g. focus moving between two children, or a momentary body focus). robonen's focusin handler (lines 84-96) fires synchronously on any focusin whose target is outside the layer with no inside-focus flag and no tick delay, so transient/relayed focus events can spuriously emit focusOutside and dismiss. - 🔶 (edge-cases) Layer-aware outside detection across portals/nested layers. reka's
isLayerExist(utils.ts lines 16-40) treats a pointer/focus on a layer that is later in document order (a layer rendered above this one) as NOT outside, using[data-dismissable-layer]ordering in the document. robonen's useClickOutside only checksel.contains(target)plus the optional ignore list, so interacting with a sibling/nested dismissable layer that is not a DOM descendant is considered outside and can dismiss the lower layer even though it should be excluded. - 🔶 (edge-cases) reka defers the pointerdown document listener registration by one
setTimeout(..., 0)to avoid the layer's own mounting pointerdown immediately bubbling to the document and self-dismissing (utils.ts lines 113-128). robonen registers the capture-phase window pointerdown listener immediately on mount with no defer, so a layer opened by the very pointerdown that mounts it can receive that same pointerdown as 'outside' and dismiss instantly (a classic open-then-instant-close bug).
Minor gaps (4):
- ▫️ (api-surface) reka wraps outside interactions as cancelable DOM CustomEvents dispatched on the actual target via handleAndDispatchCustomEvent (utils.ts + shared/handleAndDispatchCustomEvent.ts):
pointerDownOutside/focusOutsidecarrydetail.originalEventandpreventDefault()is real DOM prevention observable by other listeners and by the dispatching element. robonen emits raw PointerEvent/FocusEvent and fakes cancellation only forinteractOutsidevia a temporary preventDefault override (createInteractEvent lines 55-66); the typed event detail wrapper (PointerDownOutsideEvent/FocusOutsideEvent= CustomEvent<{originalEvent}>) that reka exports is absent, so cross-library/consumer code expecting.detail.originalEventwill break. - ▫️ (api-surface) reka exports the reusable composables
usePointerDownOutsideanduseFocusOutside(and types) from the package index (DismissableLayer/index.ts line 7), letting consumers build their own dismiss logic. robonen's index.ts exports only the component,dismissableLayerStack, and the two types — there is no exported focus-outside/pointer-down-outside composable from this module (the underlying useClickOutside lives in a different package, not surfaced here). - ▫️ (api-surface) Emit ordering of interactOutside vs specific events differs and reka's contract is the one consumers (Dialog/Menu/Popover) are written against. reka emits the specific event first then
interactOutside(lines 112-113, 126-127), sharing the SAME cancelable CustomEvent so a preventDefault in either handler blocks dismiss. robonen emitsinteractOutsideFIRST and only for pointer/focus paths via createInteractEvent, an ordering that can surprise consumers who expect to read the specific event's prevented state, and the temporary preventDefault override means a preventDefault in the interactOutside handler does not flow into the same object the specific handler later inspects. - ▫️ (edge-cases) reka awaits
nextTick()before the post-pointerDownOutside dismiss check (DismissableLayer.vue line 114) so a consumer that asynchronously calls preventDefault (or reacts in a microtask) is still honored. robonen checksevent.defaultPreventedsynchronously immediately after emit (line 79), so any async/deferred preventDefault is missed.
Recommendations to make robonen strictly better:
- Add a
DismissableLayerBranchcomponent plus a branches Set in stack.ts; in the pointerdown/focusin handlers, ignore events whose target is contained in any registered branch. Forward this through theignoreoption of useClickOutside as well. This fixes portaled-trigger and related-control false dismissals. - Use
currentElement.ownerDocument(falling back to globalThis.document) for the focusin listener, the body pointer-events mutation, and pass an owner document/window to useClickOutside/useEscapeKey so layers inside iframes work. Do not hardcode globaldocument/window. - Add touch-device handling in the outside-pointer path: when
event.pointerType === 'touch', defer dispatch to a one-shotclicklistener (and continuously remove the previous one) to respect the ~350ms touch delay and auto-cancel on scroll/long-press. Cite radix-ui/primitives#2171. - Defer registration of the outside-pointerdown listener by one macrotask (setTimeout 0) after mount to prevent the opening pointerdown from immediately self-dismissing the layer.
- Use the already-defined
dismissableLayerStack.hasDisablingLayerAbove/index ordering to compute per-layer pointer-events in the template: a layer should bepointer-events: autoif it is at or above the highest disabling layer, andnoneif it is below one, mirroring reka's isPointerEventsEnabled. Today only self-disabling layers getauto, breaking non-disabling layers stacked above a disabling one. - Harden focus-outside: track inside-focus via focus/blur capture flags on the Primitive and await one or two ticks before deciding the focus truly left, to avoid spurious focusOutside/dismiss during internal focus moves.
- Implement layer-ordering-aware outside detection (equivalent to reka's isLayerExist) so interactions on a higher/sibling dismissable layer are not treated as outside for lower layers — relying on contains() alone is insufficient for portaled nested layers.
- Wrap outside interactions in cancelable CustomEvents carrying
detail.originalEvent, dispatched on the target, and exportPointerDownOutsideEvent/FocusOutsideEventtypes for parity with reka so consumer code reading.detail.originalEventand using real DOM preventDefault works. - Emit the specific event (pointerDownOutside/focusOutside) and interactOutside as the SAME event object with the specific one first, and await nextTick() before the dismiss check so async preventDefault is honored.
- Export reusable
usePointerDownOutside/useFocusOutsidestyle composables from the dismissable-layer index so consumers can build custom dismiss logic, matching reka's exported API surface.
Where robonen is already better:
- ✅ Escape handling uses a dedicated module-level stack composable (useEscapeKey + dismissableLayerStack.isTopmost) so only the genuine topmost layer fires, and the listener is installed once globally and torn down when empty. reka instead recomputes
index.value === layers.value.size - 1from a reactive Set on every keystroke per-layer, which is O(layers) and re-derives ordering from Set insertion order. - ✅ Cleaner, smaller surface: a single
DismissableLayer.vue(133 lines) plus a tiny 40-linestack.tsvs reka's component + 207-lineutils.tswith custom-event dispatch machinery. The stack abstraction (push/remove/isTopmost/anyDisabling/hasDisablingLayerAbove) is reusable and self-documenting. - ✅ Body pointer-events teardown is guarded by
dismissableLayerStack.anyDisabling()so it only restores the original value when no other disabling layer remains; reka's teardown only fires whensize === 1inside that layer's own cleanup, which is correct but more fragile. - ✅ Adds a
data-dismissable-blockingattribute on<body>while blocking, giving consumers a clean CSS hook ([data-dismissable-blocking] *:not([data-dismissable-layer])) that reka does not provide. - ✅ SSR safety is explicit and centralized: the underlying useClickOutside/useEscapeKey/useEventListener composables early-return
noopwhendefaultWindowis falsy, and the body-blocking effect guardstypeof document === 'undefined'.
navigation-menu — robonen-better-with-gaps
Both libraries implement the same 10 parts (Root, Sub, List, Item, Trigger, Link, Content, ContentImpl, Viewport, Indicator) and the architecture is near-identical (robonen is clearly a port of reka's NavigationMenu). robonen has a few genuine improvements: a sensible default aria-label='Main' landmark, explicit timer teardown via onScopeDispose, and a more precise active-trigger match (-trigger-${value} vs reka's substring includes). However robonen currently has several real regressions that prevent 'strictly better': (1) none of the structural parts support as/asChild (reka extends PrimitiveProps everywhere); (2) the Indicator lacks aria-hidden; (3) animating-out content/viewport are never made hidden/pointer-events:none; (4) arrow nav inside content hijacks INPUT/TEXTAREA (reka uses enableIgnoredElement); (5) declared DismissableLayerProps on ContentImpl are dropped instead of forwarded and disableOutsidePointerEvents is hardcoded; (6) the root-content-dismiss path does not return focus to the trigger; (7) viewport positioning is weaker (horizontal-only clamping, no vertical align, no window/root resize reposition); plus minor keyboard (no nested-menu guard, no item-level Enter/Space focus-return) and Link select payload gaps. Implementing the recommendations — especially as/asChild, aria-hidden on indicator, hidden/pointer-events on closed panels, the INPUT/TEXTAREA arrow-nav bail-out, prop forwarding on ContentImpl, focus-return on dismiss, and richer viewport positioning — would make robonen strictly better than reka.
Major gaps:
- 🔶 (api-surface) All structural parts support
as/asChildpolymorphism in reka but NOT in robonen. reka's Root, Sub, List, Item, Trigger, Viewport and Indicator allextends PrimitivePropsand render:as/:as-child. robonen's equivalents do not extend PrimitiveProps and hardcode the element (Root forced as='nav', Item as='li', List inner as='ul', Trigger as='button', Viewport/Indicator have no as forwarding). Consumers cannot render the root as a custom element, render the trigger asChild around their own button, etc. - 🔶 (accessibility) Indicator is not hidden from assistive tech in robonen. reka sets
aria-hidden="true"on the indicator Primitive; the indicator is purely decorative. robonen's NavigationMenuIndicator renders the Primitive WITHOUT aria-hidden, exposing the decorative indicator to AT. - 🔶 (edge-cases) Closed/animating-out content and viewport are not made non-interactive in robonen. reka applies
:hidden="!present"to both NavigationMenuContentImpl and NavigationMenuViewport, andpointer-events: nonewhile closed (pointerEvents: !open && isRootMenu ? 'none' : undefined). robonen passes neither, so during the keep-mounted exit animation (isLastActiveValue / viewport exit) stale content remains interactive and visible to AT. - 🔶 (forms) Arrow-key navigation inside content hijacks text fields in robonen. reka's content keydown passes
enableIgnoredElement: trueto useArrowNavigation (ignore list ['INPUT','TEXTAREA']), so arrow keys with focus in an input/textarea inside the panel move the caret. robonen's NavigationMenuContentImpl.handleKeydown always focuses the next link with no INPUT/TEXTAREA bail-out, stealing focus from form fields. - 🔶 (api-surface) Declared DismissableLayer props are dropped instead of forwarded in robonen content. reka's NavigationMenuContentImpl binds
v-bind="props"so user-supplied DismissableLayerProps reach DismissableLayer. robonen declaresdefineProps<NavigationMenuContentImplProps>()(extends DismissableLayerProps) but the template binds onlyv-bind="$attrs"and hardcodes:disable-outside-pointer-events="false", so declared props are consumed by the component and never forwarded. - 🔶 (features) Viewport positioning is weaker: no vertical alignment and no right/bottom collision handling. reka computes both left AND top relative to the root, applies align to both axes, and clamps against all four screen edges with re-checks, plus re-positions on body/root ResizeObserver. robonen computes only
leftfrom align and a fixedtop = triggerRect.bottom, clamps only horizontally, ignores vertical-orientation alignment and bottom/top collision, and only observes the content ResizeObserver. - 🔶 (keyboard) Root content dismiss does not refocus the trigger in robonen, so focus can be lost. reka's NavigationMenuContentImpl EVENT_ROOT_CONTENT_DISMISS handler runs onItemDismiss(), onRootContentClose(), and
if (content.contains(getActiveElement())) triggerRef.value?.focus(). robonen's ContentImpl dismiss listener only calls itemContext.onRootContentClose() (tab-order restore) and never re-focuses the trigger, so after selecting a link that closes the menu focus can fall to .
Minor gaps (4):
- ▫️ (keyboard) Nested-submenu content keydown lacks the parent-vs-self guard reka has. reka's content handleKeydown begins with
if (ev.target.closest('[data-reka-navigation-menu]') !== menuContext.rootNavigationMenu.value) returnso a parent menu's content does not handle keydown bubbling from a nested submenu's content. robonen's handleKeydown has no such guard. - ▫️ (keyboard) Item-level Enter/Space handling and focus-return is absent in robonen. reka's NavigationMenuItem.handleKeydown intercepts Enter/Space: if open it dismisses and returns focus to the trigger (handleClose -> triggerRef.focus()), else clicks the focused element. robonen relies on native click and never re-focuses the trigger after a keyboard-driven close.
- ▫️ (api-surface) Link select event payload is less informative in robonen. reka emits the select CustomEvent with
detail: { originalEvent }(typed CustomEvent<{ originalEvent: Event }>), letting consumers inspect the originating click. robonen dispatches LINK_SELECT_EVENT with no detail and re-emits the raw event, so the typed select payload carries no originalEvent. - ▫️ (edge-cases) Link dismiss target divergence: reka dispatches EVENT_ROOT_CONTENT_DISMISS on
ev.target(actual clicked element, the documented Radix behavior) whereas robonen dispatches onevent.currentTarget. Minor behavioral divergence in nested-link scenarios.
Recommendations to make robonen strictly better:
- Add
extends PrimitiveProps(as/asChild) to NavigationMenuRoot, Sub, List, Item, Trigger, Viewport and Indicator, and forward:as/:as-childto the underlying Primitive (default Root 'nav', Item 'li', List 'ul', Trigger 'button'). Restores polymorphism parity. - Add
aria-hidden="true"to the NavigationMenuIndicator Primitive — it is decorative and must not be exposed to AT. - Set
:hidden="!present"on NavigationMenuContentImpl and NavigationMenuViewport (use the Presence slot'spresent) and applypointer-events: nonewhile!open && isRootMenuso animating-out panels are inert and hidden from AT. - In NavigationMenuContentImpl.handleKeydown, bail out of arrow navigation when the focused element is an INPUT or TEXTAREA (mirror reka's enableIgnoredElement) so form fields inside the panel keep native caret movement.
- Forward the declared DismissableLayerProps to DismissableLayer (bind the captured props, not just $attrs) instead of dropping them; keep disableOutsidePointerEvents user-overridable rather than hardcoded to false.
- Add the nested-menu guard
if (ev.target.closest('[data-primitives-navigation-menu]') !== menuContext.rootNavigationMenu.value) returnat the top of content handleKeydown so parent content does not double-handle nested submenu keydown. - In the root EVENT_ROOT_CONTENT_DISMISS handler inside NavigationMenuContentImpl, also call onItemDismiss() and re-focus itemContext.triggerRef when the content still contains the active element, so link-select-driven closes return focus to the trigger.
- Strengthen NavigationMenuViewport positioning: compute both left and top relative to the root, apply align to the cross axis for vertical orientation, clamp against all four screen edges with re-checks, and re-run updatePosition on body/root ResizeObserver (window/root resize).
- Add explicit Enter/Space handling at the item or trigger level that returns focus to the trigger after a keyboard-driven close (reka's handleClose pattern) for deterministic focus restoration.
- Enrich the NavigationMenuLink select payload with
detail: { originalEvent }and type the emit as CustomEvent<{ originalEvent: Event }> for parity and better consumer ergonomics. - Dispatch the root-content-dismiss event on the actual clicked target (event.target) rather than currentTarget to match reka/Radix behavior for nested links.
Where robonen is already better:
- ✅ Sensible default landmark label: NavigationMenuRoot defaults aria-label to 'Main' when the user supplies none, and still lets a user-supplied aria-label win (verified by NavigationMenu.landmark.test.ts). reka never sets a default aria-label on its nav, so reka's root is an unlabeled landmark by default.
- ✅ Cleaner debounce + skip-delay timer management with explicit teardown: NavigationMenuRoot uses manual setTimeout-based debounce plus onScopeDispose(clearDebounce/clearSkipDelay), so all timers are guaranteed cleared on unmount; reka relies on @vueuse useDebounceFn/refAutoReset whose cancellation on unmount is implicit.
- ✅ More robust active-trigger matching: robonen matches the trigger by the specific pattern
-trigger-${modelValue}(makeTriggerId), whereas reka matches with a looseitem.id.includes(modelValue.value)which can mis-match when one item value is a substring of another (e.g. value 'a' matching id of value 'ab'). - ✅ Indicator size/position tracking observes BOTH the active trigger AND the indicator track via two ResizeObservers and recomputes on either change (watch on [activeTrigger, indicatorTrack, isHorizontal]) with explicit disconnect in onScopeDispose.
- ✅ JSDoc on every prop in the props interfaces (delayDuration, skipDelayDuration, disable* flags, unmountOnHide, align) gives better editor IntelliSense than reka's terser doc comments.
- ✅ Trigger keydown explicitly stopPropagation()s the entry-key (Arrow into content) so the inner RovingFocusItem doesn't also act on it — matches reka's intent and is cleanly scoped.
listbox — robonen-better-with-gaps
Parts reka has that robonen lacks: ListboxVirtualizer
robonen's Listbox covers the core surface (Root, Content, Item, ItemIndicator, Group, GroupLabel, Filter) with the same roles, ARIA wiring (role=listbox/option/group, aria-selected, aria-multiselectable, aria-activedescendant, aria-orientation), controlled+uncontrolled modelValue, multiple + selectionBehavior toggle/replace, RTL/dir via ConfigProvider, highlightOnHover, by-comparator, entryFocus/leave/highlight emits, type-ahead, Home/End/Arrow navigation, Enter/Space activation, and Ctrl/Cmd+A select-all — and it is genuinely leaner and more performance-conscious than reka (inlined loops, shallowRef, module-scoped Sets, nextTick over setTimeout, cleaner aria-multiselectable, full types/JSDoc). However it is NOT yet strictly better: it is missing reka's ListboxVirtualizer (virtual scrolling), native form integration (name/required + VisuallyHiddenInput), shift-range selection (firstValue + findValuesBetween), PageUp/PageDown paging keys, IME composition guards, buffered multi-character type-ahead, an imperative defineExpose API (highlightItem/getItems/etc.), and deep object equality fallback. The virtualizer, form integration, shift-range, PageUp/PageDown, IME, and richer type-ahead are the load-bearing gaps to close.
Major gaps:
- 🔶 (features) No virtualization. Reka ships ListboxVirtualizer.vue (TanStack vue-virtual) wiring isVirtual + virtualFocusHook/virtualKeydownHook/virtualHighlightHook into Root, enabling large lists with aria-setsize/aria-posinset and virtual scroll-to-index navigation/typeahead. Robonen has no equivalent component or virtual hooks at all.
- 🔶 (forms) No form integration. Reka Root extends FormFieldProps (name, required) and renders when inside a form, so the listbox submits with a form. Robonen items carry a
valuebut Root has no name/required props and renders no hidden input, so it cannot participate in native form submission. - 🔶 (keyboard) No shift-range selection. Reka supports range selection in multiple+replace mode: Shift+Arrow/Home/End selects a contiguous range via firstValue tracking + handleMultipleReplace + findValuesBetween. Robonen tracks no firstValue and never handles event.shiftKey, so Shift-range selection is impossible.
- 🔶 (keyboard) PageUp/PageDown keys not handled. Reka maps PageUp->first and PageDown->last (MAP_KEY_TO_FOCUS_INTENT) so those keys jump to the first/last item. Robonen's rovingKeyToAction only handles Arrow/Home/End and returns null for PageUp/PageDown, and ListboxContent's NAV_KEYS set excludes them, so they do nothing.
- 🔶 (edge-cases) No IME composition handling. Reka guards Enter while composing (isComposing) and wires @compositionstart/@compositionend on both Root flow and Filter, preventing premature selection during CJK/IME input. Robonen has no isComposing, no onCompositionStart/End, and the Filter has no composition listeners — Enter mid-composition can select.
- 🔶 (features) Weaker type-ahead. Reka uses useTypeahead/getNextMatch: a 1s-reset search buffer supporting multi-character prefix matching, repeated-character normalization (cycling), and item-supplied textValue. Robonen only matches a single event.key letter via textContent.startsWith(letter) with no accumulation buffer, no multi-char prefixes, and no textValue override.
Minor gaps (4):
- ▫️ (api-surface) No imperative API exposed on Root. Reka defineExpose exposes highlightItem(value), highlightFirstItem(), highlightSelected(), getItems(), and highlightedElement for programmatic control. Robonen's Root exposes nothing beyond forwardRef; there is no highlightItem(value) to programmatically highlight by value, and no exposed getItems/highlightedElement.
- ▫️ (edge-cases) Object comparison without
bydiffers. Reka's compare falls back to ohash isEqual for deep structural equality when no comparator/string-key is given (and value isn't a string). Robonen's compare falls back to reference equalitya === b, so two structurally-equal object values won't be treated as selected unless the consumer passesby. - ▫️ (edge-cases) Filter onValueChange/typeahead does not set isUserAction. Reka's onKeydownTypeAhead sets isUserAction=true so the programmatic highlightSelected watcher does not fight the typeahead highlight. Robonen's onKeydownTypeAhead never sets isUserAction, so a programmatic modelValue change racing with typeahead could re-run the checked-item scan and override the typeahead highlight.
- ▫️ (types) Filter slot/v-model typing: reka declares defineSlots with typed modelValue slot prop on both Root and Filter. Robonen passes :model-value to the slot but does not declare defineSlots, so slot prop types are not surfaced to consumers (weaker DX/types).
Recommendations to make robonen strictly better:
- Add a ListboxVirtualizer component (or virtual hooks on the context: isVirtual + focus/keydown/highlight EventHooks) so large collections can be virtualized with aria-setsize/aria-posinset and scrollToIndex navigation, matching reka ListboxVirtualizer.vue. Without it, robonen cannot claim parity for large lists.
- Add form integration to ListboxRoot: extend props with name?/required?, detect form context (a useFormControl equivalent), and render the existing src/visually-hidden VisuallyHidden as a hidden input mirroring modelValue/disabled/required so the listbox submits with native forms (reka ListboxRoot.vue:415-421).
- Implement shift-range selection for multiple+selectionBehavior='replace': track a firstValue ref, handle event.shiftKey in onKeydownNavigation, and add a findValuesBetween util to select contiguous ranges on Shift+Arrow/Home/End (reka handleMultipleReplace + arrays.ts findValuesBetween).
- Extend utils/roving-focus.ts rovingKeyToAction to map PageUp->{absolute:'home'} and PageDown->{absolute:'end'}, and add 'PageUp'/'PageDown' to ListboxContent NAV_KEYS, so paging keys jump to first/last like reka MAP_KEY_TO_FOCUS_INTENT.
- Add IME composition guards: an isComposing ref set via onCompositionStart/onCompositionEnd on the context, consulted in onKeydownEnter, and wire @compositionstart/@compositionend on ListboxFilter (and the listbox content) to avoid selecting mid-composition (reka ListboxRoot.vue:230-237).
- Upgrade type-ahead to a multi-character buffered search with ~1s reset and repeated-character normalization (port getNextMatch/useTypeahead semantics), and support an item-level textValue override instead of only textContent.startsWith on a single key (reka shared/useTypeahead.ts).
- defineExpose on ListboxRoot: expose highlightItem(value), highlightFirstItem(), highlightSelected(), getItems(), and highlightedElement so consumers can drive highlighting programmatically; add a highlightItem(value) helper that resolves the element by value (reka ListboxRoot.vue:358-364).
- Make compare() fall back to a deep structural equality (e.g. a small isEqual) when by is undefined and the value is a non-primitive object, so structurally-equal object values are recognized as selected without forcing consumers to pass
by(reka utils.ts:29). - Set isUserAction=true inside onKeydownTypeAhead (reset on nextTick) to prevent the programmatic highlightSelected watcher from clobbering type-ahead highlights (reka ListboxRoot.vue:203).
- Declare defineSlots on ListboxRoot and ListboxFilter to type the modelValue slot prop, improving consumer DX/types (reka ListboxRoot.vue:94-99, ListboxFilter.vue:29-34).
Where robonen is already better:
- ✅ Performance hygiene in hot paths: enabledEls() is an inlined manual loop avoiding the .map().filter() double-allocation + two closures that reka's getCollectionItem() incurs on every keydown/typeahead/navigation; NAV_KEYS is a module-scoped Set instead of being re-evaluated per event; includes()/compare() use manual for-loops instead of .some(). (ListboxRoot.vue:103-111, utils.ts:23-26)
- ✅ Uses nextTick() instead of setTimeout(...,1) to reset isUserAction, avoiding a timer-handle allocation and the 1ms latency window reka has in onValueChange/onKeydownTypeAhead (ListboxRoot.vue:144-147 vs reka ListboxRoot.vue:144-146,225-227).
- ✅ shallowRef for localValue with an explicit comment that the value is always replaced on commit (never mutated in place), avoiding reka's deep:true watcher on modelValue (reka ListboxRoot.vue:117 deep:true, line 356 deep watch). Robonen's programmatic watch is a plain watch(localValue,...) with a manual element scan, cheaper than reka's deep diff.
- ✅ Cleaner aria-multiselectable: emits the attribute only when multiple is true (
ctx.multiple.value ? true : undefined) instead of reka always rendering aria-multiselectable="false" via!!rootContext.multiple.value(ListboxContent.vue:63 vs reka ListboxContent.vue:29). - ✅ Explicit return types on every function and full JSDoc on props/emits; reka relies on inference and has a
// @ts-expect-error ignoringon onValueChange in the provide call (reka ListboxRoot.vue:368). - ✅ Item Space activation calls event.preventDefault() before handleSelect via a dedicated onKeyDown guard (ListboxItem.vue:51-55), equivalent to reka but written as typed handler rather than inline template expression; overall robonen keeps logic in <script> rather than large inline template arrow functions (reka ListboxContent.vue:32-57 has multi-line inline handlers).
- ✅ ListboxItem disabled attribute is gated with
|| undefinedso disabled is omitted entirely when false; data-disabled uses '' sentinel consistently — matches reka but robonen also sets the realdisabledattribute (reka only sets data-disabled='' string, never the boolean attribute on line 85-86).
calendar — robonen-better-with-gaps
Parts reka has that robonen lacks: SR-only role=heading aria-level=2 live label inside CalendarRoot, isPlaceholderFocusable / firstFocusableDate / hasSelectedDate / isSelectedDateDisabled context state, disableDaysOutsideCurrentView wiring in CalendarCell and CalendarCellTrigger, outsideVisibleView slot prop + data-outside-visible-view on CalendarCellTrigger, aria-hidden thead in CalendarGridHead
Parts robonen has that reka lacks: nextYear/prevYear exposed via context, CalendarHeadCell day prop with localized long aria-label
Both libraries ship the same 12-part component set with very similar APIs. robonen is genuinely better on keyboard breadth (Home/End/PageUp/PageDown/Shift+PageUp-Down — reka has none of these) and avoids the @internationalized/date dependency. However robonen has several real, fixable gaps: fixedWeeks is a non-functional no-op (always renders 6 weeks); no multiple selection, preventDeselect, deselect-on-reclick, or disableDaysOutsideCurrentView; keyboard navigation can land on and stop at disabled days (reka skips them); arrow-shift doesn't sync the placeholder within the view; there is no SR-exposed heading (CalendarHeading is aria-hidden with no replacement, unlike reka's hidden role=heading); no locale-aware default weekStartsOn; the roving fallback can target a disabled first-of-month; and thead isn't aria-hidden. Net: robonen leads on keyboard and bundle size but trails on feature completeness (multiple/deselect/fixedWeeks), i18n defaults, and a couple of a11y details — so it is not yet strictly better.
Major gaps:
- 🔶 (features) robonen's
fixedWeeksprop is non-functional. getWeeks() in date-utils.ts always loopsfor (w = 0; w < 6)and unconditionally returns a 6×7 (42-cell) grid; the fixedWeeks ref is provided to context but never consumed when building the grid. So fixedWeeks={false} is a silent no-op and the calendar ALWAYS renders 6 weeks. Reka's createMonth (date/calendar.ts lines 61-101) builds the real number of weeks and only pads to 42 when fixedWeeks is true. - 🔶 (features) No multiple-date selection. robonen modelValue is strictly
Date | undefinedand setDate replaces the single value. Reka supportsmultipleprop with modelValueDateValue | DateValue[], toggling membership (add/remove) in onDateChange. - 🔶 (features) No deselect-on-reclick and no preventDeselect option. In reka, clicking an already-selected date deselects it (onDateChange lines 272-276) unless preventDeselect is set. robonen's setDate has no deselect path — clicking a selected date just re-sets the same date, and there is no preventDeselect prop.
- 🔶 (keyboard) Keyboard navigation does not skip disabled days. robonen shiftFocus() focuses the candidate cell even if it is disabled (focusByDataValue query is
:not([data-outside-view])only, NOT:not([data-disabled])), so arrow keys can land focus on a disabled day and stop. Reka recurses past disabled cells:if (candidateDay.hasAttribute('data-disabled')) return shiftFocus(candidateDayValue, add). - 🔶 (accessibility) No SR-only live heading. Reka renders a visually-hidden
<div role="heading" aria-level="2">{{ fullCalendarLabel }}</div>inside the root so screen readers announce the current month/year as a heading. robonen's CalendarRoot has no such element; its CalendarHeading is explicitly aria-hidden="true", so the heading text is hidden from AT with no SR-exposed replacement.
Minor gaps (8):
- ▫️ (features) No disableDaysOutsideCurrentView. Reka can disable days that belong to adjacent months (affects isDisabled, aria-disabled, data-disabled, tabindex). robonen has no such prop; outside-view days remain enabled/clickable.
- ▫️ (keyboard) Arrow-key focus shift does not move the placeholder/displayed month context. Reka calls rootContext.onPlaceholderChange(candidateDayValue) on every successful shift (CalendarCellTrigger.vue line 165), keeping placeholder in sync with the focused day. robonen only updates placeholder when crossing the visible page boundary (shiftFocus lines 91-99), leaving placeholder stale relative to the focused cell within the same view.
- ▫️ (rtl-i18n) No locale-aware default weekStartsOn. Reka defaults weekStartsOn to getWeekStartsOn(locale) so e.g. 'en-US' starts Sunday, 'fr' starts Monday automatically. robonen hard-defaults weekStartsOn=0 (Sunday) regardless of locale.
- ▫️ (rtl-i18n) No non-Gregorian calendar / era / timezone support. Reka uses @internationalized/date (DateValue) supporting BC era display (headingFormatOptions era handling in useCalendar.ts lines 128-137), arbitrary calendar systems, and timezone-correct 'today' via getLocalTimeZone(). robonen uses native Date with isToday computed as isSameDay(day, new Date()) (no tz control) and Intl formatting only against Gregorian.
- ▫️ (edge-cases) Roving-tabindex fallback can land on a disabled date. robonen isFocusedDate fallback returns the first-of-month (day.getDate() === 1) when neither selection nor today is in view, without checking disabled/unavailable/min-max (CalendarCellTrigger.vue lines 49-59). Reka computes firstFocusableDate by scanning for the first non-disabled, non-unavailable date respecting minValue (useCalendar.ts lines 360-375) so the initial tab stop is always actionable.
- ▫️ (accessibility) GridHead is not aria-hidden. Reka's CalendarGridHead sets aria-hidden="true" on the (it relies on cell aria-labels for SR), avoiding double-announcement of weekday columns. robonen's CalendarGridHead is a bare passthrough with no aria-hidden.
- ▫️ (rtl-i18n) Grid week boundaries use JS Date.getDay() rather than locale-aware getDayOfWeek. robonen startOfWeek/getWeeks compute day-of-week from native (date-utils.ts), which is correct for Gregorian but not driven by the locale's calendar; reka uses getLastFirstDayOfWeek/getNextLastDayOfWeek with locale + getDayOfWeek (date/comparators.ts) for calendar-correct week alignment.
- ▫️ (accessibility) CalendarGrid role differs: reka sets role="application" on the grid table (CalendarGrid.vue) while the root is just a labeled container; robonen sets role="application" on the root AND role="grid" on the table. Reka's split (root = region with hidden heading, grid = application) is the documented WAI pattern for keyboard-grid calendars; robonen's role=grid on a non-roving-managed table plus role=application on root is a less conventional combination worth reconciling.
Recommendations to make robonen strictly better:
- Make fixedWeeks actually work: change getWeeks (date-utils.ts) to build only the real number of weeks (start-of-week of month start through end-of-week of month end) and pad to 42 cells ONLY when fixedWeeks is true; thread fixedWeeks into createMonths/getWeeks so fixedWeeks={false} renders 4-6 weeks.
- Add multiple-selection support: allow modelValue to be
Date | Date[], add amultipleprop, and implement add/remove toggle logic in setDate mirroring reka's onDateChange array branch; update isDateSelected to check array membership. - Implement deselect-on-reclick plus a
preventDeselectprop: in setDate, if the clicked date equals the current single value and !preventDeselect, set modelValue=undefined (and move placeholder to that date). - Add
disableDaysOutsideCurrentViewprop and fold it into isDateDisabled in CalendarCell and CalendarCellTrigger (affecting aria-disabled, data-disabled, tabindex) like reka. - Skip disabled days during keyboard navigation: in CalendarCellTrigger.shiftFocus/focusByDataValue, exclude [data-disabled] from the query and recurse to the next candidate in the same direction (reka pattern) so arrows never park focus on a disabled cell.
- Call setPlaceholder(target) on every successful shiftFocus (not only on page-boundary crossings) so the displayed month context tracks the focused day.
- Add a visually-hidden
role="heading" aria-level="2"element inside CalendarRoot rendering fullCalendarLabel, so the current month/year is exposed to screen readers (CalendarHeading remains aria-hidden for the visible UI). - Derive a locale-aware default weekStartsOn (e.g. compute from Intl/locale) when the prop is not supplied, instead of hard-coding 0.
- Harden the roving-tabindex fallback: compute a firstFocusableDate that skips disabled/unavailable dates and honors minValue, and use it instead of the unconditional first-of-month fallback in isFocusedDate.
- Set aria-hidden="true" on CalendarGridHead's thead to avoid double weekday announcements.
- Consider isToday timezone correctness (allow a timezone or use a single 'now' source) and optionally support non-Gregorian calendars/era display for full i18n parity, or document the Gregorian-only limitation.
- Reconcile ARIA roles to the WAI calendar pattern: keep role=application on the interactive grid and a labeled region/heading on the root, matching reka, to avoid an unusual role=application-on-root + role=grid-on-table pairing.
Where robonen is already better:
- ✅ Richer keyboard support: CalendarCellTrigger.handleKeyDown handles Home, End, PageUp, PageDown, and Shift+PageUp/PageDown (year jump) in addition to arrows/Enter/Space. Reka's CalendarCellTrigger.handleArrowKey only binds @keydown.up.down.left.right.space.enter and has NO Home/End/PageUp/PageDown handling at all — this is a genuine a11y/keyboard advantage for robonen.
- ✅ Home/End are correctly week-relative using weekStartsOn offset math (CalendarCellTrigger.vue lines 122-135), giving WAI-ARIA grid-pattern conformance that reka lacks entirely.
- ✅ Exposes nextYear()/prevYear() in the root context (CalendarRoot.vue, context.ts) enabling year-stepping UI; reka's context exposes only nextPage/prevPage.
- ✅ CalendarHeadCell accepts a
dayprop and emits a localized long weekdayaria-label(CalendarHeadCell.vue), improving SR output for the column header; reka's CalendarHeadCell is a bare passthrough with no aria-label. - ✅ No heavy @internationalized/date runtime dependency — robonen uses native Date + Intl only (date-utils.ts), giving a smaller bundle and zero extra deps versus reka's hard dependency on @internationalized/date.
- ✅ initialFocus selector also targets ':not([data-outside-view]):not([data-disabled])' as a final fallback (CalendarRoot.vue lines 255-259), so it won't focus a disabled/outside cell; reka's handleCalendarInitialFocus falls back to a '[data-reka-calendar-day]' selector that does not even exist on the trigger (it sets data-reka-calendar-cell-trigger), so reka's third fallback is effectively dead.
- ✅ Per-prop reactive refs via toRef(() => ...) (CalendarRoot.vue lines 136-146) keep destructured props reactive in context without toRefs of the whole props object.
dialog — robonen-better-with-gaps
Parts reka has that robonen lacks: DialogOverlayImpl.vue (overlay split where body-scroll-lock lives), Dialog/utils.ts useWarning (dev a11y warnings) Parts robonen has that reka lacks: DialogPortal forceMount + Presence wrapper (reka portal is a bare teleport)
robonen's Dialog mirrors reka's architecture (Root/Trigger/Portal/Overlay/Content+Impl+Modal/NonModal/Title/Description/Close) and is genuinely better in several areas: it sets aria-modal on modal content (reka does not), supports a role prop for alertdialog reuse, locks body scroll in ContentModal independent of whether an Overlay is rendered (reka's lock lives in OverlayImpl and is skipped without an overlay), wraps the Portal in Presence with forceMount, and special-cases closed-popover siblings in useHideOthers. However robonen is missing real behaviors reka ships: the modal right-click-outside guard, the defensive focus-outside-while-trapped guard, the non-modal conditional focus-return + trigger-containment + Safari focusin workaround (robonen's NonModal forwards events but adds zero outside-interaction logic), programmatic-open trigger preservation (triggerElement is only ever set by DialogTrigger), and dev-time a11y warnings for a missing Title/Description. Smaller gaps: no close helper in the Root slot, no ref forwarding on the public DialogContent, unconditional trigger aria-controls, and no scrollBody/nonce config. Fixing the four focus/edge-case items (right-click guard, non-modal focus handling, focus-outside guard, programmatic trigger capture) plus the dev warnings would make robonen strictly better.
Major gaps:
- 🔶 (keyboard) Non-modal close does not conditionally suppress focus-return: reka DialogContentNonModal tracks hasInteractedOutsideRef and only refocuses the trigger on closeAutoFocus when the user did NOT interact outside (DialogContentNonModal.vue lines 24-35). robonen's DialogContentNonModal.vue has NO closeAutoFocus/interactOutside handlers — it relies on FocusScope's default unmount which always refocuses previouslyFocusedElement, stealing focus back to the trigger even when the user clicked elsewhere in a non-modal flow.
- 🔶 (edge-cases) Non-modal dialog does not prevent dismissing when the click target is the trigger itself, so clicking the trigger while open dismisses-then-toggles (close immediately followed by re-open). reka guards this in interactOutside via targetIsTrigger = rootContext.triggerElement.value?.contains(target) -> event.preventDefault().
- 🔶 (edge-cases) Modal content does not ignore right-click outside. reka DialogContentModal.vue pointerDownOutside detects originalEvent.button===2 or ctrl+left and calls event.preventDefault() so right-clicking the overlay does not close the dialog. robonen DialogContentModal forwards pointer-down-outside straight to dismiss with no right-click guard, so a context-menu right-click on the overlay closes the modal.
- 🔶 (keyboard) Programmatic-open trigger preservation is missing. reka DialogContentImpl.vue onMounted captures getActiveElement() into rootContext.triggerElement when the dialog is opened without a DialogTrigger (lines 56-58), so focus returns to whatever was focused and the NonModal trigger-containment guard works. robonen only ever sets ctx.triggerElement from DialogTrigger's currentElement (DialogTrigger.vue lines 17-19); a dialog opened via v-model with no DialogTrigger has triggerElement=undefined, so the (planned) conditional refocus and trigger-dismiss guard cannot work.
- 🔶 (accessibility) No dev-time accessibility warnings. reka ships useWarning (Dialog/utils.ts) wired in DialogContentImpl under NODE_ENV!=='production' to console.warn when a DialogTitle is missing (label) or when aria-describedby points to a nonexistent description. robonen has no such guard, so a content with no Title silently ships an unlabeled dialog.
Minor gaps (6):
- ▫️ (edge-cases) Modal content does not defensively prevent focusOutside-driven dismiss while trapped. reka DialogContentModal.vue focusOutside handler always event.preventDefault() because a focusout can still fire under a focus trap. robonen DialogContentModal forwards focus-outside to emit only, and robonen's DismissableLayer focusin listener can emit('dismiss') on a focusOutside; the focus trap usually re-focuses inward, but reka's explicit guard removes the race entirely.
- ▫️ (api-surface) Root default slot does not expose a close helper. reka DialogRoot.vue slot provides { open, close } (lines 54-61, 92-96, close = () => open=false) for ergonomic inline close without DialogClose. robonen DialogRoot.vue template only exposes :open (line 65).
- ▫️ (api-surface) Public DialogContent does not forward a ref/expose the content element. reka DialogContent.vue uses useForwardExpose and binds :ref="forwardRef" onto Content(Modal|NonModal) so a consumer template ref resolves to the content element. robonen DialogContent.vue has no useForwardExpose/forwardRef; a ref on resolves to the Presence wrapper, not the dialog element (only the internal Modal/NonModal forward to context).
- ▫️ (features) No scrollBody config integration. reka useBodyScrollLock honors ConfigProvider scrollBody (padding/margin compensation for scrollbar width). robonen's useBodyScrollLock call in DialogContentModal takes no config and there is no scrollBody plumbing, so layout shift from scrollbar removal is not compensated.
- ▫️ (ssr) No nonce propagation. reka ConfigProvider exposes nonce (ConfigProvider.vue lines 34-66) consumable by injected style layers. robonen config-provider has no nonce, so any injected <style> (e.g. for pointer-events blocking) cannot carry a CSP nonce.
- ▫️ (accessibility) Trigger aria-controls is always emitted even when closed. reka DialogTrigger.vue sets :aria-controls="rootContext.open.value ? rootContext.contentId : undefined" so it only references content that exists in the DOM. robonen DialogTrigger.vue line 30 always sets aria-controls to contentId even when content is unmounted, referencing a non-existent id (minor a11y validation nit).
Recommendations to make robonen strictly better:
- In DialogContentModal forward pointer-down-outside through a handler that calls event.preventDefault() for right-clicks (originalEvent.button===2 || (button===0 && ctrlKey)) before re-emitting, mirroring reka DialogContentModal.vue lines 32-43, so right-clicking the overlay no longer closes the modal.
- In DialogContentModal add a focus-outside handler that calls event.preventDefault() while trapped (reka DialogContentModal.vue lines 44-50) to remove the focusin->dismiss race in DismissableLayer.vue.
- In DialogContentNonModal add hasInteractedOutsideRef/hasPointerDownOutsideRef state and a close-auto-focus handler that only refocuses ctx.triggerElement when no outside interaction occurred, plus an interact-outside handler that preventDefaults when the target is inside ctx.triggerElement and handles the Safari focusin-after-pointerdown case (reka DialogContentNonModal.vue lines 24-58).
- In DialogContentImpl onMounted, when ctx.triggerElement is empty, capture getActiveElement() (if not document.body) so programmatic v-model opens with no DialogTrigger still restore focus correctly and feed the NonModal trigger-containment guard (reka DialogContentImpl.vue lines 56-58).
- Add an explicit close-auto-focus default on the modal path that preventDefaults and focuses ctx.triggerElement, instead of relying solely on FocusScope's previouslyFocusedElement restore (reka DialogContentModal.vue lines 24-31).
- Add a dev-only useWarning (gated on import.meta.env.DEV / NODE_ENV) wired into DialogContentImpl that warns when no DialogTitle id is registered (titleId undefined) and when descriptionId is set but the element is missing — port reka Dialog/utils.ts.
- Expose a close helper from the DialogRoot default slot () for parity with reka DialogRoot.vue.
- Add useForwardExpose to the public DialogContent and bind :ref="forwardRef" to the rendered Modal/NonModal so a consumer template ref on resolves to the content element (reka DialogContent.vue).
- Make DialogTrigger aria-controls conditional on ctx.open.value so it does not reference an unmounted content id (reka DialogTrigger.vue line 32).
- Add scrollBody (and nonce) to config-provider and pass scrollBody options into the modal useBodyScrollLock call to compensate scrollbar-width layout shift (reka shared/useBodyScrollLock.ts).
Where robonen is already better:
- ✅ Sets aria-modal="true" on modal content (DialogContentImpl.vue line 55, gated on disableOutsidePointerEvents). reka-ui's Dialog never sets aria-modal at all — robonen is more correct for screen readers in modal mode.
- ✅ Supports role prop on content: role?: 'dialog' | 'alertdialog' (DialogContentImpl.vue lines 11-12, plumbed through Content/Modal/NonModal). reka hardcodes role="dialog" in DialogContentImpl and pushes alertdialog into a separate package, so robonen's single Content is more flexible.
- ✅ Body-scroll lock lives in DialogContentModal (lines 22-34) keyed off ctx.open, with explicit release on close and onBeforeUnmount cleanup. reka puts useBodyScrollLock in DialogOverlayImpl, so a modal dialog rendered WITHOUT a DialogOverlay does not lock scroll in reka; robonen always locks for modal regardless of overlay presence.
- ✅ useHideOthers skips elements inside a closed native [popover] via isInClosedPopover with a Safari-safe try/catch (useHideOthers.ts lines 8-22) — a real edge case reka's plain useHideOthers does not special-case.
- ✅ DialogPortal exposes forceMount and wraps the teleport in Presence (DialogPortal.vue lines 29-34) and a present slot prop, giving portal-level exit-animation control. reka's DialogPortal is a bare TeleportPrimitive pass-through with no Presence/forceMount.
- ✅ Performance: modal flag forwarded via toRef getter (GetterRefImpl) instead of computed (DialogRoot.vue lines 45-47), and stable useId-based ids created eagerly rather than reka's mutate-context-string-on-mount pattern (rootContext.contentId ||= ... in DialogTrigger/Impl).
combobox — robonen-better-with-gaps
Parts reka has that robonen lacks: ComboboxVirtualizer (virtualized rendering via @tanstack/vue-virtual + ListboxVirtualizer)
robonen's combobox covers the full part set (Root, Anchor, Trigger, Input, Cancel, Content, ContentImpl, Viewport, Empty, Group, Label, Item, ItemIndicator, Separator, Arrow, Portal) and is in several respects cleaner than reka: a self-contained state machine instead of reka's Listbox/Collection/RovingFocus coupling, a pluggable filterFunction, a shipped+tested 'N results available' live region, fresh (non-stale) filterState, and modern shallowRef/triggerRef reactivity. Keyboard coverage on the input (Arrow Up/Down, Home, End, Enter, Escape, Tab, plus OPEN_KEYS to open) and the activedescendant ARIA pattern are at parity. However reka is currently better on real, verifiable axes: it has a ComboboxVirtualizer part (robonen has none), a cancelable Item select event, Root-level openOnFocus/openOnClick/resetModelValueOnClear/highlightOnHover and a highlight emit, Content-level forceMount/hideWhenEmpty/bodyLock/data-empty/interactOutside, useFocusGuards on content, a nonce-able scrollbar-hiding Viewport, accent-insensitive default filtering, IME composition guards, v-if (unmount) filtering vs robonen's v-show, an empty-value guard on Item, and more graceful group labelling. Closing the listed gaps (especially virtualization, the Item select event, focus guards, and the Root open/highlight props) would make robonen strictly better. Verdict: robonen-better-with-gaps.
Major gaps:
- 🔶 (features) No virtualization part. reka ships ComboboxVirtualizer.vue (wrapping ListboxVirtualizer via @tanstack/vue-virtual) and threads
isVirtualthrough ComboboxRoot/ComboboxItem/ComboboxInput/ComboboxViewport to support large/async option lists. robonen has no ComboboxVirtualizer and no isVirtual concept; rendering thousands of items will mount them all. - 🔶 (accessibility) No focus guards around the content. reka's ComboboxContentImpl calls useFocusGuards() to insert focus sentinels so Tab/Shift-Tab out of a portalled listbox behaves correctly. robonen's ComboboxContentImpl never calls focus guards (the repo only has focus-guard logic in menu's modal content).
- 🔶 (api-surface) No
openOnFocus/openOnClick/resetModelValueOnClear/highlightOnHoveron Root. reka centralizes these on ComboboxRoot (and threads them through context). robonen only puts openOnFocus/openOnClick on ComboboxInput (per-input) and has no resetModelValueOnClear or highlightOnHover at all — its Cancel never clears the model value, and hover-highlight is always-on via pointermove with no opt-out. - 🔶 (api-surface) Item lacks a cancelable
selectevent. reka's ComboboxItem forwards aselectSelectEvent that consumers can preventDefault to block selection/auto-close. robonen's ComboboxItem has no emits at all — selection is hardwired in handleClick with no interception point.
Minor gaps (14):
- ▫️ (features) No body scroll lock option on content. reka's ComboboxContentImpl accepts
bodyLockand calls useBodyScrollLock(props.bodyLock). robonen's ComboboxContentImpl has no bodyLock prop and never locks scroll, even though the repo already has a body-scroll-lock util (used in dialog/popover/select). - ▫️ (api-surface) No
hideWhenEmptycontent prop. reka's ComboboxContentImpl exposeshideWhenEmpty(default false) and sets display:none when no items match, plus adata-emptyattribute on the content. robonen has neither the prop nor a data-empty marker on content. - ▫️ (features) No
forceMounton Content. reka's ComboboxContent hasforceMountto keep content mounted for external animation libraries (:present="forceMount || open"). robonen's ComboboxContent only does:present="rootCtx.open.value"with no forceMount. - ▫️ (api-surface) Missing
interactOutsideemit on Content. reka forwards DismissableLayer's full emit set includinginteractOutside(ComboboxContentImplEmits = DismissableLayerEmits). robonen's ComboboxContentImplEmits only declares closeAutoFocus/escapeKeyDown/pointerDownOutside/focusOutside — no interactOutside. - ▫️ (features) No
disableOutsidePointerEventsplumbing parity / Content does not pass it cleanly. reka passesdisable-outside-pointer-eventsfrom DismissableLayerProps. robonen declaresdisableOutsidePointerEventson ContentImplProps but defaults to false and there is no documented way to fully block outside interaction with pointer-events:none on body as reka's DismissableLayer supports. - ▫️ (ssr) No
noncesupport / no scrollbar-hiding style in Viewport. reka's ComboboxViewport acceptsnonce(useNonce) and injects a<style nonce>that hides scrollbars cross-browser. robonen's ComboboxViewport has no nonce prop and only inline overflow style — under strict CSP a consumer cannot supply a nonce, and scrollbars are not hidden. - ▫️ (api-surface) No
highlightemit / programmatic highlight event. reka's ComboboxRoot emitshighlight({ ref, value }) whenever the active item changes, enabling consumers to react to navigation. robonen has no highlight emit (RootEmits is only update:modelValue/update:open). - ▫️ (performance) Items are filtered with v-show, not unmounted. reka's ComboboxItem uses
v-if="isRender"so non-matching items are removed from the DOM (lighter DOM, better for big lists). robonen's ComboboxItem usesv-show="isVisible", keeping every item element in the DOM permanently. - ▫️ (edge-cases) Empty-string item value is not guarded. reka's ComboboxItem throws if
value === ''(to keep '' reserved for clearing). robonen's ComboboxItem performs no such validation, so an empty-string value can silently collide with the cleared state. - ▫️ (rtl-i18n) No accent/diacritic-insensitive default filtering. reka uses Intl-based useFilter({ sensitivity: 'base' }) so 'cafe' matches 'café'. robonen's defaultFilter is a plain lowercase substring
includes, so accents/locale folding are not handled. - ▫️ (accessibility) Group label relationship is fragile. reka separates group
idandlabelId: the group'saria-labelledbyis empty until a ComboboxLabel mounts and sets labelId. robonen's ComboboxGroup always setsaria-labelledby={id}and ComboboxLabel sets its elementid={groupCtx.id}— if no Label is rendered, aria-labelledby points to a non-existent id (broken SR relationship). - ▫️ (features) No multiple-selection display in input. robonen's ComboboxInput.displayString explicitly returns '' for arrays, so multi-select has no built-in input display even when a custom displayValue is provided for arrays. reka pairs Combobox with TagsInput stories for multi display.
- ▫️ (forms) Form value serialization differs and may not round-trip. robonen submits the hidden input as
JSON.stringify(value)for arrays /String(value)otherwise via a raw . reka uses VisuallyHiddenInput (in ListboxRoot) which serializes each value as a proper form control entry. robonen's JSON-encoded single field is less conventional for server form parsing and won't produce repeated name entries for multiple. - ▫️ (rtl-i18n) No composition (IME) handling on the navigation path. reka's ListboxRoot wires onCompositionStart/onCompositionEnd and guards Enter while composing (isComposing). robonen's ComboboxInput handleKeyDown does not check compositionend/isComposing, so pressing Enter to confirm an IME candidate can prematurely select a combobox item.
Recommendations to make robonen strictly better:
- Add a ComboboxVirtualizer part (and
isVirtualin context) wrapping the existing robonen listbox virtualizer or @tanstack/vue-virtual; thread isVirtual through Root/Item/Input/Viewport so filterState skips per-item work and items aren't all mounted. Mirror reka Combobox/ComboboxVirtualizer.vue. - Switch ComboboxItem from
v-show="isVisible"tov-if(with a first-render registration guard like reka'sfilteredCurrentItem === undefined ? true) so filtered-out items leave the DOM. - Add a cancelable
selectemit to ComboboxItem (CustomEvent with value+originalEvent); only auto-close/commit when not defaultPrevented, matching reka ComboboxItem.vue. This unlocks create-on-enter and custom selection flows. - Add Root-level props:
openOnFocus,openOnClick,resetModelValueOnClear, andhighlightOnHover(opt-out of pointermove highlight). Implement resetModelValueOnClear in ComboboxCancel (set model to [] or undefined). Emit ahighlightevent from Root when selectedValueId changes. - Add
forceMountto ComboboxContent andhideWhenEmpty+data-emptyto ComboboxContentImpl; set display:none when empty and hideWhenEmpty is true. - Add
useFocusGuards()and an optionalbodyLockprop (reusing the existing body-scroll-lock util) to ComboboxContentImpl for correct Tab-out and scroll-locking when portalled. - Add a
nonceprop to ComboboxViewport (via a config-provider useNonce) and inject the cross-browser scrollbar-hiding<style :nonce>like reka ComboboxViewport.vue; also expose nonce on Root/ConfigProvider for CSP. - Add
interactOutsideto ComboboxContentImplEmits and forward it from DismissableLayer for full parity with DismissableLayerEmits. - Replace the plain lowercase substring defaultFilter with an Intl.Collator/useFilter-based accent-insensitive matcher (sensitivity: 'base') so diacritics match; keep filterFunction as the override.
- Fix group labelling: keep an empty
labelIdin ComboboxGroupContext that ComboboxLabel sets on mount, and only emitaria-labelledbyon the group when a label is present (avoid dangling ids when no Label is rendered). - Throw (or warn) in ComboboxItem when
value === ''to reserve empty-string for the cleared state, matching reka. - Add IME composition handling to ComboboxInput (compositionstart/compositionend + isComposing guard) so Enter does not select an item while composing.
- Reconsider hidden-input serialization: emit repeated
nameentries (or use a VisuallyHiddenInput-style control) for multiple values instead of JSON.stringify so standard form backends parse selections. - Verify highlightFirstItem re-runs after the search is cleared so the first item is re-highlighted on the clear-to-all transition (robonen has the oldState.count===0 watcher in ComboboxInput — ensure it also covers clearing back to all items).
Where robonen is already better:
- ✅ Self-contained architecture: robonen's combobox owns its full state machine (registration, filterState, highlight, keyboard) in ComboboxRoot + ComboboxInput rather than delegating to a separate Listbox/Collection/RovingFocus stack. Fewer indirection layers and no cross-primitive coupling (reka's combobox is tightly bound to ListboxRoot/ListboxFilter/ListboxContent/useCollection, so any Listbox change risks the combobox).
- ✅ Pluggable filtering: ComboboxRoot exposes
filterFunction(ComboboxFilterFunction) so consumers can fully replace the matching algorithm and reorder results. reka hardcodesuseFilter({ sensitivity: 'base' }).containsin ComboboxRoot and only offersignoreFilter; there is no per-instance custom filter hook. - ✅ Richer filterState typing/semantics: robonen's
ComboboxFilterStateusesitems: Set<string>of visible ids plusgroups: Set<string>, computed freshly each time. reka usesitems: Map<string,number>(score 0/1) and, on the empty-search branch, returns the previous oldValue.items/oldValue.groups (ComboboxRoot.vue filterState computed), which can leave a stale items map after clearing the search. - ✅ Live-region announcement is shipped and tested: ComboboxContentImpl renders a VisuallyHidden
role=status aria-live=politenode announcing 'N results available.' with dedicated tests (Combobox.live-region.test.ts). reka's Combobox has no built-in results-count live region. - ✅ ComboboxEmpty
alwaysprop lets consumers force-render the empty slot even when items exist; reka's ComboboxEmpty only renders when count===0 with no override. - ✅ More precise default
by/value comparison utilities (compare/valueComparator in utils.ts) are local and tree-shakeable rather than importing the whole Listbox comparison stack. - ✅ displayValue is wired through context to the input with explicit guards for undefined/array/object values (ComboboxInput.displayString), keeping single-select display deterministic.
- ✅ Modern reactivity: shallowRef + triggerRef for the item/group registries and toRef-based provided refs in ComboboxRoot reduce deep-reactive overhead compared to reka's plain
ref(new Map())registries mutated in place.
context-menu — robonen-better-with-gaps
Both libraries ship the identical set of 16 ContextMenu parts, all thin wrappers over a shared Menu primitive, so there is no missing sub-component on either side. robonen's wrappers are clean and slightly leaner in a few spots (onScopeDispose timer cleanup, shared context factory, typed virtual rect, reactive modal without an extra watcher). However reka's ContextMenu layer carries several behaviors robonen has dropped. The most impactful are the right-click-on-trigger interact-outside guard (requires tracking triggerElement in root context, which robonen does not store), the nested-trigger / defaultPrevented handling in handleContextMenu (robonen opens unconditionally, so nested context menus and consumer suppression break), and the @pointermove long-press cancellation plus pen-input support. Smaller gaps: configurable pressOpenDelay, sideOffset:2, non-modal closeAutoFocus focus preservation, WebkitTouchCallout/pointerEvents trigger styles + $attrs forwarding, ContextMenuSub defaultOpen/uncontrolled support, locking down ContextMenuContent props via Omit + explicit collision defaults, ContextMenuArrow defaults, and context-menu/trigger-size CSS custom properties (robonen never even aliases the popper anchor size). None of these are present in robonen today, so robonen is currently better-organized but not strictly better; closing the listed items (especially the four major edge-case/a11y gaps) would make it strictly superior.
Major gaps:
- 🔶 (edge-cases) robonen ContextMenuContent has no interact-outside guard for right-clicking the trigger while the menu is open. reka's ContextMenuContent intercepts @interact-outside and, when originalEvent.button === 2 && event.target === triggerElement, calls event.preventDefault() so the dismiss layer does not close-then-reopen (prevents flicker / lost focus when re-triggering via right click). robonen omits this entirely.
- 🔶 (api-surface) robonen ContextMenuRoot does not track the trigger element. reka stores triggerElement: Ref in ContextMenuRootContext and sets it onMounted in ContextMenuTrigger; this is required for the right-click interact-outside guard above and is generally useful for consumers. robonen's ContextMenuRootContext has only { open, onOpenChange, modal } — no triggerElement, no dir, no pressOpenDelay.
- 🔶 (edge-cases) robonen ContextMenuTrigger fires the context menu unconditionally on contextmenu (only gated by
disabled). It does not await nextTick nor check event.defaultPrevented, so a nested ContextMenuTrigger cannot suppress the outer one and right-click bubbling opens BOTH menus. reka awaits nextTick and bails if event.defaultPrevented, enabling correct nested context-menu behavior. - 🔶 (edge-cases) robonen ContextMenuTrigger never cancels the touch long-press on pointermove. reka registers @pointermove -> handlePointerEvent which clearLongPress() so a scroll/drag gesture cancels the pending open. In robonen a touch that starts a scroll still opens the menu after 700ms.
Minor gaps (10):
- ▫️ (edge-cases) robonen long-press opens only for pointerType === 'touch', excluding pen input. reka uses isTouchOrPen (pointerType !== 'mouse'), so pen long-press also opens the context menu. robonen pen users get no long-press affordance.
- ▫️ (api-surface) pressOpenDelay is not configurable in robonen. reka exposes pressOpenDelay?: number (default 700) on ContextMenuRoot, provides it via context, and uses rootContext.pressOpenDelay.value for the long-press timeout. robonen hard-codes LONG_PRESS_DELAY = 700 in the trigger with no prop.
- ▫️ (features) robonen ContextMenuContent does not set sideOffset, so it falls through to MenuContentImpl's default of 0. reka explicitly sets :side-offset="2" giving a small gap between the cursor and the menu, the documented context-menu default.
- ▫️ (accessibility) robonen ContextMenuContent does not preserve focus when the menu is dismissed by an outside interaction in non-modal mode. reka tracks hasInteractedOutside and, in @close-auto-focus, calls event.preventDefault() so focus is not yanked back to the trigger after an outside interaction. robonen just re-emits closeAutoFocus with no such handling.
- ▫️ (edge-cases) robonen ContextMenuTrigger lacks the defensive inline styles reka applies: WebkitTouchCallout: 'none' (suppresses the iOS native callout on long-press) and pointerEvents: 'auto'. Without WebkitTouchCallout iOS Safari may show the native callout competing with the custom long-press menu.
- ▫️ (code-quality) robonen ContextMenuTrigger does not disable inheritAttrs nor forward $attrs, and the Primitive sits INSIDE MenuAnchor (which is as-child with reference=virtualEl). reka sets inheritAttrs:false, renders MenuAnchor as a separate template node (as="template", virtual reference) and the Primitive separately with v-bind="$attrs", giving cleaner attribute fallthrough and avoiding the anchor wrapping the trigger DOM.
- ▫️ (forms) robonen ContextMenuSub has no defaultOpen / uncontrolled support. reka ContextMenuSub adds defaultOpen?: boolean and uses useVModel with passive mode so the submenu can be used uncontrolled. robonen ContextMenuSub merely extends MenuSubProps ({ open?: boolean }) and forwards open, so submenu open state is controlled-only.
- ▫️ (features) robonen does not expose context-menu-specific CSS custom properties, and the underlying Menu layer never aliases the anchor/trigger size. reka's ContextMenuContent and ContextMenuSubContent emit --reka-context-menu-content-transform-origin/-available-width/-available-height plus --reka-context-menu-trigger-width and --reka-context-menu-trigger-height. robonen MenuContentImpl only aliases transform-origin/available-width/available-height (as --primitives-menu-content-*) and never exposes the popper anchor-width/height, so consumers cannot size the menu to the trigger.
- ▫️ (api-surface) robonen ContextMenuContent omits the collision/positioning default overrides reka declares (alignOffset:0, avoidCollisions:true, collisionBoundary:[], collisionPadding:0, sticky:'partial', hideWhenDetached:false) and does not Omit those props from the public type to lock side/align. Defaults fall through to Popper, which may differ (e.g. sticky default) and side/sideOffset/align remain settable by consumers (reka Omits side|sideOffset|align|arrowPadding|updatePositionStrategy from ContextMenuContentProps to prevent overriding the cursor-anchored positioning).
- ▫️ (api-surface) robonen ContextMenuArrow does not set the documented arrow defaults. reka ContextMenuArrow withDefaults width:10, height:5, as:'svg'. robonen ContextMenuArrow forwards props with no defaults, so a bare renders with undefined dimensions/as unless MenuArrow supplies them.
Recommendations to make robonen strictly better:
- Add triggerElement: Ref<HTMLElement | undefined> to ContextMenuRootContext, set it onMounted in ContextMenuTrigger via useForwardExpose().currentElement, then implement the @interact-outside guard in ContextMenuContent: if (originalEvent.button === 2 && event.target === ctx.triggerElement.value) event.preventDefault(); also track hasInteractedOutside and preventDefault on @close-auto-focus for non-modal mode.
- In ContextMenuTrigger.handleContextMenu, await nextTick() and bail if event.defaultPrevented before opening, so nested ContextMenu triggers and consumer preventDefault work correctly (port reka's nested-trigger semantics).
- Add an @pointermove handler that clears the long-press timer (and gate by isTouchOrPen), so scrolling/dragging cancels the pending touch open.
- Broaden the long-press gate from pointerType === 'touch' to pointerType !== 'mouse' (add a small isTouchOrPen util like reka) so pen long-press also opens the menu.
- Add pressOpenDelay?: number (default 700) to ContextMenuRootProps, provide it through ContextMenuRootContext, and consume it in the trigger instead of the hard-coded LONG_PRESS_DELAY.
- Set :side-offset="2" on ContextMenuContent's MenuContent (or default it) to match the standard cursor gap.
- Add the defensive trigger styles WebkitTouchCallout: 'none' and pointerEvents: 'auto', set defineOptions({ inheritAttrs: false }) and forward $attrs onto the Primitive, and render MenuAnchor as a template with the virtual reference rather than wrapping the trigger DOM.
- Tighten ContextMenuContentProps to Omit<MenuContentProps,'side'|'sideOffset'|'align'|'arrowPadding'|'updatePositionStrategy'> and add withDefaults for alignOffset/avoidCollisions/collisionBoundary/collisionPadding/sticky/hideWhenDetached so cursor-anchored positioning cannot be accidentally overridden and defaults are explicit.
- Add defaultOpen?: boolean to ContextMenuSub (and uncontrolled support) so submenus can be used without controlling open state.
- Expose context-menu CSS custom properties on ContextMenuContent/ContextMenuSubContent (transform-origin/available-width/available-height) and, importantly, alias the popper anchor size as a trigger-width/-height var (add --popper-anchor-height + a menu-level alias) so consumers can size the menu to the trigger.
- Add withDefaults to ContextMenuArrow (width:10, height:5, as:'svg').
- Store dir in ContextMenuRootContext (via useDirection) for consistency with reka and so consumers/sub-parts can read the resolved direction.
Where robonen is already better:
- ✅ robonen ContextMenuTrigger uses onScopeDispose(clearLongPress) to guarantee the long-press timer is cleared when the component unmounts; reka's ContextMenuTrigger never clears longPressTimer on unmount, so a pending touch long-press timer can fire after teardown (robonen avoids this leak).
- ✅ robonen's context.ts uses the shared useContextFactory helper giving a single, consistently-named injector (useContextMenuRootContext) reused across the repo, vs reka's per-file createContext duplication.
- ✅ robonen ContextMenuTrigger's virtualEl getBoundingClientRect returns an explicit, fully-typed rect object with toJSON; reka spreads ...point.value over the literal and casts
as DOMRect, which is slightly looser typing (robonen is marginally cleaner/type-safe here). - ✅ robonen ContextMenuRoot keeps the open state minimal and provides modal as toRef(() => modal), so the modal flag stays reactive without a watcher; functionally equivalent to reka but with no extra watch(open) indirection (reka uses an explicit watch(open) to re-emit update:open).
slider — robonen-better-with-gaps
Parts reka has that robonen lacks: SliderImpl.vue (shared pointer/keyboard impl layer), SliderHorizontal.vue (orientation-specific positioning), SliderVertical.vue (orientation-specific positioning), SliderThumbImpl.vue (thumb impl with Collection + size), Collection integration (useCollection/CollectionSlot/CollectionItem for thumb indexing), VisuallyHiddenInput-based form serialization, SliderOrientationContext (startEdge/endEdge/direction/size) Parts robonen has that reka lacks: utils.ts hasMinStepsBetweenSortedValues (order-preserving min-gap check), SliderTrack owns its own pointerdown drag-start logic directly (no separate Impl layer), [min,max] bounds-change re-clamp watcher in SliderRoot
robonen's slider is a leaner, more performance-conscious implementation (GetterRefImpl context, cached step decimals, monomorphic style objects, single-pass utils, no Collection dependency, packed-array bounds clamping) and covers the core API: controlled/uncontrolled via modelValue+defaultValue, valueCommit, min/max/step/minStepsBetweenThumbs, horizontal/vertical, RTL, inverted, disabled, Arrow/Page/Home/End keys, range with neighbour clamping, hidden form inputs, and proper ARIA slider role/valuemin/valuemax/valuenow/orientation. However it is not yet strictly better than reka: it lacks Shift+Arrow large steps, automatic per-thumb accessible labels (Minimum/Maximum/Value n of m), ConfigProvider-based global direction inheritance, thumbAlignment/in-bounds thumb offset, the robust VisuallyHiddenInput+useFormControl form path, null modelValue support, and inheritAttrs routing. Closing the listed gaps (especially the three major ones: Shift+Arrow, default thumb labels, and global dir inheritance) would make robonen strictly better.
Major gaps:
- 🔶 (keyboard) No Shift+Arrow large-step support. reka treats Shift+ArrowLeft/Right/Up/Down as a 10x step (SliderRoot step-key-down handler: isSkipKey = isPageKey || (event.shiftKey && ARROW_KEYS.includes(event.key)); multiplier = isSkipKey ? 10 : 1). robonen's SliderThumb.onKeyDown only multiplies for PageUp/PageDown and ignores event.shiftKey entirely, so Shift+Arrow does a normal single step.
- 🔶 (accessibility) No automatic accessible thumb labels. reka derives an aria-label per thumb when no explicit label is given: getLabel(index,total) returns 'Minimum'/'Maximum' for 2 thumbs and 'Value n of m' for >2 (SliderThumbImpl: :aria-label="$attrs['aria-label'] || label"). robonen only forwards a manually-supplied aria-label prop and provides no default, so multi-thumb sliders are unlabeled by default for screen readers.
- 🔶 (rtl-i18n) Does not inherit global direction from ConfigProvider. reka's SliderRoot calls useDirection(propDir) so dir falls back to the ConfigProvider context dir then 'ltr'. robonen's SliderRoot reads dir directly from the prop (default 'ltr') and never consults useConfig()/the ConfigProvider context, even though robonen ships a config-provider with a reactive global dir. RTL apps must set dir on every slider.
- 🔶 (features) No thumbAlignment ('contain' vs 'overflow') support. reka offsets the thumb so it stays within the track bounds at the edges via getThumbInBoundsOffset + useSize and exposes a --reka-slider-thumb-transform CSS var; robonen positions thumbs purely by percentage with no edge inset, so at 0%/100% half the thumb overflows the track with no opt-out/opt-in.
Minor gaps (5):
- ▫️ (forms) Form integration is weaker. reka uses VisuallyHiddenInput (a properly visually-hidden, accessibility-safe input) gated by useFormControl so the hidden input only renders when actually inside a , supports disabled, passes step, and serializes arrays/objects robustly. robonen renders raw unconditionally whenever name is set (even with no surrounding form), does not pass disabled to the input, omits step, and hand-rolls the name[] suffix.
- ▫️ (api-surface) No data-slider-impl / consistent data hooks and no aria-disabled on thumb is fine but reka also forwards $attrs through inheritAttrs:false on Root/ThumbImpl giving precise attribute placement. robonen Root uses default attr inheritance (no inheritAttrs:false), so consumer-passed attributes/listeners land on the Root span rather than being routed, and Root does not v-bind $attrs to an inner element.
- ▫️ (edge-cases) Disabled thumbs remain in the tab order. reka sets :tabindex="disabled ? undefined : 0" (no tabindex when disabled => not tabbable). robonen sets :tabindex="disabled ? -1 : 0". tabindex=-1 still makes the element programmatically focusable and is arguably acceptable, but reka's removal from tab order plus its overall disabled semantics differ; minor divergence worth aligning.
- ▫️ (api-surface) Controlled/uncontrolled mode is hand-rolled and slightly lossy. reka uses useVModel with passive detection so null modelValue is supported (modelValue?: number[] | null) and defaultValue defaults to [0]. robonen's modelValue watcher early-returns on undefined and does manual sync; it does not support null, and a value-commit on programmatic min/max clamp path is inconsistent (commit only fires on pointer/keyboard, not on the bounds-clamp watcher). Edge: passing modelValue=null is a type/behavior gap vs reka.
- ▫️ (accessibility) No aria-valuetext support and no slot/prop to humanize the value for screen readers. Neither library wires aria-valuetext, but reka's getLabel at least disambiguates multiple thumbs; robonen has neither default labels nor valuetext, so the gap is purely additive for robonen to close.
Recommendations to make robonen strictly better:
- Add Shift+Arrow large-step handling in SliderThumb.onKeyDown: when event.shiftKey is set on an Arrow key, use largeStep() (the 10x multiplier) just like PageUp/PageDown, matching reka's behavior.
- Provide default accessible thumb labels: port reka's getLabel(index, total) so two-thumb sliders default to 'Minimum'/'Maximum' and >2 to 'Value n of m', used as a fallback when no aria-label prop is supplied. Expose aria-label fallback on the SliderThumb Primitive.
- Make SliderRoot inherit global direction: resolve dir via useConfig()/a useDirection-style computed (prop dir || ConfigProvider dir || 'ltr') instead of reading the prop directly, so the existing config-provider dir actually propagates to the slider.
- Add a thumbAlignment prop ('contain' | 'overflow', default 'contain') and an in-bounds offset (port getThumbInBoundsOffset + measure thumb size) so thumbs do not overflow the track at the extremes; expose a CSS var for the transform.
- Harden form integration: render via the existing VisuallyHidden primitive instead of raw , gate rendering on an inside-a-form check (useFormControl-style), and forward disabled and step to the input.
- Support null modelValue and consider useVModel-style controlled/uncontrolled handling; align defaultValue semantics and make valueCommit also fire (or document why not) on programmatic bounds clamping.
- Set inheritAttrs:false on SliderRoot (and route $attrs explicitly) so consumer attributes/listeners land predictably, matching reka's attribute routing.
- Add optional aria-valuetext support (e.g. a formatter prop or slot) so screen readers announce human-friendly values; also consider tabindex=undefined when disabled to match reka and remove disabled thumbs from the tab order.
- Add focus-on-keyboard-change parity: reka focuses the active thumb whenever the value changes via keyboard (thumbElements[index].focus() in updateValues); ensure robonen keeps the active thumb focused after programmatic value changes for consistent SR/keyboard behavior.
Where robonen is already better:
- ✅ Performance: scalar context props are exposed via toRef(() => prop) (GetterRefImpl) instead of toRefs, avoiding redundant ReactiveEffect allocations per prop. Style objects in SliderThumb/SliderRange use a stable monomorphic shape (always same keys, explicit undefined) for V8 hidden-class stability and Vue's style patcher. Decimal count is cached per-step in a watcher (stepDecimals) out of the pointermove hot path, while reka recomputes getDecimalCount(step) on every updateValues call.
- ✅ Change detection: setValue() does an element-by-element numeric comparison to skip no-op updates, whereas reka's updateValues uses String(nextValues) !== String(modelValue) stringification for equality and re-sorts the whole array (getNextSortedValues calls .sort) on every keystroke/move. robonen preserves order via neighbour clamping with a single array copy.
- ✅ Hot-path allocation: getValueFromPointer / scaleLinear are plain non-curried functions with no per-call closure, whereas reka's linearScale(input, output) returns a new closure on every call. getClosestValueIndex is single-pass with no intermediate distances array (reka allocates values.map(...) then Math.min(...distances)).
- ✅ Pointer dragging is global (window pointermove/pointerup added on slide start, removed on up), so dragging keeps tracking even if the pointer leaves the track element; reka relies on setPointerCapture and target.hasPointerCapture checks.
- ✅ SliderTrack is interactive (its own pointerdown starts a drag and focuses the closest thumb via startDragFromTrack). This is comparable to reka but co-located more simply without a 3-file Impl/Horizontal/Vertical indirection chain.
- ✅ Simpler architecture: 4 components (Root/Track/Range/Thumb) + context + utils vs reka's 8-file chain (Root -> Horizontal|Vertical -> Impl -> ThumbImpl) plus a Collection dependency. Lower bundle/indirection while still RTL/vertical/inverted-aware.
- ✅ Bounds-change safety: a watcher on [min,max] re-clamps existing values when bounds shrink (packed-array friendly). reka has no equivalent watcher; if max shrinks below a current value the value stays out of range until next interaction.
stepper — robonen-better-with-gaps
Both libraries ship the same 7 parts (Root, Item, Trigger, Indicator, Separator, Title, Description). robonen is genuinely better on several axes: it adds Home/End keyboard navigation (reka stepper omits both), uses the spec-correct aria-current="step" (reka uses "true"), supports a root-level disabled prop (reka has none), registers items through an ordered Collection rather than reka's brittle insertion-order Set with off-by-one index math, and is more performance-conscious (single-pass packed arrays, shared roving helpers, no duplicated nextTick watchers). However robonen has real gaps that block 'strictly better': (1) it has NO screen-reader live region — reka announces 'Step X of N' via an off-screen role=status/aria-live=polite node, so robonen silently changes steps for SR users; (2) it exposes no imperative API (defineExpose is effectively empty) whereas reka exposes goToStep/nextStep/prevStep/hasNext/hasPrev/isFirstStep/isLastStep/isNextDisabled/isPrevDisabled/modelValue/totalSteps; (3) it has no nextStep/prevStep relative navigation at all; (4) its Root slot exposes only value/total/goToStep vs reka's full set of nav helpers/flags; (5) StepperSeparator hardcodes a bare div instead of reusing robonen's own Separator primitive (reka wraps the real Separator with decorative+orientation). Closing the live-region, imperative-API, nav-helper, and Separator-reuse gaps (while keeping robonen's existing edge over reka) would make robonen strictly better.
Major gaps:
- 🔶 (accessibility) reka renders a visually-hidden live region inside StepperRoot — with text 'Step {{modelValue}} of {{totalSteps}}' — so screen readers announce step changes. robonen's StepperRoot has NO live region/status element, so SR users get no announcement when the active step changes.
- 🔶 (api-surface) reka exposes imperative API via defineExpose: goToStep, nextStep, prevStep, modelValue, totalSteps, isNextDisabled, isPrevDisabled, isFirstStep, isLastStep, hasNext, hasPrev. robonen's StepperRoot calls useForwardExpose() but defineExpose's nothing, so a template ref gets no stepper methods.
- 🔶 (features) reka provides rich default slot props: modelValue, totalSteps, isNextDisabled, isPrevDisabled, isFirstStep, isLastStep, goToStep, nextStep, prevStep, hasNext, hasPrev (typed via defineSlots). robonen's Root slot only exposes value, total, goToStep — no nextStep/prevStep/isFirstStep/isLastStep/hasNext/hasPrev/isNextDisabled/isPrevDisabled.
- 🔶 (api-surface) reka has nextStep()/prevStep() convenience navigation built on goToStep. robonen has only goToStep(step) — there is no relative step navigation method anywhere (not in context, not exposed, not in slot).
Minor gaps (4):
- ▫️ (features) reka derives isNextDisabled/isPrevDisabled (computed from the actual next/prev trigger's disabled attribute) and hasNext/hasPrev. robonen exposes none of these derived navigation-availability states, forcing consumers to reimplement boundary/disabled logic.
- ▫️ (code-quality) reka's StepperSeparator wraps the real Separator primitive (SeparatorProps, decorative, orientation forwarded from root) so it inherits proper separator sizing/role semantics. robonen's StepperSeparator hardcodes a bare Primitive div with role="separator" aria-hidden="true" and does NOT reuse its own existing src/separator/Separator.vue primitive, nor accept SeparatorProps (e.g. decorative/orientation override).
- ▫️ (api-surface) reka StepperIndicator and StepperSeparator forward extra DOM props via v-bind="props" on the Primitive (PrimitiveProps spread). robonen's StepperIndicator/Title/Description/Separator only forward :ref/:as and do not v-bind the full props object, so additional PrimitiveProps like asChild are not explicitly spread (they rely on fallthrough only).
- ▫️ (types) reka explicitly types the default slot of StepperItem (defineSlots state) and StepperIndicator (defineSlots step). robonen does not declare defineSlots in any stepper part, so slot prop types are inferred/loose rather than documented.
Recommendations to make robonen strictly better:
- Add a visually-hidden live region to StepperRoot: render an element (reuse VisuallyHidden primitive) with role="status" aria-live="polite" aria-atomic="true" containing localized 'Step {value} of {total}', so screen readers announce step changes. Consider making the message customizable via a prop/slot for i18n.
- Add nextStep() and prevStep() to StepperRootContext (built on goToStep), and add derived state: isFirstStep, isLastStep, hasNext, hasPrev, and (optionally) isNextDisabled/isPrevDisabled computed from the next/prev item's disabled status.
- Call defineExpose on StepperRoot to expose goToStep, nextStep, prevStep, value/modelValue, total, isFirstStep, isLastStep, hasNext, hasPrev so template refs can drive the stepper imperatively (currently useForwardExpose is called but nothing is exposed).
- Expand the Root default slot props to match/exceed reka: add nextStep, prevStep, isFirstStep, isLastStep, hasNext, hasPrev, isNextDisabled, isPrevDisabled alongside existing value/total/goToStep.
- Rewrite StepperSeparator to wrap robonen's own Separator primitive (src/separator) with decorative=true and root orientation forwarded, and accept SeparatorProps so consumers can override orientation/decorative — instead of hardcoding role=separator aria-hidden div.
- Add defineSlots type declarations to StepperItem (state, step), StepperIndicator (step, state), and StepperRoot for first-class slot typing.
- v-bind the full props object on StepperIndicator/Title/Description/Separator Primitives (or otherwise ensure asChild and other PrimitiveProps are forwarded), matching reka's v-bind="props" pattern.
- Keep robonen's advantages explicitly: retain aria-current="step", Home/End handling, root-level disabled, and Collection-based ordering — these already beat reka; do not regress them when adding the above.
Where robonen is already better:
- ✅ Keyboard: robonen handles Home/End to jump to first/last enabled trigger (StepperRoot.vue onTriggerKeyDown via rovingKeyToAction 'home'/'end'). reka's StepperTrigger only binds @keydown.enter.space.left.right.up.down and useArrowNavigation handles arrows only — reka has NO Home/End support.
- ✅ Correct ARIA value: robonen sets aria-current="step" on the active StepperItem (StepperItem.vue), which is the spec-correct token. reka uses aria-current="true" (StepperItem.vue line 89), a weaker/less-specific value.
- ✅ Ordered, deterministic registration: robonen uses Collection (useCollectionProvider/useCollectionInjector + CollectionSlot/CollectionItem) which yields DOM-ordered items via getItems(true). reka tracks triggers in a plain Set (totalStepperItems) and indexes it with Array.from — order is insertion order and its index math (totalStepperItemsArray[step], [step-2]) is brittle/off-by-one-prone.
- ✅ Root-level disable: robonen exposes a
disabledprop on StepperRoot that disables the whole stepper (gates goToStep, propagates into item.disabled and focusable, emits data-disabled on root). reka has no root-level disabled prop at all. - ✅ Performance-conscious focus traversal: robonen builds a PACKED enabled-triggers array in a single for-loop (no filter closure) and reuses shared resolveNextIndex/rovingKeyToAction helpers. reka recomputes Array.from(totalStepperItems) per keydown and runs two duplicate watch(nextTick) blocks (on modelValue and on totalStepperItemsArray) to maintain prev/next refs.
- ✅ RTL handled through the shared roving helper (rovingKeyToAction flips delta for dir==='rtl') and direction falls back to ConfigProvider via useConfig; dir is also reflected onto the root element (:dir="direction").
config-provider — robonen-better-with-gaps
Parts reka has that robonen lacks: ConfigProvider.vue (declarative SFC component with slot), ScrollBodyOption type + scrollBody config field, nonce config field (+ useNonce shared composable), locale config field (+ useLocale shared composable), useDirection shared composable resolving local-or-config dir, Vue <3.5 useId fallback path Parts robonen has that reka lacks: provideAppConfig (app-level plugin provisioning), teleportTarget config field (+ Teleport.vue consumer), Pluggable UseIdFn with (deterministic?, prefix?) signature, Local useId.ts that routes through active config, Non-optional, always-resolved ConfigContext fields
robonen's config-provider is a lean composable-first design (provideConfig/provideAppConfig/useConfig + a config-routed useId) that genuinely beats reka in a few areas: app-level plugin provisioning (provideAppConfig), a centralized teleportTarget knob consumed by Teleport.vue, a richer pluggable UseIdFn that forwards deterministic/prefix, and an always-resolved non-optional context that avoids reka's per-consumer fallback duplication. However reka's ConfigProvider currently carries three first-class global features robonen's config entirely lacks: scrollBody (body-scroll-lock padding/margin/--scrollbar-width tuning), nonce (app-wide CSP nonce inheritance), and locale (inherited by all date/calendar primitives). In robonen these are either hardcoded (scroll lock in @robonen/vue useBodyScrollLock) or only available as per-component props (nonce on ScrollAreaViewport, locale on CalendarRoot/DatePickerRoot), so they cannot be set once app-wide. There is also a real reactivity bug: resolveContext snapshots a MaybeRefOrGetter dir/teleportTarget into a fresh ref, so a reactive source disconnects from the context (reka's toRefs(props) keeps them live), and the default useId has no Vue<3.5 fallback that reka provides. Net: robonen is better on provisioning/teleport/useId ergonomics but has clear, fixable feature gaps (scrollBody/nonce/locale) plus a reactivity defect that prevent it from being strictly better today.
Major gaps:
- 🔶 (features) reka provides a global
scrollBodyconfig (boolean | ScrollBodyOption{padding,margin}) consumed by useBodyScrollLock to compute body padding/margin compensation and --scrollbar-width on lock. robonen's ConfigContext has NO scrollBody field; robonen's @robonen/vue useBodyScrollLock (used by DialogContentModal.vue:24, PopoverContentModal, MenuRootContentModal, SelectContentImpl) hardcodes scrollbar compensation and offers no global config knob to disable/customize padding vs margin per app. - 🔶 (features) reka provides a global
nonceconfig (CSP) inherited by every primitive that injects a <style> tag, via useNonce() which falls back to context.nonce. robonen has NO nonce field in config; robonen exposes nonce only as a LOCAL prop on ScrollAreaViewport.vue:6 (nonce?: string) and nowhere else, so there is no app-wide CSP nonce inheritance — every style-injecting primitive must be passed nonce manually. - �� (rtl-i18n) reka provides a global
localeconfig (default 'en') inherited by all date/calendar primitives via useLocale(). robonen has NO locale in config; robonen's CalendarRoot.vue:91 and DatePickerRoot.vue defaultlocale='en'as a PER-COMPONENT prop with no global inheritance, forcing the locale to be repeated on every Calendar/DatePicker/DateField instance. - 🔶 (performance) robonen's provideConfig does NOT keep the context reactive to a reactive source option. resolveContext() reads
toValue(options.dir)ONCE and wraps the snapshot in a brand-new ref, so passing a ref/getter for dir or teleportTarget (which ConfigOptions types as MaybeRefOrGetter) silently disconnects future updates from the source. reka uses toRefs(props) so the provided dir/locale/scrollBody/nonce refs ARE the live prop refs and propagate parent updates automatically.
Minor gaps (3):
- ▫️ (ssr) reka's useId has a built-in Vue-version fallback path: when Vue 3.5's
useIdis unavailable it uses an injectedcontext.useId?.()plus an internal incrementing counter so SSR hydration still works on Vue <3.5. robonen's toolkit useId (toolkit useId/index.ts:29) calls Vue'suseId()unconditionally with no <3.5 fallback, so on older Vue it would break; the config-provider's useId override exists but the DEFAULT path has no graceful degradation. - ▫️ (api-surface) reka ConfigProvider sets
inheritAttrs: falseand renders a real component boundary, allowing it to be used directly in templates (e.g. ). robonen ships NO ConfigProvider component at all — consumers must call provideConfig() inside a setup() of a component they author, which is less ergonomic for template-only/no-build or simple app-shell usage and gives no declarative props-with-JSDoc surface. - ▫️ (code-quality) reka documents each prop with rich JSDoc (@defaultValue, @type, descriptions of inheritance semantics) on ConfigProviderProps, surfacing in IDE tooltips and the docs generator. robonen's ConfigOptions interface (context.ts:18-22) has no per-field JSDoc, reducing discoverability of dir/teleportTarget/useId semantics and defaults.
Recommendations to make robonen strictly better:
- Add
scrollBody?: MaybeRefOrGetter<boolean | ScrollBodyOption>to ConfigOptions/ConfigContext and wire @robonen/vue's useBodyScrollLock to read it from useConfig() so the global padding/margin/--scrollbar-width behavior is configurable app-wide (match reka shared/useBodyScrollLock.ts). Define ScrollBodyOption = { padding?: boolean|number|string; margin?: boolean|number|string }. - Add
nonce?: MaybeRefOrGetter<string>to config and introduce a useNonce(localNonce?) composable that resolves localNonce ?? config.nonce; have ScrollAreaViewport and every style-injecting primitive default their nonce prop to it, giving app-wide CSP nonce inheritance. - Add
locale?: MaybeRefOrGetter<string>(default 'en') to config and a useLocale(localLocale?) composable; default CalendarRoot/DatePickerRoot/DateFieldlocaleprops to the config value so locale is inherited globally instead of repeated per instance. - Fix the reactivity leak in resolveContext: instead of
ref(toValue(options.dir)), preserve reactivity from the source — e.g. usetoRef/a computed-backed ref (or accept a ref and reuse it) so a MaybeRefOrGetter source stays live, matching reka's toRefs(props) behavior. Add a test that passes a ref/getter and asserts context updates when the source changes. - Make the default useId degrade gracefully on Vue <3.5 (feature-detect
'useId' in vue, fall back to an injected useId override + internal counter) as reka does, or document the hard Vue 3.5 requirement explicitly. - Ship an optional thin SFC (wrapping provideConfig in setup, inheritAttrs:false, ) for declarative/template usage, keeping the composable API as the primary path. Add JSDoc (@defaultValue/@type) to each ConfigOptions field for IDE discoverability.
- Consider exposing a forceMount/global flag and documenting that provideAppConfig shares a single context instance app-wide so per-component overrides via nested provideConfig still work.
Where robonen is already better:
- ✅ App-level provisioning: robonen exposes provideAppConfig(app, options) so config can be installed as a Vue plugin (app.provide) without a wrapping component. reka ONLY ships a SFC and provideConfigProviderContext is internal/not re-exported, so reka cannot provide config at the app root without rendering a component. (robonen context.ts:43-45, index.ts re-exports provideAppConfig)
- ✅ Composable-first API with zero render overhead: robonen's provider is pure composables (provideConfig / useConfig) with no SFC, no slot wrapper, no inheritAttrs handling. reka must mount a component whose template is , adding a (thin) component instance to the tree. robonen integrates anywhere in setup().
- ✅ Safer default-on-inject ergonomics: robonen's useConfig() returns a FULLY-formed ConfigContext (via resolveContext()) when no provider exists, so all fields (dir, teleportTarget, useId) are always present and typed non-optional (ConfigContext.dir: Ref, not optional). reka's ConfigProviderContextValue makes every field optional (dir?, locale?, useId?) and each consumer (useDirection, useNonce, useLocale) must re-inject with its own ad-hoc fallback ref, duplicating defaults across files and risking drift.
- ✅ teleportTarget config field: robonen centralizes the global teleport/portal destination in config (ConfigContext.teleportTarget, consumed by Teleport.vue:53 as
to ?? config.teleportTarget.value). reka's ConfigProvider has NO teleport-target config; reka primitives hardcode 'body' or require per-Portaltoprops. - ✅ Typed pluggable useId with prefix + deterministic passthrough: robonen's UseIdFn signature is (deterministic?, prefix?) => ComputedRef and the local useId.ts forwards both args through the active config (useId.ts:11-16). reka's ConfigProvider.useId is
() => string(no args), so a user-injected useId cannot receive deterministic id or prefix. - ✅ useConfig() throws a descriptive VueToolsError via useContextFactory only on genuinely-absent symbol injection but always supplies a resolved fallback, avoiding the silent-undefined-field footguns reka's optional context invites.
avatar — robonen-better-with-gaps
Both libraries implement the same three parts (Root, Image, Fallback) with identical loading-status state machines ('idle'|'loading'|'loaded'|'error') shared via context, so there is full structural parity and no missing sub-components on either side. robonen is genuinely better in several areas: it puts data-status on the Root for styling, its Fallback delayMs logic is reactive and self-correcting (reka's canRender latches once and never resets), it guards against stale in-flight image loads when src changes, it has an explicit SSR branch, and it gracefully handles missing/empty src. However robonen has real gaps versus reka, mostly concentrated in AvatarImage: it does not forward referrerPolicy or crossOrigin (major — affects CORS, privacy, and cache reuse between the off-DOM preload and the displayed img), it lacks reka's synchronous cached-image detection so cached avatars flash the fallback, it doesn't validate naturalWidth>0 before declaring 'loaded', it omits role="img", it has no default slot on the image, it uses v-if (remount) instead of v-show, and it surfaces loading status only via a prop callback rather than a Vue emit. None of these are structural; all are additive fixes. With the referrerPolicy/crossOrigin forwarding, cached/naturalWidth detection, role="img", a slot, v-show, and a loadingStatusChange emit added, robonen would be strictly better than reka while retaining its existing advantages. Verdict: robonen-better-with-gaps.
Major gaps:
- 🔶 (api-surface) AvatarImage does not forward referrerPolicy. reka exposes a referrerPolicy prop that is applied both to the preloading Image (utils.ts:56-57 img.referrerPolicy = referrerPolicy.value) and to the rendered element (AvatarImage.vue:54 :referrerpolicy). robonen's AvatarImageProps has no referrerPolicy, so privacy-sensitive avatar requests cannot set Referrer-Policy, and the displayed
may issue a second request with different policy than the preload.
- 🔶 (api-surface) AvatarImage does not forward crossOrigin. reka exposes crossOrigin applied to the preload Image (utils.ts:58-59) and the rendered img (AvatarImage.vue:55 :crossorigin). Because robonen preloads via
new Image()with no crossOrigin and then renders a separate(also without crossOrigin), CORS-required avatars (canvas tainting, credentialed CDNs) cannot be configured, and the displayed image may not reuse the preload cache entry.
- 🔶 (performance) No synchronous cached-image detection. reka's resolveLoadingStatus returns 'loaded' immediately when image.complete && image.naturalWidth > 0 (utils.ts:17), so a browser-cached avatar shows instantly with no fallback flash. robonen always calls setStatus('loading') then waits for an async onload (AvatarImage.vue:50-59), so even a fully cached image produces a fallback flash before swapping in the image.
Minor gaps (5):
- ▫️ (edge-cases) No naturalWidth validation of a 'loaded' image. reka requires image.naturalWidth > 0 to count as loaded (utils.ts:17), rejecting broken/zero-dimension responses that still fire onload. robonen treats any onload as 'loaded' (AvatarImage.vue:53-54) regardless of dimensions, so a degenerate image can suppress the fallback.
- ▫️ (accessibility) AvatarImage sets no explicit role. reka renders role="img" on the image Primitive (AvatarImage.vue:50), which preserves image semantics even when
as/asChild changes the element to a non-tag. robonen renders a bare Primitive with as='img' and no role (AvatarImage.vue:76-82); if a consumer sets as to a span/div the accessibility role is lost.
- ▫️ (features) AvatarImage renders no default slot. reka's AvatarImage forwards a (AvatarImage.vue:57), allowing content/children inside the image element (relevant when as/asChild renders a non-void wrapper). robonen's AvatarImage template (AvatarImage.vue:76-82) is a self-closing Primitive with no slot, so it cannot host children.
- ▫️ (performance) Image visibility uses v-if (unmount) instead of v-show. reka keeps the
mounted and toggles visibility with v-show display:none (AvatarImage.vue:49), so the actual
participates in the browser's image pipeline and is not torn down/recreated on status changes. robonen unmounts the img entirely with v-if (AvatarImage.vue:79), meaning the displayed image element is created only after the off-DOM preload completes, increasing the chance of a separate fetch and a remount.
- ▫️ (api-surface) Loading status is exposed as a prop callback only, not a Vue emit. reka defines AvatarImageEmits loadingStatusChange via defineEmits (AvatarImage.vue:6-12,28,39), so consumers can use @loading-status-change and it integrates with Vue's emit tooling/types. robonen only offers an onLoadingStatusChange prop callback (AvatarImage.vue:10,20,31); there is no defineEmits, so template @-handler ergonomics and emit typing differ from the ecosystem convention.
Recommendations to make robonen strictly better:
- Add referrerPolicy and crossOrigin props to AvatarImageProps and apply them both to the preloading
new Image()(img.referrerPolicy / img.crossOrigin before setting src) and to the renderedPrimitive (:referrerpolicy / :crossorigin), matching reka utils.ts:56-59 and AvatarImage.vue:54-55, so the displayed image reuses the preload cache and CORS/privacy can be configured.
- Detect already-loaded/cached images synchronously to avoid a fallback flash: when creating the loader Image (or by reading the rendered img), if img.complete && img.naturalWidth > 0 set status to 'loaded' immediately instead of unconditionally going through 'loading' (mirror reka resolveLoadingStatus, utils.ts:14-17).
- Require img.naturalWidth > 0 in the onload handler before treating the image as 'loaded' (AvatarImage.vue:53-54), so zero-dimension/broken-but-onload responses fall through to 'error' and the fallback stays visible.
- Add role="img" to the AvatarImage Primitive (AvatarImage.vue:76-82) so image semantics survive when consumers override
as/use as='template'. - Add a default to AvatarImage's Primitive so children can be rendered when
asis a non-void element (parity with reka AvatarImage.vue:57). - Switch the image render from v-if to v-show (keep the
mounted, toggle display) to avoid remounting the displayed element and to let the browser image pipeline drive load detection, as reka does (AvatarImage.vue:49).
- Introduce a defineEmits-based loadingStatusChange emit on AvatarImage in addition to (or instead of) the onLoadingStatusChange prop, so consumers get idiomatic @loading-status-change handlers and proper emit typing (reka AvatarImage.vue:6-12).
- Consider preserving robonen's strengths while adopting the above: keep data-status on Root, the self-correcting delayMs fallback, the in-flight load cancellation guards, and the explicit SSR branch — these are real advantages over reka and should not be regressed.
Where robonen is already better:
- ✅ Root exposes a data-status attribute reflecting imageLoadingStatus (AvatarRoot.vue:27 :data-status="imageLoadingStatus"), giving consumers a CSS/style hook (e.g. [data-status=loading]). reka's AvatarRoot.vue renders no status attribute at all.
- ✅ Fallback delay logic is reactive and self-correcting: on every status change it re-evaluates, re-hides when the image becomes 'loaded' (AvatarFallback.vue:26-44 sets canShow=false and clears the timer), and re-arms the delay if status reverts. reka's AvatarFallback canRender (AvatarFallback.vue:24,26-36) only ever latches true once via a one-shot timer and is never reset when the image later loads or src changes.
- ✅ AvatarImage cancels stale in-flight loads via identity guards (AvatarImage.vue:53-57
if (currentImage === img)) so a rapid src change cannot let an older image's onload/onerror clobber the newer status. reka relies on watchEffect cleanup removing listeners but reuses a single persistent Image element (utils.ts:22-31), which is functional but less explicit about race ordering. - ✅ Explicit SSR branch in the loader: when typeof window === 'undefined' robonen sets status to 'loading' and skips constructing an Image (AvatarImage.vue:46-49). reka's useImageLoadingStatus guards new window.Image() with isClient (utils.ts:27) but still computes resolveLoadingStatus against a null image, which is fine but robonen's intent is clearer.
- ✅ AvatarImage explicitly emits 'error' when src is falsy/undefined (AvatarImage.vue:42-44), and the src prop is optional, so passing no/empty src cleanly shows the fallback. reka types src as required (AvatarImage.vue:14
src: string). - ✅ onBeforeUnmount detaches the loader image's onload/onerror and drops the reference (AvatarImage.vue:64-70), avoiding any lingering callback firing after unmount.
visually-hidden — robonen-better-with-gaps
Parts reka has that robonen lacks: VisuallyHiddenInput.vue (form-integration input that serializes primitive/array/object values into native hidden inputs), VisuallyHiddenInputBubble.vue (single native input that fires synthetic input/change events via the native HTMLInputElement.prototype value setter, plus checked/required/disabled props)
Robonen's core VisuallyHidden display component is at parity-or-better than reka's on the pure visual-hiding job: it has the same correct sr-only style block (including reka's #2127 container-scroll fix), it forwards the inner element/exposed API via useForwardExpose (which reka's VisuallyHidden does not), it emits a richer data-visually-hidden attribute, and crucially its default 'focusable' mode keeps content announced — fixing reka's footgun where the default feature='focusable' silently sets aria-hidden='true' and hides the label from screen readers. The decisive gap is scope: reka's VisuallyHidden package also ships VisuallyHiddenInput + VisuallyHiddenInputBubble, a reusable native-form-integration layer (value serialization for primitives/arrays/objects, required+empty-array handling, checked/required/disabled props, and programmatic value sync via the native prototype setter + synthetic input/change events) that is consumed across ~10 reka form primitives. Robonen ships none of that here and instead hand-rolls inline hidden inputs per primitive (e.g. CheckboxRoot). Robonen also lacks a hidden-but-focusable proxy mode (reka separates aria-hidden from tabindex), lacks an asChild boolean on the component, and has a misleading JSDoc on the feature prop. To be strictly better, robonen should add the form-input component pair, separate the focus/a11y-tree concerns, add asChild, and fix the prop docs.
Major gaps:
- 🔶 (forms) Reka ships a full native-form-integration component pair (VisuallyHiddenInput + VisuallyHiddenInputBubble) inside the VisuallyHidden package; robonen's visually-hidden package ships only the display component with no form-input analogue.
- 🔶 (forms) Reka's input serializes complex values: primitives, arrays with
name[index]keys, arrays-of-objects withname[index][key], and plain objects withname[key], rendering one native input per leaf. Robonen has no equivalent value-serialization helper. - 🔶 (forms) Reka programmatically sets the input value using the native HTMLInputElement.prototype 'value' setter and dispatches bubbling synthetic input + change events, so third-party form libraries / event listeners detect changes. Robonen has no such mechanism in this package.
Minor gaps (5):
- ▫️ (edge-cases) Reka handles the 'required + empty array' edge case by still rendering one input so native HTML required validation fires on empty multi-selects.
- ▫️ (forms) Reka supports a
checkedprop on the bubble input (for checkbox/radio submission) distinct fromvalue. Robonen's package exposes nothing of the sort. - ▫️ (api-surface) Reka provides a distinct 'hidden-but-focusable proxy' mode (feature='focusable' => aria-hidden=true, element stays in tab/focus order). Robonen has no mode that keeps the element focusable while removing it from the accessibility tree; robonen's two modes are 'focusable' (announced, in tab order) and 'hidden' (aria-hidden AND tabindex=-1), which conflates a11y-tree removal with focus-order removal.
- ▫️ (api-surface) Reka's VisuallyHidden binds :as-child='asChild', so consumers can merge the hidden styles onto an existing child element/component (e.g. BubbleSelect.vue uses
<VisuallyHidden as-child>). Robonen's VisuallyHiddenProps/Primitive expose no asChild boolean for this component. - ▫️ (code-quality) JSDoc on robonen's
featureprop is inaccurate/misleading: it is documented as 'Exclude the element from the accessibility tree entirely ... @default false' even though the type is 'focusable' | 'hidden' and the real default is 'focusable'. Reka's prop has no such doc mismatch.
Recommendations to make robonen strictly better:
- Add a VisuallyHiddenInput (+ inner bubble) component to robonen's visually-hidden package mirroring reka: serialize primitive/array/array-of-object/object values into one native hidden input per leaf using
name[index][key]conventions, support name/value/checked/required/disabled props, and handle the required+empty-array case by still rendering one input. - Implement programmatic value sync in that input using the native HTMLInputElement.prototype 'value' setter plus bubbling synthetic 'input' and 'change' events, so external form libraries detect changes (port reka VisuallyHiddenInputBubble.vue watch logic).
- Refactor robonen's form primitives (CheckboxRoot, Switch, RadioGroup, Slider, ToggleGroup, Select, TagsInput, PinInput) to reuse the new VisuallyHiddenInput instead of hand-rolling inline elements, eliminating duplicated ad-hoc hidden-input markup.
- Separate the two orthogonal concerns into distinct/composable behavior so a hidden-but-focusable proxy is expressible: e.g. keep 'focusable' (announced, in tab order) and 'hidden' (aria-hidden + tabindex=-1), but add a third mode or boolean so an element can be aria-hidden while staying focusable (reka's focus-proxy use case).
- Add an asChild boolean to VisuallyHiddenProps (or document the as='template' equivalent) so consumers can merge sr-only styles onto an existing child element/component the way reka's BubbleSelect uses
<VisuallyHidden as-child>. - Fix the JSDoc on the
featureprop: it currently says '@default false' / 'exclude from accessibility tree entirely' for a 'focusable' | 'hidden' union whose real default is 'focusable'. Document each enum value's exact aria-hidden/tabindex effect. - Export the new VisuallyHiddenInput from src/visually-hidden/index.ts (and consider a top-level export) so form integration is reusable by consumers, matching reka VisuallyHidden/index.ts.
Where robonen is already better:
- ✅ Default feature='focusable' does NOT set aria-hidden, so the common screen-reader-only-label use case (the one shown in reka's own docs: Settings) actually gets announced. Reka's default feature='focusable' sets aria-hidden='true' (VisuallyHidden.vue:19), meaning the naive default use of reka's component is NOT announced to screen readers unless the author explicitly passes feature='fully-hidden' — a real footgun that robonen avoids.
- ✅ Forwards the underlying element/exposed API to consumers via useForwardExpose() + :ref='forwardRef' (VisuallyHidden.vue:20,41). Reka's VisuallyHidden.vue does NOT call useForwardExpose / forwardRef, so a parent placing a template ref on reka's VisuallyHidden does not transparently get the inner $el/exposed API.
- ✅ Exposes a single readable data-visually-hidden attribute carrying the active feature value ('focusable' | 'hidden') for styling/testing hooks (VisuallyHidden.vue:46). Reka only emits a value-less data-hidden='' and only for the fully-hidden case (VisuallyHidden.vue:20), so reka's focusable mode has no styling/test hook at all.
- ✅ Identical, correct sr-only style block including the top/-1px / left/-1px container-scroll fix from reka issue #2127 — robonen has parity on the styling itself with no regression.
alert-dialog — robonen-better-with-gaps
Both libraries build alert-dialog as a thin specialization of Dialog: force modal=true, set role='alertdialog', block outside-pointer/interaction dismissal, and redirect open auto-focus to the Cancel button. robonen reaches functional parity on the load-bearing behaviors — role='alertdialog', modal focus trap, focus return to trigger (via FocusScope useAutoFocus restoring the previously-focused element), body scroll lock, aria-labelledby/aria-describedby wiring, and blocking of outside-click/escape-driven dismissal (it prevents both pointerDownOutside and focusOutside, equivalent to reka's interactOutside.prevent). robonen is genuinely better on dependency hygiene (defineModel vs vueuse useVModel) and adds data-alert-dialog-* hooks reka lacks. However, several real gaps remain. The most significant: robonen focuses Cancel by global document.querySelector instead of a per-instance context, which is incorrect for nested/multiple alert dialogs (reka uses provide/inject + the cancel element's own ref); robonen omits the dev-mode missing-Title/Description accessibility warning reka ships; and robonen does not call useForwardExpose on any alert-dialog part, so template refs do not resolve. Minor gaps: cancel focus without preventScroll, missing { close } slot prop, and an incomplete public type-export surface (no AlertDialogEmits, no per-part Props aliases for Description/Overlay/Portal/Title/Trigger). Files: /Users/robonen/Projects/tools/vue/primitives/src/alert-dialog/AlertDialogContent.vue, AlertDialogCancel.vue, AlertDialogRoot.vue, index.ts. Verdict: robonen is better in places but not yet strictly better — fix the cancel-focus context, the a11y dev warning, and ref forwarding to close the gap.
Major gaps:
- 🔶 (edge-cases) Cancel auto-focus uses a global document.querySelector('[data-alert-dialog-content]') then querySelector('[data-alert-dialog-cancel]'), which returns the FIRST match in document order. With nested or multiple simultaneously-mounted alert dialogs, the wrong dialog's cancel button (or none) gets focused.
- 🔶 (accessibility) No dev-mode accessibility warning when an alert dialog is missing a Title or Description. An alertdialog with no accessible name silently ships.
Minor gaps (5):
- ▫️ (accessibility) Cancel button is focused without preventScroll, so opening the alert dialog can scroll the page/container to the cancel button.
- ▫️ (api-surface) No per-part ref forwarding/expose on AlertDialog parts. Consumers using template refs on AlertDialogContent/Action/Cancel/Root cannot reach the underlying element.
- ▫️ (api-surface) Default slot of AlertDialogRoot exposes only { open }, missing the { close } slot prop.
- ▫️ (types) No dedicated AlertDialog* type re-exports for several parts and no AlertDialogEmits type. Consumers must import DialogDescriptionProps/DialogOverlayProps/etc. from the dialog package and have no AlertDialogEmits alias.
- ▫️ (api-surface) AlertDialogRoot does not forward arbitrary DialogRoot emits as a typed surface; it only wires update:open via defineModel.
Recommendations to make robonen strictly better:
- Replace the document.querySelector-based cancel lookup in AlertDialogContent.vue with a provide/inject context (mirror reka: an AlertDialogContentContext with onCancelElementChange, and have AlertDialogCancel.vue report its own currentElement via useForwardExpose + onMounted). This fixes nested/multiple-dialog focus correctness — the most important gap.
- Change cancel.focus() to cancel.focus({ preventScroll: true }) to avoid scroll jumps on open.
- Add useForwardExpose() to AlertDialogRoot/Content/Action/Cancel and bind :ref="forwardRef" so template refs reach the underlying elements (AlertDialogCancel especially needs its element ref for the context approach above).
- Add a dev-only accessibility warning (NODE_ENV guard) in the Dialog content layer warning when no Title (aria-labelledby) or no Description (aria-describedby) is present, with a hint to use VisuallyHidden — port reka's useWarning. This benefits both Dialog and AlertDialog.
- Expose { open, close } from AlertDialogRoot's default slot to match reka and improve ergonomics (e.g. ).
- Export an AlertDialogEmits type alias and dedicated AlertDialog{Description,Overlay,Portal,Title,Trigger}Props re-exports from alert-dialog/index.ts so the public type surface is complete and self-contained.
- Consider forwarding DialogRoot emits generically in AlertDialogRoot (or document that update:open is the only emit) to future-proof the emit surface.
Where robonen is already better:
- ✅ Cleaner v-model: robonen AlertDialogRoot uses defineModel('open') with no external dependency, whereas reka relies on @vueuse/core's useVModel inside DialogRoot. Same controlled/uncontrolled support with a smaller dependency footprint.
- ✅ Adds stable styling/test hooks reka lacks: robonen stamps data-alert-dialog-content, data-alert-dialog-cancel, and data-alert-dialog-action on the rendered parts (AlertDialogContent.vue, AlertDialogAction.vue, AlertDialogCancel.vue). Reka emits none of these, so consumers cannot target the alert-dialog parts by attribute.
- ✅ Explicit, well-typed dismissal event ordering: robonen's DismissableLayer.createInteractEvent emits interactOutside first and lets it cancel before pointerDownOutside/focusOutside, and AlertDialogContent prevents both pointerDownOutside and focusOutside — functionally blocking every outside-dismiss path, equivalent to reka's @interact-outside.prevent + @pointer-down-outside.prevent.
- ✅ AlertDialogContentProps is Omit<DialogContentProps,'role'> so a consumer cannot accidentally override role='alertdialog'; reka leaves role overridable in the prop type (AlertDialogContentProps extends full DialogContentProps) and only pins it in the template.
toggle — robonen-better-with-gaps
Parts reka has that robonen lacks: VisuallyHiddenInput (hidden form-bubble checkbox rendered inside Toggle)
Both are single-component primitives (Toggle.vue + index.ts; no sub-parts). robonen is genuinely better on interaction robustness for non-button hosts: it adds Space/Enter keyboard activation, tabindex (0/-1) management, aria-disabled, and a logic-level disabled guard — all of which reka's Toggle entirely lacks (reka relies solely on the native , so reka rendered as as='div'/asChild div is keyboard-inoperable and still toggles when 'disabled'). However, robonen is missing reka's form integration: reka extends FormFieldProps (name/required) and renders a VisuallyHiddenInput checkbox so the toggle participates in native form submission (SSR-safe via useFormControl), and it suppresses that input when nested in a ToggleGroup. robonen has no name/required props, no hidden input, no useFormControl, and no toggle-group context injection in the standalone Toggle. reka also exposes a richer slot scope (modelValue/state/disabled vs robonen's pressed-only) and exports an emits/state type. Net: robonen wins on a11y/keyboard for non-native hosts but has a real, fixable form-integration gap. Verdict: robonen-better-with-gaps.
Major gaps:
- 🔶 (forms) Form integration is entirely missing in robonen. reka's Toggle accepts FormFieldProps (name, required) and, when standalone in a form, renders a so the toggle's value is submitted with the surrounding as a name/value pair (and works without JS for SSR). robonen's ToggleProps only extends PrimitiveProps and has no name/required props and renders no hidden input, so a robonen Toggle contributes nothing to form submission.
- 🔶 (ssr) No useFormControl-style detection of an enclosing . reka uses useFormControl(currentElement) (computed Boolean(unrefElement(el)?.closest('form'))) so the hidden input is only rendered when actually inside a form, with an SSR-safe default of true. robonen has neither the composable nor the behavior.
Minor gaps (4):
- ▫️ (features) ToggleGroup awareness is missing. reka's standalone Toggle injects the ToggleGroup root context (injectToggleGroupRootContext(null)) and suppresses its own hidden form input when nested in a ToggleGroup (so the group owns the form value). robonen's Toggle has no injection of toggle-group context; a robonen Toggle placed inside a ToggleGroup would have no shared awareness (robonen's ToggleGroupItem is a separate component that does not reuse Toggle, confirmed by ToggleGroupItem.vue importing useToggleGroupContext directly).
- ▫️ (api-surface) Slot scope exposes fewer props. reka's default slot exposes { modelValue, pressed, state ('on'|'off'), disabled }, letting consumers render based on disabled/state without recomputing. robonen's slot only exposes { pressed } — no disabled, no data-state string.
- ▫️ (types) No exported emits type / explicit update event type. reka exports ToggleEmits ({ 'update:modelValue': [value: boolean] }) and a DataState type for consumers. robonen relies on defineModel('pressed') so the emit is 'update:pressed' (different name from reka's 'update:modelValue') and exports no emits/state type from index.ts (only ToggleProps).
- ▫️ (types) modelValue typing allows null in reka (modelValue?: boolean | null) accommodating cleared/indeterminate-ish external state; robonen's model is strictly boolean | undefined.
Recommendations to make robonen strictly better:
- Add form integration to match reka: extend ToggleProps with FormFieldProps (name?: string; required?: boolean), add a useFormControl composable (computed Boolean(currentElement.closest('form')) with SSR default true) to @robonen/vue or src/utils, and build a VisuallyHiddenInput (hidden ) bubble component. Render it in Toggle when isFormControl && name && no enclosing toggle-group, mirroring reka's
v-if="isFormControl && name && !toggleGroupContext". Use currentElement from the existing useForwardExpose return (already available). - Make standalone Toggle aware of the toggle-group context: inject the toggle-group context (with a null default) and suppress the hidden form input when nested, so the group can own the submitted value.
- Enrich the default slot scope to expose { pressed, disabled, state: 'on'|'off' } (and optionally modelValue alias) so consumers can style/branch without recomputation. Export a DataState ('on'|'off') type.
- Export an emits type from index.ts. Decide on v-model naming: reka uses 'modelValue'/'update:modelValue'. If cross-compat with reka matters, consider supporting/standardizing the model name; at minimum export ToggleEmits for the chosen 'update:pressed' event.
- Consider widening the model type to boolean | null to tolerate externally-cleared controlled values like reka, or document the stricter boolean contract intentionally.
- Add an aria-label/labeling note or test in docs since a toggle button needs an accessible name (reka's tests mount with aria-label='Toggle italic' and run axe); add a vitest-axe accessibility assertion to robonen's Toggle test to guard a11y regressions.
Where robonen is already better:
- ✅ Non-button host support is materially better. For as!='button', robonen synthesizes keyboard activation (onKeydown handles ' ' and 'Enter' with event.preventDefault + toggle), sets tabindex (0 enabled / -1 disabled), and sets aria-disabled. reka's Toggle.vue has NO @keydown handler, NO tabindex, and NO aria-disabled at all (grep confirms none present) — so a reka Toggle rendered as a non-button (as='div' / asChild on a div) is keyboard-inoperable and not properly exposed to AT. robonen explicitly skips synthesizing keys for native button (if (as === 'button') return) to avoid double-activation, which is correct.
- ✅ Disabled is enforced in logic, not just via the native attribute. robonen's toggle() does
if (disabled) returnbefore flipping state, so a disabled non-button host (as='div', which has no native :disabled effect) still cannot be toggled by click or key. reka binds @click="togglePressed" unconditionally and relies solely on the native :disabled attribute; for as='div' the disabled attribute is inert, so a disabled reka Toggle rendered as a div WOULD still toggle on click. robonen closes this edge case (covered by its test 'does not toggle on keyboard when disabled (as=div)'). - ✅ Controlled/uncontrolled handled with the native Vue 3 defineModel('pressed') API + a local ref fallback (get: v => v ?? localPressed.value). This is lighter than reka's dependency on @vueuse/core useVModel and avoids the passive-cast hack
(props.modelValue === undefined) as false. - ✅ Slot prop naming uses
pressedwhich matches the ARIA semantics of a toggle button (aria-pressed) and is concise. - ✅ Toggle button only emits the toggle on actual state change path and keeps the click/keydown separation explicit and readable.
focus-scope — robonen-better-with-gaps
robonen's focus-scope is architecturally cleaner than reka's and matches its full public API (props loop/trapped, emits mountAutoFocus/unmountAutoFocus, Tab/Shift+Tab loop+trap keyboard handling, autofocus-on-mount, focus-return-on-unmount, focusin/focusout trap, MutationObserver re-focus, nested pause/resume stack, as/as=template passthrough, tabindex=-1, preventDefault on both autofocus events). It is genuinely better in three concrete ways: it avoids reka's array-mutating candidates.reverse() via a dedicated findLastVisible; it correctly removes the mount-autofocus listener (reka leaks it by passing a fresh arrow to removeEventListener); and it factors logic into reusable, independently-tested platform utilities with a stronger useForwardExpose. However, three real behavioral gaps keep it from being strictly better. (1) MAJOR: useFocusTrap's handleMutations lacks reka's two guards (null lastFocusedElement and anyNodesRemoved), so robonen steals focus to the container on pure node additions or before anything is focused (el.contains(null) is false). (2) MAJOR: unmount focus restore + stack.remove run synchronously in robonen vs reka's setTimeout(0) deferral, allowing focus to land on the wrong element during rapid nested scope unmount/mount. (3) MINOR: no explicit isClient SSR guard, and the stack is a bare module-level array rather than a true global singleton, so it can fork across duplicated module copies. There are no sub-component or part differences — FocusScope is a single component in both libraries; focus guards/hideOthers are Dialog-level concerns in both. Fixing the three gaps (notably the two MAJOR ones) makes robonen strictly better.
Major gaps:
- 🔶 (edge-cases) Unmount focus restore is synchronous in robonen vs reka's deferred setTimeout(0), risking focus landing on the wrong element during rapid nested scope unmount/remount.
- 🔶 (edge-cases) useFocusTrap handleMutations lacks reka's null-lastFocusedElement and anyNodesRemoved guards, so it steals focus to the container on pure node additions or before any element is focused (el.contains(null)===false triggers focus(el)).
Minor gaps (2):
- ▫️ (ssr) No explicit isClient/SSR guard on the trap effect; relies solely on watchPostEffect not flushing server-side.
- ▫️ (code-quality) Focus-scopes stack is a bare module-level array rather than a global singleton (reka uses createGlobalState), so it can fork across duplicated module copies and break nested pause/resume.
Recommendations to make robonen strictly better:
- Add reka's two guards to useFocusTrap handleMutations: pass MutationRecord[], return early if lastFocusedElement.value is null, and only refocus when a node was actually removed (mutations.some(m => m.removedNodes.length > 0)).
- Defer unmount focus restore + stack.remove to setTimeout(0) in useAutoFocus onCleanup to match reka and avoid focus races during nested scope unmount/mount.
- Add explicit client guards to useFocusTrap/useAutoFocus effects for SSR robustness instead of relying only on watchPostEffect timing.
- Make the focus-scopes stack a true global singleton (createGlobalState or globalThis symbol) so nested pause/resume survives module duplication; optionally make it a ref for parity.
- Add tests: trap must not steal focus on node additions, must not refocus before anything is focused, and rapid nested mount/unmount must restore to the correct previouslyFocusedElement.
Where robonen is already better:
- ✅ robonen avoids reka's in-place candidates.reverse() array mutation in getTabbableEdges by using a dedicated backward-iterating findLastVisible.
- ✅ robonen correctly removes the AUTOFOCUS_ON_MOUNT listener (same handler reference in dispatchCancelableEvent); reka leaks it by calling removeEventListener with a new arrow function.
- ✅ robonen decomposes into reusable useFocusTrap/useAutoFocus composables + an independently unit-tested @robonen/platform/browsers focus toolkit, vs reka inlining everything in the SFC.
- ✅ robonen's useForwardExpose skips #text/#comment nodes and forwards the child's exposed API + $el, more robust for as='template' passthrough than reka's simpler version.
progress — robonen-better-with-gaps
Both libraries implement Progress as a two-part Root + Indicator primitive with role=progressbar, aria-valuemin/max/now, data-state (indeterminate|loading|complete), and data-value/data-max — there is a direct reka analogue and subcomponent parity (no extra parts on either side; Progress has no keyboard/focus/RTL/form concerns). robonen is genuinely better in several places: a more correct complete state (v >= max vs reka's strict ===), a richer slot scope on BOTH Root and Indicator (reka's Indicator exposes nothing and its Root only modelValue), lighter reactivity (toRef passthroughs vs reka's two useVModels + two immediate watchers), and cleaner non-optional context types. However robonen has real gaps to close to be strictly better: (1) ACCESSIBILITY — robonen exposes only one label function feeding aria-valuetext and never sets aria-label, while reka has BOTH getValueLabel->aria-label and getValueText->aria-valuetext, so reka's progressbar gets a proper accessible name and robonen's does not (major); (2) EDGE CASES — reka validates and self-corrects bad value/max (NaN, out-of-range, max<=0) with descriptive dev errors, while robonen passes everything through unguarded (max=0 yields Infinity% / aria-valuemax=0) (major); (3) reka guards aria-valuenow with an isNumber typeguard whereas robonen only filters null/undefined (minor); (4) reka supports passive/uncontrolled v-model and emits update:modelValue, while robonen's modelValue is a read-only passthrough that never emits, breaking the v-model naming contract (minor — though reka's emit type is itself buggy). Fixing the dual-label ARIA and adding input validation are the two changes required for robonen to be strictly better.
Major gaps:
- 🔶 (accessibility) reka exposes a SECOND accessible-label function and a distinct ARIA attribute. reka has both
getValueLabel->aria-labelANDgetValueText->aria-valuetext(ProgressRoot.vue:23-27, 150-151). robonen has onlygetValueLabel, and it feedsaria-valuetext(ProgressRoot.vue:11,57). So robonen renders NOaria-labelon the progressbar and gives consumers no way to set the accessible name of the widget separately from the value text. The default reka label"50%"becomes the accessible NAME of the progressbar; robonen's becomes only valuetext, leaving the widget effectively unnamed for screen readers unless the consumer supplies their own aria-label/aria-labelledby. - 🔶 (edge-cases) reka validates and self-corrects bad inputs at runtime with developer-facing errors.
validateValuerejects NaN / out-of-range / negative values and coerces to null with a descriptiveconsole.error(ProgressRoot.vue:45-60, watched at :105-115), andvalidateMaxrejects non-positive/NaN max and falls back to 100 with an error (ProgressRoot.vue:62-72, watched at :117-125). robonen does NO validation: a negative value, NaN, max<=0, or value>max are passed straight through to aria attributes andgetValueLabel(which divides by max). With max=0 robonen's default label computesvalue/0->Infinity%and aria-valuemax=0; with NaN it emitsaria-valuenowof NaN. robonen has no dev warnings for misuse.
Minor gaps (2):
- ▫️ (api-surface) reka supports controlled + uncontrolled (passive v-model) operation and emits
update:modelValue. reka usesuseVModel(props,'modelValue',emit,{passive: props.modelValue===undefined})(ProgressRoot.vue:96-98) and declares the emit (ProgressRoot.vue:8). robonen'smodelValueis read-only (toRef passthrough, ProgressRoot.vue:31) with NO emit declared, so although the prop is namedmodelValueit cannot be used as a true two-wayv-modeland never firesupdate:modelValue. A progress bar is typically write-from-parent so this is low impact, but it is a missing API surface vs reka and breaks thev-modelnaming contract. - ▫️ (accessibility) reka guards
aria-valuenowagainst non-number values via anisNumbertypeguard::aria-valuenow="isNumber(modelValue) ? modelValue : undefined"(ProgressRoot.vue:43,149). robonen usesvalue ?? undefined(ProgressRoot.vue:57), which only filters null/undefined — if a consumer passesNaNor a numeric string, robonen will emit an invalidaria-valuenow. (reka also still emits it but additionally validates/corrects upstream.)
Recommendations to make robonen strictly better:
- Add a second accessible-name pathway: introduce a
getValueLabel->aria-labelmapping AND keep a separategetValueText->aria-valuetext, matching reka. Concretely, rename robonen's currentgetValueLabeltogetValueText(it currently feeds aria-valuetext), and add a newgetValueLabel?: (value, max) => string | undefinedbound to:aria-label, so the progressbar has a real accessible name for screen readers and consumers can customize both independently. - Add runtime validation + dev warnings mirroring reka's
validateValue/validateMax: clamp/guard against NaN, value<0, value>max, and max<=0 (the last is critical because the defaultgetValueLabeldivides bymax-> producesInfinity%/division issues, andaria-valuemax=0is invalid). Emitconsole.error/@robonen/vuewarn in dev (tree-shaken in prod). - Harden
aria-valuenowto only render for finite numbers: change:aria-valuenow="value ?? undefined"to use anisNumber/Number.isFiniteguard so NaN or non-numeric inputs do not produce invalid ARIA. Apply the same finite-number guard inside the defaultgetValueTextso the percentage is only computed for valid numbers. - Decide and document the v-model story: either (a) declare
defineEmits<{ 'update:modelValue': [number | null] }>()and makemodelValuea true two-way model viadefineModel/useVModel so themodelValuename is honest, or (b) rename the prop tovalueto signal it is one-way and avoid the broken v-model contract. Note reka's own emit type is buggy (string[]), so robonen can leapfrog by typing it correctly as[number | null]. - Consider passing the
getValueText/getValueLabelstrings into the slot scope as well (e.g.:valueText) so consumers rendering custom labels inside the bar do not have to recompute the percentage; this would extend robonen's already-superior slot scope. - Keep robonen's superior
v >= maxcomplete logic and richer slot scope — do not regress to reka's strict-equalitycompletecheck.
Where robonen is already better:
- ✅ robonen's
completestate is more robust: it usesv >= max(ProgressRoot.vue:38) so a value that overshoots the max (e.g. 105/100) still reportscomplete. reka uses strict equalitymodelValue.value === max.value(ProgressRoot.vue:131), so an overshoot value would incorrectly reportloadingand never reachcomplete. - ✅ robonen passes a richer default slot scope:
{ value, max, state }on both Root (ProgressRoot.vue:63) and Indicator (ProgressIndicator.vue:27). reka's Root slot exposes only{ modelValue }(ProgressRoot.vue:157-159) and its Indicator slot exposes nothing at all (ProgressIndicator.vue:25), so reka consumers must callinjectProgressRootContext()to render the value/state inside the indicator. - ✅ robonen avoids reka's known double-vmodel hazard. reka wraps
maxin a seconduseVModelwith a synthetically-typedpassiveflag and emitsupdate:max(ProgressRoot.vue:100-102, 6-11), but the emit is typed wrongly:'update:modelValue': [value: string[] | undefined]declares a string[] payload for a numeric model (ProgressRoot.vue:8) — a real type bug. robonen sidesteps this entirely with read-onlytoRefpassthroughs (ProgressRoot.vue:31-32). - ✅ robonen uses
toRef(() => ...)identity passthroughs for value/max instead ofcomputed, intentionally avoiding the effect/cache overhead of a computed for a pure passthrough (ProgressRoot.vue:30-32). reka runs twouseVModelinstances plus twoimmediatewatchers (ProgressRoot.vue:96-125) for the same data, which is heavier. - ✅ robonen's context typing is cleaner and explicit (
value: Ref<number | null>,max: Ref<number>,state: Ref<ProgressState>in context.ts:6-10), whereas reka's context marksmodelValueoptional/possibly-undefined (modelValue?: Readonly<Ref<...>>, ProgressRoot.vue:33) forcing optional-chaining at every use site (rootContext.modelValue?.value, ProgressIndicator.vue:23).
command — robonen-better-with-gaps
Parts reka has that robonen lacks: ItemIndicator (ComboboxItemIndicator / ListboxItemIndicator), Cancel/clear (ComboboxCancel), Trigger (ComboboxTrigger, aria-haspopup), Anchor (ComboboxAnchor), Arrow (ComboboxArrow), Portal (ComboboxPortal), Content + ContentImpl (Popper positioning, DismissableLayer, FocusScope, body-scroll-lock, hideWhenEmpty, position inline|popper), Viewport (scrollbar hiding + nonce-able style), Virtualizer (ComboboxVirtualizer / ListboxVirtualizer), GroupLabel as a dedicated part (ListboxGroupLabel / ComboboxLabel) Parts robonen has that reka lacks: CommandLoading (role=progressbar with progress), Built-in result-count live region inside CommandRoot, Built-in --list-height ResizeObserver in CommandList (cmdk-style auto height)
robonen's command is a deliberate cmdk-style flat command palette and has no 1:1 reka equivalent; judged against reka's Combobox/Listbox it is genuinely better on the palette-specific axes it was built for: scoring + fuzzy + keyword filtering with ranked ordering, automatic best-result highlighting on every keystroke, a ResizeObserver-driven --list-height for smooth animated height, a built-in result-count live region, a role=progressbar Loading part, and a lean SSR-safe virtual-focus model that avoids reka's focus-shuffling. The most important real gap is an a11y-semantics bug: CommandItem applies aria-selected to the keyboard-highlighted option rather than to the committed modelValue, so screen readers mis-announce selection — reka separates aria-selected (checked) from data-highlighted correctly. Beyond that, command lacks several reka capabilities (PageUp/PageDown, IME/composition guarding on Enter, RTL/dir awareness, ItemIndicator, a clear/Cancel part, multiple-select, form integration, object values with a by comparator, a textValue distinct from the identity value, and virtualization), and getSelectableItems() re-queries the DOM on every keypress. Fixing the aria-selected semantics and adding PageUp/PageDown + composition guarding closes the only consequential a11y/keyboard gaps; the remaining items are feature-completeness niceties. Verdict: robonen-better-with-gaps.
Major gaps:
- 🔶 (accessibility) aria-selected is wired to the HIGHLIGHT, not the committed selection. CommandItem.vue sets :aria-selected="isHighlighted || undefined" (where isHighlighted = ctx.selectedValue === value, i.e. the roving active item), while the actual chosen value (isSelected = ctx.modelValue === value) is exposed only as a non-semantic :data-selected attribute. In an ARIA listbox, aria-selected must reflect selection state; the active option is conveyed via the input's aria-activedescendant. Conflating them means a screen reader announces every keyboard-highlighted option as 'selected' and never announces the truly committed item.
Minor gaps (12):
- ▫️ (features) No ItemIndicator part to render/announce which option is the committed selection. A palette that supports a persistent modelValue should be able to show a check on the selected row.
- ▫️ (keyboard) No PageUp / PageDown keyboard support. A long command list cannot be paged, and PageUp/PageDown do not jump to first/last.
- ▫️ (rtl-i18n) No RTL / dir awareness anywhere in the command tree. There is no dir prop, no useDirection, and no direction-aware key handling.
- ▫️ (edge-cases) No IME / composition guarding. Pressing Enter while composing (e.g. CJK input) will commit the highlighted item instead of confirming the IME candidate.
- ▫️ (features) No multiple-selection support. The palette can only ever commit a single string modelValue.
- ▫️ (forms) No form integration (hidden input / name / required). The committed value cannot participate in native form submission.
- ▫️ (types) Item value is constrained to string; cannot use object values with a custom equality comparator.
- ▫️ (features) Filtering matches the item's
value(also the data-value/key) rather than a separate display/text value, so the searchable text is coupled to the identity key. - ▫️ (performance) No virtualization option for very large command lists.
- ▫️ (performance) getSelectableItems() performs a live DOM querySelectorAll on every call (every arrow keypress, every commit, and inside the auto-highlight watch).
- ▫️ (features) selectedValue (highlight) is not exposed on the input for assistive recovery when the input is blurred, and there is no Cancel/clear affordance.
- ▫️ (accessibility) Root uses role=application, which is a heavyweight ARIA role that tells screen readers to suppress their normal browse-mode/quick-nav handling for the entire subtree.
Recommendations to make robonen strictly better:
- Fix the core a11y semantics in CommandItem.vue: set :aria-selected from isSelected (committed modelValue match) instead of isHighlighted, and convey the highlighted/active option solely through the input's aria-activedescendant (already present) plus a data-highlighted attribute. This matches reka ListboxItem and the ARIA combobox/listbox pattern.
- Add PageUp/PageDown handling to CommandInput.vue handleKeyDown (PageUp -> moveTo('first') or page jump, PageDown -> moveTo('last') or page jump) to match reka's MAP_KEY_TO_FOCUS_INTENT.
- Guard Enter against IME composition: in handleKeyDown skip commit when event.isComposing (or wire compositionstart/compositionend), mirroring reka's isComposing check in ListboxRoot.onKeydownEnter.
- Add a CommandItemIndicator sub-component (v-if on isSelected, aria-hidden=true) so consumers can render a check on the committed row, matching reka ComboboxItemIndicator.
- Add a separate
textValueprop on CommandItem so filtering can target display text whilevaluestays an opaque identity key; pass textValue (falling back to rendered text) into the filter haystack instead of conflating with value. - Add an optional CommandCancel/clear part that resets searchTerm (and optionally modelValue) and refocuses the input, matching reka ComboboxCancel including a resetModelValueOnClear-style option.
- Cache DOM order for getSelectableItems(): build the value->domIndex map once via MutationObserver (the list already has one in CommandList.vue) instead of running querySelectorAll on every keypress and inside the auto-highlight watch.
- Add a
dirprop (and propagate via useDirection / a dir attribute) for RTL correctness and future horizontal/orientation support, even if current navigation is vertical-only. - Reconsider role=application on CommandRoot; evaluate dropping it (or making it opt-out) to avoid suppressing screen-reader browse mode across the palette subtree.
- Consider optional multiple-select support (modelValue as array + selectionBehavior) and native form integration (name/required + VisuallyHiddenInput) to reach feature parity with reka Listbox for cases where a palette doubles as a picker.
- Optionally offer a virtualization adapter for very large command lists, analogous to reka's ListboxVirtualizer/ComboboxVirtualizer, since getSelectableItems currently scales linearly with DOM size.
Where robonen is already better:
- ✅ Scoring filter with fuzzy subsequence matching and per-item keywords: defaultFilter (command/utils.ts) returns 1 for substring/keyword match, 0.5 for in-order subsequence (loose fuzzy), 0 to hide, and getSelectableItems() in CommandRoot.vue sorts candidates by score desc then DOM order. reka's useFilter.contains (shared/useFilter.ts) is a binary Intl.Collator substring check with no fuzzy match, no scoring, and no keyword/alias support — so reka cannot rank results or match on hidden aliases.
- ✅ Auto-highlight of the best result: CommandRoot.vue has a flush:'post' watch on [searchTerm, filteredItems, allItems] that keeps the highlight on the current item if still visible, otherwise highlights getSelectableItems()[0]. This is exactly the cmdk palette behavior. reka only calls highlightFirstItem() in ComboboxInput.vue when the previous filter count was 0, so a typed query that narrows from many->fewer results does not re-anchor the highlight to the top result.
- ✅ List auto-height CSS variable: CommandList.vue uses a ResizeObserver + MutationObserver to publish --primitives-command-list-height tracking the first child's offsetHeight, enabling the smooth animated-height palette effect from cmdk. reka has no equivalent; ComboboxViewport.vue only sets overflow:auto and hides scrollbars.
- ✅ Live-region result-count announcement: CommandRoot.vue renders a VisuallyHidden role=status aria-live=polite node with announceCount ('N results available.'). reka Combobox/Listbox have no built-in SR announcement of how many results remain after filtering.
- ✅ Dedicated CommandLoading part with role=progressbar, aria-valuetext/valuenow/valuemin/valuemax and aria-live=polite (CommandLoading.vue) for async command palettes. reka has no Loading/progress part in Combobox or Listbox.
- ✅ Simpler, dependency-light virtual-focus model: input keeps real DOM focus and drives aria-activedescendant (CommandInput.vue activeDescendant + aria-activedescendant), avoiding reka's heavier focus-shuffling (ListboxFilter sets rootContext.focusable=false on mount and restores on unmount; ListboxRoot juggles changeHighlight focus()/scrollIntoView). robonen's approach is less stateful and avoids focus-trap machinery for a flat palette.
popper — robonen-better-with-gaps
Parts reka has that robonen lacks: shared Arrow.vue component (renders default SVG arrow path, supports rounded)
Popper is a pure positioning primitive (no ARIA roles, keyboard, focus trap, forms, RTL/loop logic — those live in consumers like Menu/Select/Tooltip), so the comparison centers on sub-parts, arrow rendering, exported API surface, types, and performance. The two implementations are structurally near-identical (same 4 parts: Root, Anchor, Content, Arrow; same floating-ui middleware pipeline; identical prop set on Content including side/align flip, collision, sticky, hideWhenDetached, positionStrategy, prioritizePosition, reference override; same data-side/data-align/--popper-* CSS vars; same 'placed' emit). robonen is genuinely better on micro-performance and code quality: hoisted lookup maps, stable style hidden-class, a more robust arrow ResizeObserver, useTemplateRef/shallowRef, and reactive-props-destructure. The one real, user-visible gap is the Arrow: reka's PopperArrow renders an actual default SVG arrow (with a rounded variant) via a shared Arrow.vue, while robonen's PopperArrow defaults to an empty span that draws nothing unless the consumer supplies their own SVG. Remaining reka advantages are exported helpers robonen lacks: PopperContentPropsDefaultValue, the Measurable interface, and SIDE_OPTIONS/ALIGN_OPTIONS runtime arrays, plus minor reactivity (computedEager) and update exposure. None are critical, but fixing the Arrow default and adding the exported helpers/types would make robonen strictly better than reka for this primitive.
Major gaps:
- 🔶 (features) reka's PopperArrow renders a real, visible SVG arrow by default. It delegates to a shared Arrow.vue whose default
as: 'svg'outputs<svg viewBox="0 0 12 6" preserveAspectRatio="none">containing a default<path d="M0 0L6 6L12 0"/>. robonen's PopperArrow defaults toas = 'span'and renders an EMPTY Primitive with no SVG, no viewBox, and no path, so out of the box robonen's arrow draws nothing visible and the consumer must supply their own SVG via the slot.
Minor gaps (6):
- ▫️ (api-surface) reka exposes a
roundedprop on PopperArrow (via ArrowProps) that switches to an alternate rounded SVG path (d="M0 0L4.58579 4.58579C5.36683..."). robonen's PopperArrowProps has onlywidthandheightand noroundedoption. - ▫️ (api-surface) reka exports
PopperContentPropsDefaultValue(the canonical defaults object) from PopperContent.vue and re-exports it from index.ts, so downstream primitives (Tooltip/Popover/Select etc.) can spread it into their own withDefaults. robonen inlines its defaults via destructuring in PopperContent.vue and does not export any shared defaults object, forcing each consumer to re-declare defaults. - ▫️ (types) reka exports a
Measurableinterface ({ getBoundingClientRect: () => DOMRect }) from PopperRoot.vue for typing virtual/anchor reference elements. robonen has no exported Measurable type; consumers passing a virtual reference must rely solely on floating-ui's ReferenceElement. - ▫️ (api-surface) reka exports the runtime arrays SIDE_OPTIONS (['top','right','bottom','left']) and ALIGN_OPTIONS (['start','center','end']) via
export * from './utils', useful for runtime prop validation, iteration, and Storybook controls. robonen's utils.ts only declares the Side/Align type unions with no runtime array equivalents. - ▫️ (performance) reka builds the middleware array with computedEager (from @vueuse/core), so the middleware list recomputes eagerly/synchronously when any dependency changes rather than lazily on first read. robonen uses a plain
computed, which is lazy — it only recomputes when floating-ui reads.value, which can defer a middleware update by a microtask/tick in edge cases (e.g. arrow size change feeding offset.mainAxis). - ▫️ (api-surface) reka destructures
updatefrom useFloating, making the imperative reposition function available for potential exposure/use; robonen does not destructure or exposeupdate, so there is no imperative way to force a reposition.
Recommendations to make robonen strictly better:
- Give PopperArrow a real default SVG arrow to reach visual parity: create a shared Arrow component (or inline default slot content) that defaults to
as = 'svg'withviewBox="0 0 12 6",preserveAspectRatio="none", and a default<path d="M0 0L6 6L12 0"/>. Currently robonen renders an empty span so the arrow is invisible without a user-supplied SVG. - Add a
rounded?: booleanprop to PopperArrowProps that swaps in the rounded path (d="M0 0L4.58579 4.58579C5.36683 5.36683 6.63316 5.36684 7.41421 4.58579L12 0") to match reka's Arrow feature set. - Export a shared defaults object (e.g. POPPER_CONTENT_DEFAULTS) equivalent to reka's PopperContentPropsDefaultValue, and re-export it from index.ts so downstream primitives (Tooltip/Popover/Select/Menu) can reuse canonical defaults instead of redeclaring them.
- Export a
Measurableinterface ({ getBoundingClientRect: () => DOMRect }) from context.ts/index.ts for typing virtual reference elements passed to PopperAnchor.reference / PopperContent.reference. - Add runtime
SIDE_OPTIONSandALIGN_OPTIONSconst arrays in utils.ts (with Side/Align derived from them via typeof[number]) and export them for prop validation, iteration, and story controls. - Switch computedMiddleware from
computedto an eager computation (computedEager from @vueuse/core or a watchSyncEffect-driven shallowRef) so middleware updates synchronously when arrow size or options change, matching reka's eager behavior. - Consider destructuring and exposing
update(and optionallyisPositioned) from useFloating so consumers can imperatively force a reposition, e.g. on async content size changes.
Where robonen is already better:
- ✅ PopperArrow lookup maps hoisted to module scope (one allocation) vs reka allocating inline objects per render
- ✅ PopperContent style object keeps stable V8 hidden class by always writing visibility/pointerEvents keys vs reka's shape-changing conditional spread
- ✅ More robust arrow ResizeObserver that re-observes on element change and disconnects on unmount, vs reka useSize observing only once in onMounted
- ✅ Uses useTemplateRef and shallowRef (modern Vue 3.5 reactivity) for floatingRef and contentZIndex
- ✅ Reactive props destructure with inline defaults for cleaner code without withDefaults boilerplate
- ✅ Exports both root and content context injectors plus their interface types, vs reka exporting only the root inject
label — robonen-better-with-gaps
The Label primitive itself is essentially identical between the two libraries: both render a native <label> via Primitive with an as default of 'label', forward a for prop, and add a mousedown handler that prevents text selection on double-click (detail > 1). There are no extra sub-components on either side and no roving-tabindex/keyboard/focus-trap concerns for this primitive. The real differences live in the shared Primitive/PrimitiveProps layer that Label inherits. reka's Label is better in one meaningful way: it inherits and forwards a documented asChild composition prop (via v-bind=\"props\"), whereas robonen's Primitive has no asChild (composition only via as=\"template\") and robonen's Label hard-codes :as/:for, so it would silently drop asChild and any future PrimitiveProps. reka also has stronger documentation/test surface (axe a11y test, negative focus edge-case tests, a story). robonen is better on small code-quality and micro-perf points (named handler, batched property descriptors in useForwardExpose, shallowRef, explicit two-directional mousedown tests) but has a dead forwardRef destructure. Verdict: robonen-better-with-gaps — robonen is marginally cleaner at the component level but is NOT strictly better because it lacks asChild/full prop forwarding and the a11y/edge-case test coverage reka provides.
Major gaps:
- 🔶 (api-surface) reka's Label inherits a documented
asChildboolean prop from PrimitiveProps, enabling the standard composition pattern<Label as-child><span>...</span></Label>to merge label behavior (mousedown handler,for) onto a custom child element. robonen's Label has noasChild: robonen's PrimitiveProps only declaresas(primitive/Primitive.ts:7-9), and composition is only possible viaas="template", which is undocumented on Label and is a different mental model than the rest of the headless ecosystem. A consumer who writes<Label as-child>against robonen gets a silently-ignored attribute. Note robonen's own MenubarRoot already binds:as-child(MenubarRoot.vue:93) against a Primitive that has no such prop, showing the API is inconsistent/incomplete repo-wide.
Minor gaps (4):
- ▫️ (api-surface) reka's Label forwards ALL props to Primitive via
v-bind="props"(Label.vue:23), so any future PrimitiveProps additions (asChild today, anything later) propagate automatically. robonen's Label hard-codes:asand:foronly (Label.vue:25), so it will silently drop any new PrimitiveProps member (e.g. asChild) without a code change. This makes robonen's Label brittle against Primitive API growth. - ▫️ (accessibility) reka ships an explicit accessibility test using
vitest-axethat renders Label+input together and assertstoHaveNoViolations()(Label.test.ts:12-25). robonen's Label test file has no axe/a11y assertion. While the rendered markup is equivalent, robonen lacks an automated a11y regression guard for this primitive. - ▫️ (edge-cases) reka's Label test explicitly covers two negative focus edge cases: clicking a label with no
for, and clicking a label whoseforpoints to a non-existent id, asserting the input does NOT receive focus (Label.test.ts:50-77). robonen has no equivalent edge-case coverage for label/control association behavior. - ▫️ (code-quality) reka provides a Histoire story (Label.story.vue) demonstrating the documented Label+input pairing and styling, serving as live docs/usage reference. robonen's label/ directory has no story file (only Label.vue, index.ts, test). Lower discoverability/documentation surface.
Recommendations to make robonen strictly better:
- Add
asChildsupport so robonen's Label matches the standard headless composition API. Easiest path: addasChild?: booleantoPrimitiveProps(primitive/Primitive.ts) and have Primitive mapasChild ? 'template' : asinternally (mirroring reka Primitive.ts:56); then in Label bindv-bind="props"(or forward:as-child="asChild"). This also fixes the existing repo inconsistency where MenubarRoot.vue:93 already binds:as-childto a Primitive that ignores it. - Change robonen Label to forward all props with
v-bind="props"(and keep@mousedown) instead of hard-coding:as/:for, so future PrimitiveProps additions propagate automatically and don't get silently dropped. - Remove the dead
const { forwardRef } = useForwardExpose();destructure in Label.vue:14 OR actually bind:ref="forwardRef"on the Primitive. CurrentlyforwardRefis destructured but never used (the template has no:ref), so it is dead code; either wire it up to expose the underlying<label>element to consumers (better, matches the intent of useForwardExpose) or calluseForwardExpose()bare like reka does for the side effect only. - Add a
vitest-axeaccessibility assertion to robonen's Label.test.ts (render Labelfor="input"next to<input id="input">and assert no violations) to match reka's automated a11y guard. - Add negative focus edge-case tests mirroring reka Label.test.ts:50-77 (label with no
for, andforpointing to a missing id should not move focus) to lock in correct label/control association behavior. - Add a story/demo file (Label.story.vue or the repo's docs equivalent) showing the canonical Label+input pairing, matching reka's documentation surface.
Where robonen is already better:
- ✅ JSDoc on the
forprop is slightly richer: robonen documents it as(renders as 'for')(Label.vue:5), clarifying the rendered output; reka just says 'The id of the element the label is associated with.' (Label.vue:6). Minor but a documentation edge. - ✅ robonen extracts the mousedown handler into a named top-level function
onMouseDown(event)(Label.vue:18-21) instead of reka's inline arrow in the template (Label.vue:24-27). This is marginally better for readability, avoids recreating the handler closure on every render, and is easier to unit test. - ✅ robonen's test suite directly asserts the mousedown behavior in both directions (
detail > 1prevents default,detail === 1does not — Label.test.ts:18-32), giving stronger behavioral coverage of the double-click-selection guard than reka, whose tests focus on rendering/for/click-focus and axe. - ✅ robonen's underlying
useForwardExposeis implemented more efficiently: it batches all property definitions via a singleObject.defineProperties(ret, descriptors)call (index.ts:85) and usesshallowRefinstead of reka's per-keyObject.definePropertyloop and plainref(reka useForwardExpose.ts:33-61). This is shared infra Label benefits from indirectly.
separator — robonen-better-with-gaps
Parts reka has that robonen lacks: shared/component/BaseSeparator.vue (shared base layer reused by other primitives)
Both libraries implement Separator as a single leaf primitive with the same core semantics: orientation (horizontal/vertical, default horizontal), decorative (role='none' when true, else role='separator'), and aria-orientation only emitted when vertical, plus a data-orientation attribute. There are no sub-components, keyboard interaction, focus management, forms, RTL, or presence concerns for this primitive, so the comparison reduces to props, a11y wiring, validation, and ergonomics. robonen is genuinely better in ref/expose forwarding (useForwardExpose) and renders one layer less than reka (which interposes a reusable BaseSeparator). reka is better in three concrete ways: (1) it exposes a documented asChild composition prop that robonen lacks (robonen only has the undocumented as='template' escape hatch), (2) it runtime-validates orientation and falls back to horizontal, whereas robonen passes any value straight into data-orientation, and (3) it ships an automated axe a11y test plus uses a shared DataOrientation type and a reusable BaseSeparator. None of reka's advantages are critical, but the asChild gap is a real API-surface shortfall. Verdict: robonen-better-with-gaps — close the asChild, orientation-validation, shared-type, and axe-test gaps to make robonen strictly better.
Minor gaps (5):
- ▫️ (api-surface) Explicit documented asChild prop for composition
- ▫️ (edge-cases) Runtime orientation validation with horizontal fallback
- ▫️ (code-quality) Reusable shared BaseSeparator consumed by other primitives
- ▫️ (accessibility) Automated axe a11y test for both orientations
- ▫️ (types) Shared DataOrientation type alias instead of inlined union
Recommendations to make robonen strictly better:
- Add an explicit, documented
asChild?: booleanprop. Either add asChild to robonen's PrimitiveProps and wire it through (mapping to as='template' internally) so consumers get a discoverable, documented composition API matching reka, or document the as='template' pattern prominently on Separator. This closes the biggest API-surface gap. - Add runtime orientation validation/fallback in Separator.vue: compute a validated orientation (
isValidOrientation(orientation) ? orientation : 'horizontal') and use it for both :data-orientation and the aria-orientation/role logic, so invalid orientation values cannot produce an invalid data-orientation token or a missing aria-orientation. - Introduce a shared DataOrientation type (e.g. in a central types module) and use it for SeparatorProps.orientation instead of inlining the literal union, to match reka's single-source-of-truth typing and stay consistent across other robonen primitives that have an orientation.
- Add an automated accessibility assertion to the Separator test (vitest-axe toHaveNoViolations) for both horizontal and vertical orientations, mirroring reka's Separator.test.ts, to guard against a11y regressions.
- Consider extracting a shared Base separator (or at least a composable computing the role/aria-orientation/data-orientation semantics) so future robonen primitives (toolbar, toggle-group, menu) that need internal separators reuse identical semantics instead of reimplementing them.
- Keep robonen's useForwardExpose ref-forwarding advantage (do not drop it) and ensure any asChild implementation continues to forward the ref correctly when rendering via template/Slot.
Where robonen is already better:
- ✅ Forwards underlying $el and child exposed API via useForwardExpose + forwardRef (reka's Separator does not forward refs)
- ✅ Single-layer render (Separator -> Primitive) vs reka's extra BaseSeparator layer
- ✅ Modern Vue 3.5 reactive props destructure with defaults instead of withDefaults
- ✅ Stronger literal-typed aria/role attributes via
as constin the computed
pagination — robonen-better-with-gaps
Both libraries ship the identical 8-part pagination (Root, List, ListItem, Ellipsis, First, Last, Prev, Next) with the same ARIA wiring (data-type, aria-label per control, aria-current='page' + data-selected on the active page, type='button' guarded by as==='button', native disabled), the same controlled+uncontrolled v-model:page support, the same scoped slots (Root: page/pageCount; List: items), and the same disabled-gating of clicks. Pagination is a click-driven (not roving-tabindex) widget, so there is no keyboard/focus-trap surface to differ on, and neither has RTL/loop/form-input/portal/positioning concerns. Robonen is genuinely better in a few places: it auto-clamps currentPage to [1, totalPages] via useClamp (reka leaves page stale when total shrinks), returns pre-transformed PaginationItem[] from getRange (reka re-runs transform() each render), gives pageSize a default, and sits on a richer useOffsetPagination composable. I verified the suspected currentPage/page desync is NOT a bug — ref(ref) is idempotent in Vue so they share one ref. The remaining gaps are all minor and DX-oriented: robonen makes total required (reka defaults it to 0 and tests the empty state), robonen has no per-prop JSDoc and no exported emits type (reka documents both), and reka's showEdges windowing algorithm is the well-tested zag implementation with a stronger test matrix than robonen's home-grown variant. No structural/a11y/keyboard gaps; verdict is robonen-better-with-gaps once the four minor items are addressed.
Minor gaps (4):
- ▫️ (api-surface) Reka's
totalprop is optional with a default of 0 (PaginationRoot.vue props line 27 + withDefaultstotal: 0), and the 0-total state is explicitly supported/tested (Pagination.test.ts 'given 0 total value': page 1 shown, all First/Prev/Next/Last disabled). Robonen makestotal: numberREQUIRED (PaginationRootProps line 6) — consumers must always pass it, and there is no defined/tested behavior for an empty collection (total 0 -> totalPages clamps to 1, but this path is not part of the documented API the way reka tests it). - ▫️ (types) Every reka prop carries JSDoc documentation that surfaces in editor IntelliSense (PaginationRoot.vue lines 16-33: page, defaultPage, itemsPerPage, total, siblingCount, disabled, showEdges, plus the PaginationRootEmits update:page doc). Robonen's PaginationRootProps (lines 4-11) and all sub-component prop interfaces have zero JSDoc, so consumers get no hover documentation for total/pageSize/siblingCount/showEdges/disabled/defaultPage.
- ▫️ (api-surface) Reka explicitly exports a named emits type
PaginationRootEmits({ 'update:page': [value] }) from the package index (index.ts line 8), documenting and typing the event for consumers. Robonen relies on defineModel('page') (auto-generates update:page) but exports no named emits type, so there is no public, documented emit type to import. - ▫️ (edge-cases) Reka's showEdges range algorithm (utils.ts getRange lines 27-77) is the zag-derived implementation with three guard conditions per side (default + towards-end + towards-middle checks using itemCount and lastPageIndex math) and is validated by an extensive matrix of presence/absence tests (Pagination.test.ts 'given show-edges' asserting exact pages 2/3/4 present, 8 absent, exactly one ellipsis). Robonen's getRange (utils.ts) is a simpler home-grown variant; its own utils.test.ts only checks coarse invariants (first/last present, ellipsis count >= 1) and does not pin the exact windowing reka guarantees, leaving more risk of off-by-one edge windows for specific (currentPage,totalPages,siblingCount,showEdges) combinations.
Recommendations to make robonen strictly better:
- Make
totaloptional with a default of 0 in PaginationRootProps (mirror reka) and add explicit tests for the empty-collection state (total: 0 -> page 1 displayed, First/Prev/Next/Last all disabled) so the zero-total path is a documented, guaranteed behavior. - Add JSDoc comments to every prop in PaginationRootProps (total, pageSize, siblingCount, showEdges, disabled, defaultPage, page) and to sub-component props (PaginationListItemProps.value, etc.) so consumers get hover documentation parity with reka.
- Export a named emits type (e.g.
export type PaginationRootEmits = { 'update:page': [value: number] }) from index.ts for documentation/typing parity, even though defineModel is used internally. - Harden getRange: port reka's three-condition zag windowing logic (or add an exhaustive snapshot/matrix test over combinations of currentPage x totalPages x siblingCount x showEdges) to guarantee exact window output and eliminate off-by-one risk in the showEdges branch.
- Consider adding ARIA niceties neither library has but which would make robonen strictly better: an optional aria-label on the nav Root (e.g. 'pagination') and aria-disabled mirroring disabled on the -rendered controls (when as='a', the native
disabledattr is inert, so disabled elements remain clickable/focusable — guard with aria-disabled and pointer/keyboard suppression). - Keep pageSize as the public prop name but optionally alias itemsPerPage for drop-in reka migration ergonomics; document the naming difference.
Where robonen is already better:
- ✅ Automatic page clamping: robonen derives currentPage = useClamp(page, 1, totalPages) (PaginationRoot.vue line 53-57 via @robonen/vue useOffsetPagination). If
totalshrinks below the current page, currentPage is automatically clamped into [1, totalPages]. Reka's PaginationRoot.onPageChange just doespage.value = valuewith no clamping (PaginationRoot.vue line 80-82), so aftertotaldecreases reka can hold a stale page (e.g. page 10 when only 2 pages exist) until the consumer fixes it. - ✅ Verified bidirectional sync between the page v-model and the internal currentPage: ref(ref) is idempotent in Vue, so useClamp(page, ...) shares the same underlying ref; both controlled and uncontrolled modes stay consistent. (Confirmed empirically.)
- ✅ Cleaner item pipeline: robonen's getRange returns fully-formed PaginationItem[] objects, so PaginationList passes them straight to the slot (PaginationList.vue line 26-31). Reka's getRange returns raw (number|'ellipsis')[] and PaginationList must call transform() every recompute (reka PaginationList.vue line 26-35) — an extra map pass on every render.
- ✅ Richer underlying composable: robonen's useOffsetPagination exposes next/previous/select and onPageChange/onPageSizeChange/onTotalPagesChange callbacks and currentPageSize, giving a stronger reusable foundation than reka's inline
pageCountcomputed. - ✅ PaginationItemType enum is exported (utils.ts line 1-4) giving consumers named constants ('page'/'ellipsis') instead of bare string literals as in reka.
- ✅ pageSize has a sensible default of 10 (PaginationRoot.vue line 23). Reka's equivalent
itemsPerPageis a required prop with no default.
teleport — robonen-better-with-gaps
Both Teleport primitives are intentionally thin wrappers around Vue's built-in <Teleport>; neither has sub-components, ARIA/a11y wiring, keyboard handling, focus management, forms, or RTL concerns (none of which apply to a portal primitive). robonen is broadly equal-or-better on API surface: it adds a globally configurable default target via useConfig().teleportTarget (reka hardcodes 'body'), exports Portal/PortalProps aliases, declares inheritAttrs: false, allows to: null, and defaults forceMount to true for animation-friendliness with documented SSR reasoning (disabled || !isClient). The only respects where reka is arguably better are SSR/mount-gating defensiveness: reka uses a reactive useMounted() ref and gates the whole render with v-if=\"isMounted || forceMount\", whereas robonen uses a module-level isClient constant (non-reactive, evaluated at import time) and always renders the Teleport node when forceMount is true. Both are minor and not hard bugs, but adopting a reactive mounted hook would make robonen strictly better with no downside. Verdict: robonen-better-with-gaps.
Minor gaps (2):
- ▫️ (ssr) reka uses
useMounted()(a per-instance reactive ref from @vueuse) to gate rendering, so the mounted state is genuinely reactive within the component lifecycle. robonen uses the module-level constantisClientfrom @robonen/platform/multi, which is evaluated once at import time and never becomes reactive. In practice this is fine becauseisClientcannot change, but it means robonen'seffectiveDisableddoes not re-evaluate on the client-mount tick the way reka'sv-ifdoes; if a target is only attached during the parent's onMounted, robonen cannot react to that transition the way reka's mounted-gated render can, and would need the user-facingdeferprop instead. - ▫️ (ssr) When
forceMountis true and disabled is false but the page is still in SSR, robonen always renders the<Teleport>node (v-if="forceMount || !effectiveDisabled", default forceMount=true) relying solely on:disabledto keep it inline. reka's approach of not rendering the Teleport node at all until mounted (when forceMount is unset) more conservatively avoids any chance of Vue attempting target resolution during hydration. This is a defensiveness trade-off rather than a hard bug, but reka's default is the safer of the two for the common (non-forceMount) case.
Recommendations to make robonen strictly better:
- Replace the module-level
isClientconstant with a per-instance reactive mounted hook (e.g.useMounted()from VueUse or an internalconst mounted = ref(false); onMounted(() => mounted.value = true)), then computeeffectiveDisabled = computed(() => disabled || !mounted.value). This matches reka's reactive mounted gating, lets the component react to the client-mount transition, and removes any reliance on import-time evaluation that can be brittle under bundler/SSR edge cases. - Add a unit/integration test asserting SSR-then-hydration produces no mismatch and that content moves from inline to the target on client mount, to lock in the hydration-safe behavior that currently only
isClientprovides implicitly. - Document the interaction between
forceMount: true(default) anddisabledexplicitly in the prop JSDoc — clarify that with forceMount the Teleport node is always rendered and onlydisabledcontrols inline-vs-teleported, so users understand the difference from reka's mount-gated default. - Consider mirroring reka by NOT rendering the
<Teleport>node at all whenforceMountis false AND not yet mounted, for maximum hydration defensiveness in the non-animation case, while keeping forceMount=true as the animation-friendly default. - Optionally expose the underlying behavior consistency by adding a test that
toreactively updates the target (changingtore-resolves the computed target) to guarantee parity with reka's direct:tobinding. - Keep the
Portal/PortalPropsaliases and the configurableteleportTarget— these are real advantages over reka; ensure they are surfaced in docs as differentiators.
Where robonen is already better:
- ✅ Configurable default teleport target: robonen resolves the target via
to ?? config.teleportTarget.valuefromuseConfig()(config-provider/context.ts), so an app can globally redirect all portals (e.g. to a custom root) and override per-instance. reka hardcodes the default to'body'viawithDefaults(..., { to: 'body' })with no global override mechanism. - ✅ Explicit, documented SSR/hydration handling: robonen computes
effectiveDisabled = disabled || !isClient(Teleport.vue:52) and passes it as:disabled, so during SSR children render inline and there is no attempt to teleport to a not-yet-existing target. reka relies onuseMounted()gating the wholev-if, which renders nothing on the server unlessforceMountis set (potential content flash on hydration). - ✅ Ergonomic dual naming: robonen exports both
Teleport/TeleportPrimitivePropsandPortal/PortalPropsaliases (index.ts), matching the Radix/portal mental model. reka only exportsTeleportPrimitive/TeleportProps. - ✅
inheritAttrs: falseis explicitly declared (Teleport.vue:30-32), making the no-DOM-element nature of the wrapper explicit and avoiding any stray attribute fallthrough surprises. reka does not declareinheritAttrs. - ✅
totype explicitly permitsnull(string | HTMLElement | null) with documented fallback to the configured target, giving a clear 'use default' signal. reka'stoisstring | HTMLElementonly. - ✅ forceMount defaults to
trueand is documented as an animation-aware escape hatch (keeps children mounted), which is the more useful default for primitives wrapped in Presence/Transition. reka leavesforceMountundefined (effectively false).
presence — robonen-better-with-gaps
Both libraries implement Presence as a single-component primitive (no sub-parts) that defers unmount until exit animations finish, exposing the same API surface: a present prop, a forceMount prop, a { present } scoped slot, and an exposed present ref. robonen is genuinely better in several respects: it supports CSS transitions (reka only handles CSS animations and will instantly unmount transition-only children), it factors the animation lifecycle into reusable, independently-tested @robonen/platform helpers (getAnimationName/isAnimatable/shouldSuspendUnmount/dispatchAnimationEvent/onAnimationSettle) with passive listeners, and its usePresence has a cleaner signature (MaybeRefOrGetter input, internally-owned node ref, exposed setRef) versus reka forcing the caller to pass an external node Ref. However, reka still leads in a few edge cases: (1) MAJOR — it special-cases the Popper content wrapper (data-reka-popper-content-wrapper -> firstElementChild) so nested popper animations work, which robonen lacks entirely; (2) it throws a descriptive single-child validation error (robonen only warns generically via Slot); (3) it dispatches enter/after-enter lifecycle events on initial mount via immediate watcher (robonen reacts only to changes); (4) it resolves the owner window for its fill-mode timer (iframe/popup correctness); and (5) it explicitly isClient-guards CustomEvent construction. None of these make reka strictly better overall, but the popper-wrapper handling is a real functional gap worth closing. Verdict: robonen-better-with-gaps.
Recommendations to make robonen strictly better:
- Add the data-reka-popper-content-wrapper equivalent to robonen's setRef: when the resolved element carries the popper-wrapper data attribute, track el.firstElementChild instead. This is the only major behavioral gap and matters for any Popper/Tooltip/Menu content that animates a nested element.
- Add explicit single-child validation in Presence.vue (count resolved VNodes via getRawChildren) and throw a descriptive, parent-named error in dev (mirroring reka Presence.ts:59-78) instead of relying on Slot's generic warn — Presence has stricter single-child semantics than a general Slot.
- Make robonen's present watcher dispatch on initial mount (add immediate handling or an onMounted bootstrap) so the 'enter'/'after-enter' lifecycle events fire for an element that is present on first render, matching reka's { immediate: true } behavior.
- Use the element's ownerDocument.defaultView for setTimeout/clearTimeout in onAnimationSettle (capture ownerWindow when the node is attached) so the fill-mode flash-prevention timer is correct inside iframes and detached popup windows.
- Add an explicit client/environment guard (e.g. typeof CustomEvent !== 'undefined' or an isClient check) inside dispatchAnimationEvent so the helper is robust if ever called with a live element in an SSR/edge runtime, and document it like reka does.
- Optional: consider a formal state-machine (mounted/unmountSuspended/unmounted) like reka's useStateMachine to make the unmount-during-enter (animationcancel) edge transitions explicit and self-documenting; robonen's isAnimating boolean + isCurrentAnimation check covers most cases but the state machine is easier to reason about and audit.
- Keep and advertise the transition support advantage (transitionend/transitioncancel + hasTransition) since reka lacks it — ensure it is covered by tests using real CSS transitions, not just mocked animation events.
Where robonen is already better:
- ✅ Transition support beyond CSS animations (reka handles animations only)
- ✅ Reusable, separately-tested platform animationLifecycle helpers
- ✅ Passive event listeners
- ✅ More ergonomic usePresence API (MaybeRefOrGetter input, owns node ref, exposes setRef)
- ✅ Robust child extraction via getRawChildren with keyed-fragment BAIL handling
switch — robonen-better-with-gaps
Parts reka has that robonen lacks: SwitchThumb (separate part with own data-state/data-disabled)
robonen's Switch is a single monolithic generic component that is genuinely stronger than reka on core interaction correctness: it uses Object.is+toRaw identity comparison, fully supports Space+Enter activation plus tabindex and aria-disabled synthesis for non-button hosts (reka does neither Space nor focusability for non-button hosts), guards against double-toggle on native buttons, and serializes complex truthy/falsy values into the hidden form input. However, reka is better on composition and form/a11y polish: it ships a dedicated SwitchThumb part exposing its own data-state/data-disabled (essential for thumb animations), supports asChild on both parts, gates the hidden input on actual form membership via useFormControl (with SSR-safe default), dispatches native input/change events on the hidden input so form libraries react, auto-derives aria-label from an associated
Recommendations to make robonen strictly better:
- Add a SwitchThumb (or SwitchIndicator) subcomponent that injects switch context and renders Primitive with its own data-state (checked/unchecked) and data-disabled attributes, mirroring reka SwitchThumb. Provide a context.ts exposing { checked: ComputedRef, disabled: Ref } and refactor Switch into SwitchRoot. This unlocks data-[state=checked] thumb animation styling, the single most common switch UI pattern.
- Add asChild support to robonen's PrimitiveProps (or at minimum to the Switch) so the switch can merge onto a consumer-provided element while preserving all ARIA/data attrs, matching reka's polymorphism. If asChild is intentionally omitted repo-wide, document the as='template' equivalent in SwitchProps.
- Only render the hidden form input when actually inside a : add a useFormControl-style composable (closest('form'), defaulting true for SSR) and gate the input with
v-if=isFormControl && nameto avoid stray DOM nodes when used outside forms. - Dispatch native input and change events on the hidden input when the value changes (via the HTMLInputElement.prototype value setter), so third-party form libraries and direct change listeners on the hidden input react to programmatic toggles, matching reka's VisuallyHiddenInputBubble behavior.
- Add accessible-name support: accept an
idprop and derive aria-label from an associated - Expose a typed emits contract for update:modelValue and consider modeling modelValue as T | null to document the controlled/uncontrolled contract; add a dedicated
valueprop (or document that truthy/falsy serialization is the form value) so the form-submission value is explicit. - Keep robonen's superior non-button keyboard/tabindex/aria-disabled synthesis and Object.is identity semantics — these are genuine wins over reka; ensure the new SwitchRoot/SwitchThumb refactor preserves them.
Where robonen is already better:
- ✅ Object.is + toRaw identity comparison for checked state (more correct than reka's === for proxies/NaN/-0)
- ✅ Space + Enter activation and tabindex/aria-disabled synthesis for non-button hosts (reka handles neither Space nor focusability/disabled for non-button hosts)
- ✅ Guards against double-toggle on native buttons by only synthesizing keyboard handling when as != 'button'
- ✅ Hidden input value serialization for numbers/booleans/objects (reka submits an unrelated static
valuestring for custom state types) - ✅ Fewer external dependencies for core behavior; no document.querySelector during setup (better SSR/hydration robustness)