fix(web-shell): restore packaged dialog styles on React 18 (#6827)

* fix(web-shell): restore packaged dialog styles on React 18

* docs(web-shell): document UI component conventions

* test(web-shell): verify workspace input focus

* test(web-shell): verify button ref binding

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
This commit is contained in:
ytahdn 2026-07-13 23:57:23 +08:00 committed by GitHub
parent 048ced7d0e
commit 2071508eaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 195 additions and 61 deletions

View file

@ -156,6 +156,33 @@ npm run preflight # Full check: clean → install → format → lint → build
- **Commits**: Conventional Commits (e.g., `feat(cli): Add --json flag`)
- **Node.js**: Development and production both require `>=22` (Ink 7 + React 19.2 requirement)
### Web Shell UI development
- Prefer the shared primitives in
`packages/web-shell/client/components/ui` when developing Web Shell UI. Do
not duplicate an existing primitive or rewrite stable CSS Modules solely for
consistency.
- If a required primitive is missing, run
`npx shadcn@latest add <component>` from `packages/web-shell`, then review the
generated diff. Do not let the CLI overwrite the existing global CSS,
semantic tokens, CSS scoping, or portal-root integration. Keep generated
components internal unless a public package API is explicitly required.
- Web Shell supports React 18 and React 19. Generated shadcn components often
assume React 19 ref semantics, so wrappers that accept or receive refs —
including Radix `asChild`, `Slot`, `Presence`, and portal children — must use
`React.forwardRef` and pass the ref to the underlying DOM or Radix primitive.
Add a regression test for any ref-sensitive component path.
- Use unprefixed Tailwind classes and shadcn semantic color tokens such as
`background`, `primary`, and `muted`. The package build scopes generated CSS
to the Web Shell root and portal root and prefixes global animations and CSS
property registrations; changes must preserve that isolation from host-page
styles.
- Components with portals, such as dialogs, popovers, dropdown menus, and
tooltips, must use `useWebShellPortalRoot()` as the Radix portal container so
themes, scoped CSS, and z-index variables continue to apply. Preserve
existing `data-web-shell-*` attributes and public `--web-shell-*` CSS
variables. See `packages/web-shell/README.md` for the full conventions.
## Development Guidelines
### General workflow

View file

@ -75,6 +75,29 @@ describe('build artifact — package boundary', () => {
expect(unscoped).toEqual([]);
});
it('applies Tailwind theme variables to WebShell roots', () => {
const themeRules: string[] = [];
postcss.parse(readInjectedCss()).walkRules((rule) => {
if (
rule.nodes.some(
(node) => node.type === 'decl' && node.prop === '--spacing',
)
) {
themeRules.push(rule.selector);
}
});
expect(themeRules).toContain(
':where([data-web-shell-root][data-web-shell-shadcn], [data-web-shell-portal-root][data-web-shell-shadcn])',
);
expect(themeRules).not.toEqual(
expect.arrayContaining([
expect.stringContaining(':root'),
expect.stringContaining(':host'),
]),
);
});
it('prefixes global CSS registrations and animations', () => {
const unscoped: string[] = [];
postcss.parse(readInjectedCss()).walkAtRules((atRule) => {

View file

@ -54,6 +54,12 @@ function submit() {
}
describe('AddWorkspaceDialog', () => {
it('focuses the path input when opened', () => {
mount(<AddWorkspaceDialog onClose={vi.fn()} onAdd={vi.fn()} />);
expect(document.activeElement).toBe(input());
});
it('describes the input with the hint and no error initially', () => {
mount(<AddWorkspaceDialog onClose={vi.fn()} onAdd={vi.fn()} />);
// Hint is always associated; error id is only added once an error exists so

View file

@ -1,4 +1,4 @@
import type * as React from 'react';
import * as React from 'react';
import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
@ -33,12 +33,13 @@ function AlertDialogPortal({
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentProps<typeof AlertDialogPrimitive.Overlay>
>(function AlertDialogOverlay({ className, ...props }, ref) {
return (
<AlertDialogPrimitive.Overlay
ref={ref}
data-slot="alert-dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
@ -47,19 +48,23 @@ function AlertDialogOverlay({
{...props}
/>
);
}
});
function AlertDialogContent({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
type AlertDialogContentProps = React.ComponentProps<
typeof AlertDialogPrimitive.Content
> & {
size?: 'default' | 'sm';
}) {
};
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
AlertDialogContentProps
>(function AlertDialogContent({ className, size = 'default', ...props }, ref) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
data-slot="alert-dialog-content"
data-size={size}
className={cn(
@ -70,7 +75,7 @@ function AlertDialogContent({
/>
</AlertDialogPortal>
);
}
});
function AlertDialogHeader({
className,

View file

@ -1,4 +1,4 @@
import type * as React from 'react';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
@ -41,20 +41,26 @@ const buttonVariants = cva(
},
);
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
type ButtonProps = React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
className,
variant = 'default',
size = 'default',
asChild = false,
...props
},
ref,
) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
ref={ref}
data-slot="button"
data-variant={variant}
data-size={size}
@ -62,6 +68,6 @@ function Button({
{...props}
/>
);
}
});
export { Button, buttonVariants };

View file

@ -1,6 +1,6 @@
'use client';
import type * as React from 'react';
import * as React from 'react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
@ -40,12 +40,13 @@ function DialogClose({
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentProps<typeof DialogPrimitive.Overlay>
>(function DialogOverlay({ className, ...props }, ref) {
return (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 isolate z-[var(--web-shell-dialog-backdrop-z-index,50)] bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
@ -54,22 +55,27 @@ function DialogOverlay({
{...props}
/>
);
}
});
function DialogContent({
className,
children,
showCloseButton = true,
overlayProps,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
type DialogContentProps = React.ComponentProps<
typeof DialogPrimitive.Content
> & {
showCloseButton?: boolean;
overlayProps?: React.ComponentProps<typeof DialogPrimitive.Overlay>;
}) {
};
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(function DialogContent(
{ className, children, showCloseButton = true, overlayProps, ...props },
ref,
) {
return (
<DialogPortal>
<DialogOverlay {...overlayProps} />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
'fixed top-1/2 left-1/2 z-[var(--web-shell-dialog-backdrop-z-index,50)] grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
@ -93,7 +99,7 @@ function DialogContent({
</DialogPrimitive.Content>
</DialogPortal>
);
}
});
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (

View file

@ -1,19 +1,22 @@
import type * as React from 'react';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
);
}
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
function Input({ className, type, ...props }, ref) {
return (
<input
ref={ref}
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
);
},
);
export { Input };

View file

@ -0,0 +1,42 @@
// @vitest-environment jsdom
import * as React from 'react';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { describe, expect, it } from 'vitest';
import { AlertDialogContent, AlertDialogOverlay } from './alert-dialog';
import { Button } from './button';
import { DialogContent, DialogOverlay } from './dialog';
import { Input } from './input';
import { SelectTrigger } from './select';
const FORWARD_REF_TYPE = Symbol.for('react.forward_ref');
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('React 18 ref compatibility', () => {
it.each([
['AlertDialogContent', AlertDialogContent],
['AlertDialogOverlay', AlertDialogOverlay],
['Button', Button],
['DialogContent', DialogContent],
['DialogOverlay', DialogOverlay],
['Input', Input],
['SelectTrigger', SelectTrigger],
])('%s forwards refs', (_name, Component) => {
expect(Component).toHaveProperty('$$typeof', FORWARD_REF_TYPE);
});
it('forwards a Button ref to its DOM element', () => {
const ref = React.createRef<HTMLButtonElement>();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<Button ref={ref}>Button</Button>));
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
act(() => root.unmount());
container.remove();
});
});

View file

@ -1,6 +1,6 @@
'use client';
import type * as React from 'react';
import * as React from 'react';
import { Select as SelectPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
@ -32,16 +32,22 @@ function SelectValue({
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
type SelectTriggerProps = React.ComponentProps<
typeof SelectPrimitive.Trigger
> & {
size?: 'sm' | 'default';
}) {
};
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
SelectTriggerProps
>(function SelectTrigger(
{ className, size = 'default', children, ...props },
ref,
) {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
@ -56,7 +62,7 @@ function SelectTrigger({
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
});
function SelectContent({
className,

View file

@ -8,6 +8,8 @@ import pkg from './package.json' with { type: 'json' };
const COMPONENT_SCOPE =
':where([data-web-shell-root][data-web-shell-shadcn], [data-web-shell-portal-root][data-web-shell-shadcn], [data-web-shell-root][data-web-shell-shadcn] *, [data-web-shell-portal-root][data-web-shell-shadcn] *)';
const COMPONENT_ROOT_SCOPE =
':where([data-web-shell-root][data-web-shell-shadcn], [data-web-shell-portal-root][data-web-shell-shadcn])';
function scopeComponentCss(css: string): string {
const root = postcss.parse(css);
@ -58,6 +60,14 @@ function scopeComponentCss(css: string): string {
}
parent = parent.parent;
}
if (
rule.selectors.every(
(selector) => selector === ':root' || selector === ':host',
)
) {
rule.selector = COMPONENT_ROOT_SCOPE;
return;
}
rule.selector = selectorParser((selectors) => {
selectors.each((selector) => {
const first = selector.first;