Skip to content

Commit bbfce35

Browse files
jvorcakclaude
andcommitted
chore(redpanda-ui): bump dialog + tabs to registry 2.3.0
Console's dialog and tabs were pinned to registry 2.3.0's predecessor (2.2.0), which the frontend UI audit flags as outdated once the consumer pages use them. Sync both to the 2.3.0 release: - dialog: scroll-shadow refactor — useScrollShadow now takes the container ref and returns axis-relative { start, end } edges (was sentinel refs + { top, bottom }). - use-scroll-shadow: rewritten to the 2.3.0 geometry-based API (scroll + ResizeObserver/MutationObserver, horizontal orientation support). Only caller in console is DialogBody; rp-connect only mentions it in a comment. - tabs: add ScrollableTabsList, pulling in the new drag-scroll-area dep. - drag-scroll-area: new component from 2.3.0. Audit now reports dialog + tabs up-to-date at 2.3.0; 0 drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9398543 commit bbfce35

4 files changed

Lines changed: 271 additions & 70 deletions

File tree

frontend/src/components/redpanda-ui/components/dialog.tsx

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -262,13 +262,17 @@ const dialogBodyContentVariants = cva('', {
262262
},
263263
});
264264

265+
const dialogScrollShadow = 'pointer-events-none sticky z-10 h-0 transition-opacity duration-150';
266+
const dialogScrollShadowState = (visible: boolean) => (visible ? 'opacity-100' : 'opacity-0');
267+
265268
interface DialogBodyProps extends React.ComponentProps<'div'>, VariantProps<typeof dialogBodyContentVariants> {
266269
/** Show fading top/bottom shadows when the body overflows. Defaults to `true`. */
267270
scrollShadow?: boolean;
268271
}
269272

270273
function DialogBody({ className, padding, spacing, scrollShadow = true, children, style, ...props }: DialogBodyProps) {
271-
const { containerRef, topRef, bottomRef, edges } = useScrollShadow<HTMLDivElement>(scrollShadow);
274+
const containerRef = React.useRef<HTMLDivElement>(null);
275+
const edges = useScrollShadow(containerRef, scrollShadow);
272276

273277
return (
274278
<div
@@ -279,33 +283,15 @@ function DialogBody({ className, padding, spacing, scrollShadow = true, children
279283
{...props}
280284
>
281285
{scrollShadow ? (
282-
<>
283-
<div aria-hidden className="h-px shrink-0" ref={topRef} />
284-
<div
285-
aria-hidden
286-
className={cn(
287-
'pointer-events-none sticky top-0 z-10 h-0 transition-opacity duration-150',
288-
edges.top ? 'opacity-100' : 'opacity-0'
289-
)}
290-
>
291-
<div className="absolute inset-x-0 top-0 h-3 bg-gradient-to-b from-black/[0.10] to-transparent" />
292-
</div>
293-
</>
286+
<div aria-hidden className={cn(dialogScrollShadow, 'top-0', dialogScrollShadowState(edges.start))}>
287+
<div className="absolute inset-x-0 top-0 h-3 bg-gradient-to-b from-black/[0.10] to-transparent" />
288+
</div>
294289
) : null}
295290
<div className={cn(dialogBodyContentVariants({ padding, spacing }))}>{children}</div>
296291
{scrollShadow ? (
297-
<>
298-
<div
299-
aria-hidden
300-
className={cn(
301-
'pointer-events-none sticky bottom-0 z-10 h-0 transition-opacity duration-150',
302-
edges.bottom ? 'opacity-100' : 'opacity-0'
303-
)}
304-
>
305-
<div className="absolute inset-x-0 bottom-0 h-3 bg-gradient-to-t from-black/[0.10] to-transparent" />
306-
</div>
307-
<div aria-hidden className="h-px shrink-0" ref={bottomRef} />
308-
</>
292+
<div aria-hidden className={cn(dialogScrollShadow, 'bottom-0', dialogScrollShadowState(edges.end))}>
293+
<div className="absolute inset-x-0 bottom-0 h-3 bg-gradient-to-t from-black/[0.10] to-transparent" />
294+
</div>
309295
) : null}
310296
</div>
311297
);
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
'use client';
2+
3+
import React from 'react';
4+
5+
import { useScrollShadow } from '../lib/use-scroll-shadow';
6+
import { cn, type SharedProps } from '../lib/utils';
7+
8+
// Drag past this many px counts as a scroll, not a click.
9+
const DRAG_THRESHOLD = 4;
10+
11+
// Default width of the edge fade, also the inset a focused child is scrolled clear of.
12+
const DEFAULT_FADE_SIZE = 32;
13+
14+
// Manual horizontal scroll (not `scrollIntoView`) so the page's vertical scroll isn't disturbed.
15+
function scrollChildIntoView(scroller: HTMLDivElement, child: HTMLElement, fadeSize: number) {
16+
const view = scroller.getBoundingClientRect();
17+
const rect = child.getBoundingClientRect();
18+
// Inset by the fade so the child clears the gradient, not just the frame edge.
19+
if (rect.left < view.left + fadeSize) {
20+
scroller.scrollLeft -= view.left + fadeSize - rect.left;
21+
} else if (rect.right > view.right - fadeSize) {
22+
scroller.scrollLeft += rect.right - (view.right - fadeSize);
23+
}
24+
}
25+
26+
// Scroll any focused descendant into view via `focusin` — agnostic to the children, so roving
27+
// keyboard focus, toolbars, and chip rows all work.
28+
function useFocusScroll(ref: React.RefObject<HTMLDivElement | null>, fadeSize: number) {
29+
React.useEffect(() => {
30+
const el = ref.current;
31+
if (!el) {
32+
return;
33+
}
34+
const onFocusIn = (e: FocusEvent) => {
35+
const target = e.target as HTMLElement | null;
36+
if (target && el.contains(target)) {
37+
scrollChildIntoView(el, target, fadeSize);
38+
}
39+
};
40+
el.addEventListener('focusin', onFocusIn);
41+
return () => el.removeEventListener('focusin', onFocusIn);
42+
}, [ref, fadeSize]);
43+
}
44+
45+
// Click-and-drag horizontal scrolling; a real drag swallows the trailing click.
46+
function useDragScroll(ref: React.RefObject<HTMLDivElement | null>) {
47+
const drag = React.useRef<{ x: number; left: number } | null>(null);
48+
const dragged = React.useRef(false);
49+
50+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
51+
const el = ref.current;
52+
if (!el || e.button !== 0) {
53+
return;
54+
}
55+
drag.current = { x: e.clientX, left: el.scrollLeft };
56+
dragged.current = false;
57+
};
58+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
59+
const el = ref.current;
60+
const start = drag.current;
61+
if (!(el && start)) {
62+
return;
63+
}
64+
const dx = e.clientX - start.x;
65+
// Stay a click until the pointer crosses the threshold; only then capture and scroll, so a
66+
// sub-threshold wobble never nudges the strip.
67+
if (!dragged.current && Math.abs(dx) <= DRAG_THRESHOLD) {
68+
return;
69+
}
70+
dragged.current = true;
71+
if (typeof el.setPointerCapture === 'function' && !el.hasPointerCapture(e.pointerId)) {
72+
el.setPointerCapture(e.pointerId);
73+
}
74+
el.scrollLeft = start.left - dx;
75+
};
76+
const onPointerEnd = () => {
77+
drag.current = null;
78+
};
79+
const onClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
80+
// Swallow only the pointer-driven click that ends a real drag. Keyboard activation (Enter/Space)
81+
// produces a `detail === 0` click, so it always passes — even if a prior drag left `dragged` set.
82+
if (dragged.current && e.detail > 0) {
83+
e.preventDefault();
84+
e.stopPropagation();
85+
}
86+
dragged.current = false;
87+
};
88+
return { onPointerDown, onPointerMove, onPointerEnd, onClickCapture };
89+
}
90+
91+
type MaskStyle = Pick<React.CSSProperties, 'maskImage' | 'WebkitMaskImage' | 'maskComposite' | 'WebkitMaskComposite'>;
92+
93+
// Alpha mask fading the content to transparent on the overflowing side(s) — no track-color
94+
// knowledge needed, so it blends on any background. Snaps when an edge flips (reduced-motion-safe).
95+
// `preserveBottomEdge` keeps the bottom N px opaque (e.g. an underline baseline) by unioning a
96+
// second layer with `mask-composite: add` (legacy WebKit spells `add` as `source-over`).
97+
function edgeMask(start: boolean, end: boolean, fadeSize: number, preserveBottomEdge: number): MaskStyle {
98+
if (!(start || end)) {
99+
return {};
100+
}
101+
const left = start ? `transparent 0, #000 ${fadeSize}px` : '#000 0';
102+
const right = end ? `#000 calc(100% - ${fadeSize}px), transparent 100%` : '#000 100%';
103+
const horizontal = `linear-gradient(to right, ${left}, ${right})`;
104+
105+
if (!preserveBottomEdge) {
106+
return { maskImage: horizontal, WebkitMaskImage: horizontal };
107+
}
108+
const bottom = `linear-gradient(to top, #000 ${preserveBottomEdge}px, transparent ${preserveBottomEdge}px)`;
109+
const image = `${horizontal}, ${bottom}`;
110+
return { maskImage: image, WebkitMaskImage: image, maskComposite: 'add', WebkitMaskComposite: 'source-over' };
111+
}
112+
113+
type DragScrollAreaProps = React.ComponentProps<'div'> &
114+
SharedProps & {
115+
// Width of the edge fade in px, and the inset a focused child is scrolled clear of.
116+
fadeSize?: number;
117+
// Keep the bottom N px fully opaque, exempt from the edge fade (e.g. an underline baseline).
118+
preserveBottomEdge?: number;
119+
};
120+
121+
/**
122+
* Makes an overflowing horizontal strip drag-scrollable, with alpha edge fades and
123+
* keyboard-focus-into-view. Wrap any single-row content that can overflow its container
124+
* (tab strips, toolbars, chip rows). Horizontal only by design.
125+
*/
126+
function DragScrollArea({
127+
className,
128+
children,
129+
fadeSize = DEFAULT_FADE_SIZE,
130+
preserveBottomEdge = 0,
131+
testId,
132+
style,
133+
...props
134+
}: DragScrollAreaProps) {
135+
const scrollerRef = React.useRef<HTMLDivElement | null>(null);
136+
const edges = useScrollShadow(scrollerRef, true, 'horizontal');
137+
const drag = useDragScroll(scrollerRef);
138+
useFocusScroll(scrollerRef, fadeSize);
139+
140+
const mask = edgeMask(edges.start, edges.end, fadeSize, preserveBottomEdge);
141+
const overflowing = edges.start || edges.end;
142+
143+
return (
144+
// `{...props}` first so the drag handlers, ref, and mask can't be clobbered by a consumer.
145+
<div
146+
{...props}
147+
className={cn(
148+
// `min-w-0` lets the area shrink below its content (and thus overflow) inside flex parents.
149+
'min-w-0 overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
150+
overflowing && 'cursor-grab active:cursor-grabbing',
151+
className
152+
)}
153+
data-slot="drag-scroll-area"
154+
data-testid={testId}
155+
onClickCapture={drag.onClickCapture}
156+
onPointerDown={drag.onPointerDown}
157+
onPointerLeave={drag.onPointerEnd}
158+
onPointerMove={drag.onPointerMove}
159+
onPointerUp={drag.onPointerEnd}
160+
ref={scrollerRef}
161+
style={{ ...mask, ...style }}
162+
>
163+
{children}
164+
</div>
165+
);
166+
}
167+
168+
export { DragScrollArea, type DragScrollAreaProps };

frontend/src/components/redpanda-ui/components/tabs.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import { Tabs as TabsPrimitive } from '@base-ui/react/tabs';
44
import { cva, type VariantProps } from 'class-variance-authority';
5-
import type React from 'react';
5+
import React from 'react';
66

7+
import { DragScrollArea } from './drag-scroll-area';
78
import { cn, type SharedProps } from '../lib/utils';
89

910
const tabsVariants = cva('flex data-[orientation=horizontal]:flex-col', {
@@ -100,6 +101,8 @@ function TabsList({
100101
style,
101102
...props
102103
}: TabsListProps) {
104+
// To make an overflowing strip drag-scrollable, wrap <TabsList> in <DragScrollArea> and add
105+
// `w-max min-w-full` to size the list to its content. See the scrollable tabs demos.
103106
return (
104107
<TabsPrimitive.List
105108
className={cn('relative', tabsListVariants({ variant, layout, gap }), className)}
@@ -126,6 +129,36 @@ function TabsList({
126129
);
127130
}
128131

132+
type ScrollableTabsListProps = TabsListProps & {
133+
// Classes for the scroll viewport (the DragScrollArea wrapper) — `className`/`style` go to the
134+
// list itself, so use this to size or constrain the viewport, e.g. `scrollAreaClassName="max-w-sm"`.
135+
scrollAreaClassName?: string;
136+
// Width of the edge fade in px (forwarded to DragScrollArea).
137+
fadeSize?: number;
138+
};
139+
140+
// Keep the bottom 4px opaque through the edge fade so the baseline border and active indicator
141+
// (`after:-bottom-px after:h-0.5`, ~3px tall) stay crisp; still well below the label baseline.
142+
const UNDERLINE_OPAQUE_PX = 4;
143+
144+
/**
145+
* `TabsList` pre-composed with `DragScrollArea` for overflowing strips: handles the content sizing
146+
* (`w-max min-w-full`) and, for `underline`, the wrapper padding and crisp baseline. Drop in for
147+
* `TabsList` when the strip can overflow.
148+
*/
149+
function ScrollableTabsList({ className, scrollAreaClassName, fadeSize, variant, ...props }: ScrollableTabsListProps) {
150+
const underline = variant === 'underline';
151+
return (
152+
<DragScrollArea
153+
className={cn(underline && 'pb-px', scrollAreaClassName)}
154+
fadeSize={fadeSize}
155+
preserveBottomEdge={underline ? UNDERLINE_OPAQUE_PX : 0}
156+
>
157+
<TabsList className={cn('w-max min-w-full', className)} variant={variant} {...props} />
158+
</DragScrollArea>
159+
);
160+
}
161+
129162
const tabsTriggerVariants = cva(
130163
// Base UI uses `aria-disabled`/`data-disabled` (not native `disabled`) and `data-active`, so target those.
131164
'z-[1] inline-flex size-full cursor-pointer items-center justify-center whitespace-nowrap rounded-sm font-medium text-sm ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-[active]:text-foreground',
@@ -195,11 +228,13 @@ function TabsContents({ children, className, ...props }: TabsContentsProps) {
195228
export {
196229
Tabs,
197230
TabsList,
231+
ScrollableTabsList,
198232
TabsTrigger,
199233
TabsContent,
200234
TabsContents,
201235
type TabsProps,
202236
type TabsListProps,
237+
type ScrollableTabsListProps,
203238
type TabsTriggerProps,
204239
type TabsContentProps,
205240
type TabsContentsProps,

0 commit comments

Comments
 (0)