Skip to content

Commit bbb25c3

Browse files
author
Chris Hasson
committed
feat(ui): add decorated VSCode text field component
Introduces a new `DecoratedVSCodeTextField` component that wraps the standard VSCode text field and allows for the addition of left and right nodes (e.g., icons, currency symbols). This enhances the flexibility and visual presentation of text input fields within the UI. The component includes: - Support for `leftNodes` and `rightNodes` to display content inside the text field. - Automatic padding adjustment based on the presence of nodes. - Storybook stories demonstrating various use cases (price input, search input). - Unit tests to ensure proper rendering and functionality of the component with and without nodes.
1 parent abe7839 commit bbb25c3

File tree

3 files changed

+285
-0
lines changed

3 files changed

+285
-0
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { Meta, StoryObj } from "@storybook/react"
2+
import { DecoratedVSCodeTextField } from "../../../webview-ui/src/components/common/DecoratedVSCodeTextField"
3+
4+
const meta: Meta<typeof DecoratedVSCodeTextField> = {
5+
title: "Component/DecoratedVSCodeTextField",
6+
component: DecoratedVSCodeTextField,
7+
parameters: {
8+
layout: "centered",
9+
},
10+
tags: ["autodocs"],
11+
argTypes: {
12+
placeholder: {
13+
control: "text",
14+
description: "Placeholder text for the input",
15+
},
16+
value: {
17+
control: "text",
18+
description: "Current value of the input",
19+
},
20+
disabled: {
21+
control: "boolean",
22+
description: "Whether the input is disabled",
23+
},
24+
leftNodes: {
25+
control: false,
26+
description: "Array of React nodes to display on the left side of the input",
27+
},
28+
rightNodes: {
29+
control: false,
30+
description: "Array of React nodes to display on the right side of the input",
31+
},
32+
},
33+
}
34+
35+
export default meta
36+
type Story = StoryObj<typeof meta>
37+
38+
export const Default: Story = {
39+
args: {
40+
value: "",
41+
placeholder: "Enter text...",
42+
},
43+
}
44+
45+
export const WithBothNodes: Story = {
46+
args: {
47+
value: "",
48+
placeholder: "0.00",
49+
leftNodes: [<span key="dollar">$</span>],
50+
rightNodes: [<span key="usd">USD</span>],
51+
},
52+
}
53+
54+
export const PriceInput: Story = {
55+
name: "Price Input Example",
56+
args: {
57+
placeholder: "0.00",
58+
leftNodes: [<span key="dollar">$</span>],
59+
rightNodes: [<span key="usd">USD</span>],
60+
value: "25.99",
61+
},
62+
}
63+
64+
export const SearchInput: Story = {
65+
name: "Search Input Example",
66+
args: {
67+
value: "",
68+
placeholder: "Search files...",
69+
leftNodes: [<span key="search">🔍</span>],
70+
rightNodes: [
71+
<span key="shortcut" style={{ fontSize: "11px", opacity: 0.7 }}>
72+
⌘K
73+
</span>,
74+
],
75+
},
76+
}
77+
78+
export const Disabled: Story = {
79+
args: {
80+
placeholder: "Disabled input",
81+
leftNodes: [<span key="dollar">$</span>],
82+
disabled: true,
83+
value: "100.00",
84+
},
85+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { cn } from "@/lib/utils"
2+
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
3+
import { forwardRef, useCallback, useRef, ReactNode, ComponentRef } from "react"
4+
5+
// Type for web components that have shadow DOM
6+
interface WebComponentWithShadowRoot extends HTMLElement {
7+
shadowRoot: ShadowRoot | null
8+
}
9+
10+
export interface VSCodeTextFieldWithNodesProps {
11+
className?: string
12+
placeholder?: string
13+
value?: string
14+
onInput?: (e: any) => void
15+
onBlur?: (e: any) => void
16+
onKeyDown?: (e: any) => void
17+
style?: React.CSSProperties
18+
"data-testid"?: string
19+
leftNodes?: ReactNode[]
20+
rightNodes?: ReactNode[]
21+
disabled?: boolean
22+
}
23+
24+
function VSCodeTextFieldWithNodesInner(
25+
props: VSCodeTextFieldWithNodesProps,
26+
forwardedRef: React.Ref<HTMLInputElement>,
27+
) {
28+
const {
29+
className,
30+
placeholder,
31+
value,
32+
onInput,
33+
onBlur,
34+
onKeyDown,
35+
style,
36+
"data-testid": dataTestId,
37+
leftNodes,
38+
rightNodes,
39+
disabled,
40+
...restProps
41+
} = props
42+
43+
const inputRef = useRef<HTMLInputElement | null>(null)
44+
const vscodeFieldRef = useRef<any>(null)
45+
46+
// Callback ref to get access to the underlying input element.
47+
// VSCodeTextField doesn't expose this directly so we have to query for it!
48+
const handleVSCodeFieldRef = useCallback(
49+
(element: ComponentRef<typeof VSCodeTextField>) => {
50+
vscodeFieldRef.current = element
51+
if (!element) return
52+
53+
const webComponent = element as unknown as WebComponentWithShadowRoot
54+
const inputElement =
55+
webComponent.shadowRoot?.querySelector?.("input") || webComponent.querySelector?.("input")
56+
if (inputElement && inputElement instanceof HTMLInputElement) {
57+
inputRef.current = inputElement
58+
if (typeof forwardedRef === "function") {
59+
forwardedRef?.(inputElement)
60+
} else if (forwardedRef) {
61+
;(forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = inputElement
62+
}
63+
}
64+
},
65+
[forwardedRef],
66+
)
67+
68+
const focusInput = useCallback(async () => {
69+
if (inputRef.current && document.activeElement !== inputRef.current) {
70+
setTimeout(() => {
71+
inputRef.current?.focus()
72+
})
73+
}
74+
}, [])
75+
76+
const hasLeftNodes = leftNodes && leftNodes.filter(Boolean).length > 0
77+
const hasRightNodes = rightNodes && rightNodes.filter(Boolean).length > 0
78+
79+
return (
80+
<div
81+
className={cn(
82+
`group`,
83+
`relative flex items-center cursor-text`,
84+
`bg-[var(--input-background)] text-[var(--input-foreground)]`,
85+
`rounded-[calc(var(--corner-radius-round)*1px)]`,
86+
className,
87+
)}
88+
style={style}
89+
onMouseDown={focusInput}>
90+
{hasLeftNodes && (
91+
<div className="absolute left-2 z-10 flex items-center gap-1 pointer-events-none">{leftNodes}</div>
92+
)}
93+
94+
<VSCodeTextField
95+
placeholder={placeholder}
96+
value={value}
97+
onInput={onInput}
98+
onBlur={onBlur}
99+
onKeyDown={onKeyDown}
100+
data-testid={dataTestId}
101+
disabled={disabled}
102+
ref={handleVSCodeFieldRef}
103+
style={{
104+
flex: 1,
105+
paddingLeft: hasLeftNodes ? "24px" : undefined,
106+
paddingRight: hasRightNodes ? "24px" : undefined,
107+
}}
108+
className="[--border-width:0]"
109+
{...restProps}
110+
/>
111+
112+
{hasRightNodes && (
113+
<div className="absolute right-2 z-10 flex items-center gap-1 pointer-events-none">{rightNodes}</div>
114+
)}
115+
116+
{/* Absolutely positioned focus border overlay */}
117+
<div className="absolute top-0 left-0 size-full border border-[var(--input-border)] group-focus-within:border-[var(--focus-border)] rounded-[calc(var(--corner-radius-round)*1px)]"></div>
118+
</div>
119+
)
120+
}
121+
122+
export const DecoratedVSCodeTextField = forwardRef(VSCodeTextFieldWithNodesInner)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { render, screen } from "@testing-library/react"
2+
import { DecoratedVSCodeTextField } from "../DecoratedVSCodeTextField"
3+
4+
describe("DecoratedVSCodeTextField", () => {
5+
test("renders without nodes as standard VSCodeTextField", () => {
6+
render(<DecoratedVSCodeTextField placeholder="Test placeholder" data-testid="test-input" />)
7+
8+
const input = screen.getByTestId("test-input")
9+
expect(input).toBeInTheDocument()
10+
})
11+
12+
test("renders with left nodes", () => {
13+
render(
14+
<DecoratedVSCodeTextField
15+
placeholder="Test placeholder"
16+
data-testid="test-input"
17+
leftNodes={[<span key="dollar">$</span>]}
18+
/>,
19+
)
20+
21+
const input = screen.getByTestId("test-input")
22+
expect(input).toBeInTheDocument()
23+
24+
// Check that the dollar sign is rendered
25+
expect(screen.getByText("$")).toBeInTheDocument()
26+
})
27+
28+
test("renders with right nodes", () => {
29+
render(
30+
<DecoratedVSCodeTextField
31+
placeholder="Test placeholder"
32+
data-testid="test-input"
33+
rightNodes={[<span key="usd">USD</span>]}
34+
/>,
35+
)
36+
37+
const input = screen.getByTestId("test-input")
38+
expect(input).toBeInTheDocument()
39+
40+
// Check that the USD text is rendered
41+
expect(screen.getByText("USD")).toBeInTheDocument()
42+
})
43+
44+
test("renders with both left and right nodes", () => {
45+
render(
46+
<DecoratedVSCodeTextField
47+
placeholder="Test placeholder"
48+
data-testid="test-input"
49+
leftNodes={[<span key="dollar">$</span>]}
50+
rightNodes={[<span key="usd">USD</span>]}
51+
/>,
52+
)
53+
54+
const input = screen.getByTestId("test-input")
55+
expect(input).toBeInTheDocument()
56+
57+
// Check that both nodes are rendered
58+
expect(screen.getByText("$")).toBeInTheDocument()
59+
expect(screen.getByText("USD")).toBeInTheDocument()
60+
})
61+
62+
test("handles multiple left nodes", () => {
63+
render(
64+
<DecoratedVSCodeTextField
65+
placeholder="Test placeholder"
66+
data-testid="test-input"
67+
leftNodes={[<span key="icon">🔍</span>, <span key="text">Search</span>]}
68+
/>,
69+
)
70+
71+
const input = screen.getByTestId("test-input")
72+
expect(input).toBeInTheDocument()
73+
74+
// Check that both left nodes are rendered
75+
expect(screen.getByText("🔍")).toBeInTheDocument()
76+
expect(screen.getByText("Search")).toBeInTheDocument()
77+
})
78+
})

0 commit comments

Comments
 (0)