Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions app/components/RouteTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,26 @@ const selectTab = (e: React.KeyboardEvent<HTMLDivElement>) => {
// Don't intercept modified arrow keys (Cmd for browser back/forward, etc.)
if (hasModifier(e)) return

const target = e.target as HTMLDivElement
if (e.key === KEYS.left) {
e.stopPropagation()
e.preventDefault()
const target = e.target as HTMLElement
const isArrow = e.key === KEYS.left || e.key === KEYS.right
if (!isArrow) return

const sibling = (target.previousSibling ??
target.parentElement!.lastChild!) as HTMLDivElement

sibling.focus()
sibling.click()
} else if (e.key === KEYS.right) {
e.stopPropagation()
e.preventDefault()
// Walk only role=tab siblings so non-tab children (e.g. the hover pill) are skipped
const tabs = Array.from(
target.parentElement!.querySelectorAll<HTMLElement>('[role="tab"]')
)
const i = tabs.indexOf(target)
if (i === -1) return

const sibling = (target.nextSibling ??
target.parentElement!.firstChild!) as HTMLDivElement
e.stopPropagation()
e.preventDefault()

sibling.focus()
sibling.click()
}
const next =
e.key === KEYS.left
? tabs[(i - 1 + tabs.length) % tabs.length]
: tabs[(i + 1) % tabs.length]
next.focus()
next.click()
}

export interface RouteTabsProps {
Expand Down Expand Up @@ -66,6 +66,7 @@ export function RouteTabs({
className={cn(sideTabs ? 'ox-side-tabs-list' : 'ox-tabs-list', tabListClassName)}
onKeyDown={selectTab}
>
{!sideTabs && <span className="ox-tab-pill" aria-hidden="true" />}
{children}
</div>

Expand Down
7 changes: 5 additions & 2 deletions app/ui/lib/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ export const Tabs = {
<div>{children}</div>
</BaseTabs.Tab>
),
List: ({ className, ...props }: BaseTabs.List.Props) => (
<BaseTabs.List {...props} className={cn('ox-tabs-list', className)} />
List: ({ className, children, ...props }: BaseTabs.List.Props) => (
<BaseTabs.List {...props} className={cn('ox-tabs-list', className)}>
<span className="ox-tab-pill" aria-hidden="true" />
{children}
</BaseTabs.List>
),
Content: ({ className, ...props }: BaseTabs.Panel.Props) => (
<BaseTabs.Panel {...props} className={cn('ox-tabs-panel', className)} />
Expand Down
48 changes: 42 additions & 6 deletions app/ui/styles/components/Tabs.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,54 @@

/* Tab list container styles */
.ox-tabs-list {
@apply mb-8 flex bg-transparent;
@apply relative mb-8 flex bg-transparent;
}

.ox-tabs-list:after {
@apply border-secondary block w-full border-b;
content: '';
}

/* Animated hover/focus pill — follows the active tab via CSS anchor positioning.
Falls back to a per-tab static bg in browsers without anchor support. */
.ox-tab-pill {
position: absolute;
position-anchor: --hover-tab;
left: anchor(left);
top: anchor(top);
width: anchor-size(width);
height: anchor-size(height);
@apply bg-hover rounded-md;
opacity: 0;
pointer-events: none;
transition:
/* On-screen morph between tabs → ease-in-out */
left 200ms cubic-bezier(0.645, 0.045, 0.355, 1),
top 200ms cubic-bezier(0.645, 0.045, 0.355, 1),
width 200ms cubic-bezier(0.645, 0.045, 0.355, 1),
height 200ms cubic-bezier(0.645, 0.045, 0.355, 1),
/* Color swap when sliding onto/off the active tab */ background-color 150ms ease,
/* Enter/exit fade → ease-out */ opacity 120ms cubic-bezier(0.215, 0.61, 0.355, 1);
}

@media (prefers-reduced-motion: reduce) {
.ox-tab-pill {
transition: none;
}
}

.ox-tabs-list:has(.ox-tab:is(:hover, :focus-visible)) .ox-tab-pill {
opacity: 1;
}

.ox-tabs-list:has(.ox-tab[data-active]:is(:hover, :focus-visible)) .ox-tab-pill {
@apply bg-accent-hover;
}

.ox-tabs-list .ox-tab:is(:hover, :focus-visible) > * {
anchor-name: --hover-tab;
}

/* Panel styles */
.ox-tabs-panel:focus-visible {
@apply outline-accent-secondary outline-2 outline-offset-[1rem];
Expand All @@ -28,11 +68,7 @@

.ox-tabs-list .ox-tab > * {
@apply rounded-md bg-transparent px-1.5 py-1;
}

/* Hover states */
.ox-tabs-list .ox-tab:hover > * {
@apply bg-hover;
position: relative; /* sit above the pill */
}

/* Active states */
Expand Down
Loading