Skip to content

Commit 8eee174

Browse files
os-zhuangclaude
andauthored
fix(components): a stacked resizable group gets a divider, not a 1px sliver (#3024)
`ui/resizable.tsx` is the react-resizable-panels **v3** Shadcn file: every stacked-group style on its handle is keyed on `data-[panel-group-direction=vertical]`. v4 — the installed major (4.12.2) — never emits that attribute; the only ones it writes are `data-group`, `data-panel`, `data-separator`, `data-disabled` and `data-testid`. So all seven variants plus `[&[data-panel-group-direction=vertical]>div]:rotate-90` were dead selectors, and a stacked divider kept the base `w-px`. Measured in a browser before the fix: the divider rendered 1px wide × 16px tall — its whole height coming from the grip — with a 4×16px `::after` drag target pinned to the group's left edge and an unrotated grip. Off the grip, the separator was not the hit target anywhere along the split, so 99% of the divider could not be grabbed. `ui/**` is a no-touch Shadcn-sync zone (AGENTS.md #7), so the fix is a wrapper in `custom/resizable.tsx` that re-keys those styles onto `aria-orientation`, which v4 *does* emit on the separator — the same attribute upstream Shadcn moved to when it regenerated this component for v4. The value reads backwards, deliberately: `aria-orientation` describes the separator, not the group, so an `orientation="vertical"` group (stacked panels) has an `aria-orientation="horizontal"` divider. `ui/index.ts` no longer re-exports `./resizable`; `custom/` owns the public `Resizable*` names, so no consumer can reach the unpatched handle through `@object-ui/components` (and the two barrels cannot collide on TS2308). The public export surface is unchanged. After, in a browser: 478×1px rule, 478×4px hit strip, grip at `rotate: 90deg`, and a real off-grip drag moves the split 50 → 75.21. Side-by-side is untouched (1×238px, unrotated grip). Affected surfaces: `FormSchema.fieldPanes` with `fieldPanesOrientation: 'vertical'` (#2153) and the SDUI `resizable` renderer with `direction: 'vertical'`. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 4545380 commit 8eee174

8 files changed

Lines changed: 251 additions & 8 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The stacked resizable divider — `custom/resizable.tsx`.
11+
*
12+
* `ui/resizable.tsx` is the react-resizable-panels **v3** Shadcn file: its
13+
* whole stacked-group appearance hangs off `data-[panel-group-direction=vertical]`,
14+
* an attribute v4 (the installed major) never emits. So in a stacked group the
15+
* divider kept the base `w-px` and came out a 1px-wide, 16px-tall sliver — its
16+
* only height being the grip — instead of a full-width hairline, with a 4x16px
17+
* drag target pinned to the group's left edge and an unrotated grip.
18+
* `custom/resizable.tsx` re-keys those styles onto `aria-orientation`, which v4
19+
* does emit on the separator.
20+
*
21+
* These tests pin the *class contract* and the attribute it depends on; happy-dom
22+
* has no Tailwind, so resolved geometry (478x1px rule, 478x4px hit strip, grip at
23+
* `rotate: 90deg`, and a real off-grip drag moving the split 50 → 75.21) is
24+
* verified in a browser instead — see the PR.
25+
*
26+
* The load-bearing case is the one that reads backwards: `aria-orientation`
27+
* describes the SEPARATOR, so a `orientation="vertical"` group (stacked panels)
28+
* has a `aria-orientation="horizontal"` divider. Keying the stacked styles on
29+
* `aria-[orientation=vertical]` would be the easy, silent way to reintroduce
30+
* exactly this bug — hence the first test.
31+
*/
32+
33+
import { describe, it, expect, afterEach } from 'vitest';
34+
import { render, cleanup } from '@testing-library/react';
35+
36+
import {
37+
ResizableHandle,
38+
ResizablePanel,
39+
ResizablePanelGroup,
40+
} from '../custom/resizable';
41+
42+
afterEach(cleanup);
43+
44+
function renderGroup(orientation: 'horizontal' | 'vertical') {
45+
const { container } = render(
46+
<ResizablePanelGroup orientation={orientation}>
47+
<ResizablePanel defaultSize={50}>one</ResizablePanel>
48+
<ResizableHandle withHandle />
49+
<ResizablePanel defaultSize={50}>two</ResizablePanel>
50+
</ResizablePanelGroup>,
51+
);
52+
const separator = container.querySelector<HTMLElement>('[role="separator"]');
53+
if (!separator) throw new Error('no separator rendered');
54+
return { container, separator };
55+
}
56+
57+
/**
58+
* Every `aria-[name=value]:`-prefixed utility on the element, as the
59+
* [attribute, value] pair the variant actually selects on — Tailwind's `aria-[…]`
60+
* variant prepends `aria-` to the bracketed name, so `aria-[orientation=horizontal]:`
61+
* compiles to `&[aria-orientation="horizontal"]`.
62+
*/
63+
function ariaVariantsOn(el: HTMLElement): Array<[string, string]> {
64+
return [...el.classList].flatMap((cls) => {
65+
const m = /(?:^|:)aria-\[([a-z-]+)=([^\]]+)\]:/.exec(cls);
66+
return m ? [[`aria-${m[1]}`, m[2]] as [string, string]] : [];
67+
});
68+
}
69+
70+
describe('custom/resizable — the stacked divider (react-resizable-panels v4)', () => {
71+
it('gives the stacked divider aria-orientation=horizontal, not vertical', () => {
72+
// The inversion that makes this bug easy to reintroduce: the group stacks
73+
// vertically, so the rule between the panels runs horizontally.
74+
expect(renderGroup('vertical').separator.getAttribute('aria-orientation')).toBe('horizontal');
75+
expect(renderGroup('horizontal').separator.getAttribute('aria-orientation')).toBe('vertical');
76+
});
77+
78+
it('keys the stacked divider on that attribute, so a hairline replaces the sliver', () => {
79+
const { separator } = renderGroup('vertical');
80+
const classes = [...separator.classList];
81+
82+
// The rule: full-width, 1px tall — overriding the base `w-px` that made it a
83+
// vertical sliver in a stacked group.
84+
expect(classes).toContain('aria-[orientation=horizontal]:h-px');
85+
expect(classes).toContain('aria-[orientation=horizontal]:w-full');
86+
// The `::after` drag target: a strip ALONG the rule, not across it.
87+
expect(classes).toContain('aria-[orientation=horizontal]:after:h-1');
88+
expect(classes).toContain('aria-[orientation=horizontal]:after:w-full');
89+
expect(classes).toContain('aria-[orientation=horizontal]:after:left-0');
90+
expect(classes).toContain('aria-[orientation=horizontal]:after:translate-x-0');
91+
expect(classes).toContain('aria-[orientation=horizontal]:after:-translate-y-1/2');
92+
// The grip crosses the rule only when rotated.
93+
expect(classes).toContain('[&[aria-orientation=horizontal]>div]:rotate-90');
94+
});
95+
96+
it('leaves the side-by-side divider on the base classes', () => {
97+
const { separator } = renderGroup('horizontal');
98+
// Same class string either way — nothing above matches, so the base
99+
// `w-px` / `after:left-1/2` / `after:-translate-x-1/2` styling stands and
100+
// the grip is not rotated. This is the case that already worked; it must
101+
// keep working.
102+
expect(separator.getAttribute('aria-orientation')).toBe('vertical');
103+
for (const [attr, value] of ariaVariantsOn(separator)) {
104+
expect(separator.matches(`[${attr}="${value}"]`)).toBe(false);
105+
}
106+
});
107+
108+
it('only keys on attributes the installed library actually emits', () => {
109+
// The guard that would have caught the original bug, and catches the next
110+
// rename: every `aria-[…]` variant the wrapper adds must match the real DOM
111+
// in the orientation it targets. A variant naming an attribute/value the
112+
// library never writes is a dead selector — which is exactly what
113+
// `data-[panel-group-direction=vertical]` became when v4 landed.
114+
const { separator } = renderGroup('vertical');
115+
const variants = ariaVariantsOn(separator);
116+
117+
expect(variants.length).toBeGreaterThan(0);
118+
for (const [attr, value] of variants) {
119+
expect(
120+
separator.matches(`[${attr}="${value}"]`),
121+
`dead selector: no [${attr}="${value}"] on the rendered separator`,
122+
).toBe(true);
123+
}
124+
});
125+
126+
it('documents why the wrapper exists: v4 emits no panel-group-direction', () => {
127+
// If this ever fails, the library started emitting the v3 attribute again
128+
// and `ui/resizable.tsx`'s own variants would carry the stacked case — at
129+
// which point this wrapper is redundant rather than load-bearing.
130+
const { container } = renderGroup('vertical');
131+
expect(container.querySelectorAll('[data-panel-group-direction]')).toHaveLength(0);
132+
});
133+
});

packages/components/src/custom/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './item';
1212
export * from './kbd';
1313
export * from './native-select';
1414
export * from './navigation-overlay';
15+
export * from './resizable';
1516
export * from './section-header';
1617
export * from './spinner';
1718
export * from './sort-builder';

packages/components/src/custom/navigation-overlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import {
6464
ResizablePanelGroup,
6565
ResizablePanel,
6666
ResizableHandle,
67-
} from '../ui/resizable';
67+
} from './resizable';
6868
import { usePopperAwareInteractOutside } from './mobile-dialog-content';
6969

7070
/** Navigation mode type — matches ViewNavigationConfig.mode */
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
'use client';
10+
11+
import * as React from 'react';
12+
13+
import { cn } from '../lib/utils';
14+
import {
15+
ResizableHandle as ShadcnResizableHandle,
16+
ResizablePanel,
17+
ResizablePanelGroup,
18+
} from '../ui/resizable';
19+
20+
/**
21+
* Orientation-correct `ResizableHandle`.
22+
*
23+
* `ui/resizable.tsx` (a no-touch Shadcn-sync file, AGENTS.md #7) is from the
24+
* `react-resizable-panels` **v3** generation: every one of its stacked-group
25+
* styles is keyed on `data-[panel-group-direction=vertical]`. v4 — the
26+
* installed major — never emits that attribute. The only data attributes it
27+
* puts in the DOM are `data-group`, `data-panel`, `data-separator`,
28+
* `data-disabled` and `data-testid`, so all seven variants plus the grip's
29+
* `[&[data-panel-group-direction=vertical]>div]:rotate-90` are dead selectors.
30+
*
31+
* The visible consequence: in a **stacked** group the divider kept the base
32+
* `w-px` and rendered as a 1px-wide, 16px-tall sliver (its whole height came
33+
* from the grip) instead of a full-width 1px-tall rule, and the grip never
34+
* rotated. Measured in a browser before this wrapper existed: 1×16px, with a
35+
* 4×16px `::after` hit area pinned to the group's left edge.
36+
*
37+
* What v4 *does* emit on the separator is `aria-orientation`, so that is what
38+
* these variants key on — matching upstream Shadcn, which has since been
39+
* regenerated for v4 the same way.
40+
*
41+
* ⚠️ The attribute value is the **inverse** of the group's `orientation`, and
42+
* deliberately so: `aria-orientation` describes the separator, not the group.
43+
* A `orientation="vertical"` group stacks its panels, so the divider between
44+
* them is a *horizontal* line — hence `aria-orientation="horizontal"`.
45+
*
46+
* group orientation="horizontal" → panels side-by-side → separator aria-orientation="vertical" (base classes)
47+
* group orientation="vertical" → panels stacked → separator aria-orientation="horizontal" (these classes)
48+
*
49+
* Each variant below is the live counterpart of a dead `data-[…]` one still
50+
* carried by the base class string. They win on specificity: an attribute
51+
* selector scores above the bare utility class it overrides, which is the same
52+
* mechanism upstream relies on within its single file.
53+
*/
54+
const HORIZONTAL_SEPARATOR_CLASSES = [
55+
// The rule itself: a full-width hairline instead of a 1px-wide sliver.
56+
'aria-[orientation=horizontal]:h-px',
57+
'aria-[orientation=horizontal]:w-full',
58+
// The `::after` drag hit area: a 4px strip along the rule, not across it.
59+
'aria-[orientation=horizontal]:after:left-0',
60+
'aria-[orientation=horizontal]:after:h-1',
61+
'aria-[orientation=horizontal]:after:w-full',
62+
'aria-[orientation=horizontal]:after:translate-x-0',
63+
'aria-[orientation=horizontal]:after:-translate-y-1/2',
64+
// The grip glyph reads as a drag affordance only when it crosses the rule.
65+
'[&[aria-orientation=horizontal]>div]:rotate-90',
66+
].join(' ');
67+
68+
export type ResizableHandleProps = React.ComponentProps<typeof ShadcnResizableHandle>;
69+
70+
const ResizableHandle = ({ className, ...props }: ResizableHandleProps) => (
71+
<ShadcnResizableHandle className={cn(HORIZONTAL_SEPARATOR_CLASSES, className)} {...props} />
72+
);
73+
74+
/**
75+
* `ResizablePanelGroup` and `ResizablePanel` need no wrapping and are
76+
* re-exported as-is: the group's `flex-direction` comes from an inline style
77+
* the library writes itself (`row` / `column` from `orientation`), so its own
78+
* dead `data-[panel-group-direction=vertical]:flex-col` is inert but harmless.
79+
* They pass through here purely so callers have one import site for the trio.
80+
*
81+
* These are the *only* public `Resizable*` exports — `ui/index.ts` deliberately
82+
* does not re-export `./resizable`, so no consumer can reach the unpatched
83+
* handle through `@object-ui/components` and silently lose the stacked case.
84+
*/
85+
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };

packages/components/src/renderers/complex/resizable.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
import React from 'react';
1010
import { ComponentRegistry } from '@object-ui/core';
1111
import type { ResizableSchema } from '@object-ui/types';
12-
import {
13-
ResizablePanelGroup,
14-
ResizablePanel,
15-
ResizableHandle
16-
} from '../../ui';
12+
import {
13+
ResizablePanelGroup,
14+
ResizablePanel,
15+
ResizableHandle
16+
} from '../../custom/resizable';
1717
import { renderChildren } from '../../lib/utils';
1818

1919
ComponentRegistry.register('resizable',

packages/components/src/renderers/form/__tests__/form-field-panes.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,24 @@ describe('FormSchema.fieldPanes — one form across the split (#2153)', () => {
214214
const separator = container.querySelector('[role="separator"]')!;
215215
expect(separator.getAttribute('aria-disabled')).not.toBe('true');
216216
});
217+
218+
it('gives a vertical split a full-width divider, not a 1px sliver', () => {
219+
// `fieldPanesOrientation: 'vertical'` stacks the panes, so the divider is a
220+
// horizontal rule. It used to render as a 1px-wide vertical sliver: the
221+
// Shadcn handle styled that case with `data-[panel-group-direction=vertical]`
222+
// variants, which react-resizable-panels v4 never triggers. The form renders
223+
// through `custom/resizable`, which re-keys them onto the separator's own
224+
// `aria-orientation` — see custom/resizable.tsx and
225+
// __tests__/resizable-orientation.test.tsx.
226+
const { container } = renderSplitForm({ fieldPanesOrientation: 'vertical' });
227+
const separator = container.querySelector('[role="separator"]')!;
228+
229+
expect(separator.getAttribute('aria-orientation')).toBe('horizontal');
230+
expect([...separator.classList]).toContain('aria-[orientation=horizontal]:w-full');
231+
232+
// …and the default (side-by-side) split still gets the vertical divider.
233+
cleanup();
234+
const sideBySide = renderSplitForm().container.querySelector('[role="separator"]')!;
235+
expect(sideBySide.getAttribute('aria-orientation')).toBe('vertical');
236+
});
217237
});

packages/components/src/renderers/form/form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from '../../ui/select';
2525
import { renderChildren } from '../../lib/utils';
2626
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
27-
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../../ui/resizable';
27+
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../../custom/resizable';
2828
import { Alert, AlertDescription } from '../../ui/alert';
2929
import { toast } from '../../ui/sonner';
3030
import { AlertCircle, ChevronDown, ChevronRight, Loader2, Maximize2, Check, X } from 'lucide-react';

packages/components/src/ui/index.ts

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)