mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 22:00:58 +00:00
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:
parent
048ced7d0e
commit
2071508eaf
10 changed files with 195 additions and 61 deletions
27
AGENTS.md
27
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue