Repository: cornell-dti/idol
Pull request #918: [New website] New `<Input>` and `<LabeledInput>` components
Author: login: clementroze
Created: 2025-04-16T01:41:04Z | Merged: 2025-04-16T14:15:22Z

# What changed

- Created new `<Input>` and `<LabeledInput>` components
- `<Input>` supports both `<input>` and `<textarea>` 
- Supports basic accessibility attributes like `id`, `aria-describedby`, and `aria-invalid`
- `<LabeledInput>` adds a `label` and optional `error` prop
- Added these components and all their variants in the `/test` page

|State|Pic|
|-|-|
|Default|<img width="1048" alt="Screenshot 2025-04-15 at 10 02 18 PM" src="https://github.com/user-attachments/assets/79b9e797-8932-47d5-b8cc-a937fbb61134" />|
|Hover|<img width="1048" alt="Screenshot 2025-04-15 at 10 02 25 PM" src="https://github.com/user-attachments/assets/1df25e2b-e0a9-4197-983c-1109455de958" />|
|Focus|<img width="1048" alt="Screenshot 2025-04-15 at 10 02 28 PM" src="https://github.com/user-attachments/assets/c7c444ec-0e87-4c53-9adb-6557bad1be3d" />|


# Test plan

- Go to the `/test` page
- Scroll to Inputs section
- Interact with each input/textarea and make sure they look and function as intended 



Diff:
diff --git a/new-dti-website-redesign/src/app/components/Input.tsx b/new-dti-website-redesign/src/app/components/Input.tsx
new file mode 100644
index 000000000..39f717327
--- /dev/null
+++ b/new-dti-website-redesign/src/app/components/Input.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+
+type InputProps = {
+  value?: string;
+  onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
+  placeholder?: string;
+  multiline?: boolean;
+  height?: number;
+  resize?: 'none' | 'both' | 'horizontal' | 'vertical';
+  disabled?: boolean;
+  className?: string;
+  id?: string;
+  ariaDescribedby?: string;
+  ariaInvalid?: boolean;
+};
+
+const Input: React.FC<InputProps> = ({
+  value,
+  onChange,
+  placeholder,
+  multiline = false,
+  height,
+  resize = 'none',
+  disabled = false,
+  className,
+  id,
+  ariaDescribedby,
+  ariaInvalid
+}) => {
+  const baseStyles = `
+    p-3 text-rg text-foreground-1 placeholder-foreground-3
+    bg-background-2 rounded-lg border border-border-1 min-w-64 min-h-12
+    focus-visible:outline focus-visible:outline-[2px] focus-visible:outline-offset-[-1px] focus-visible:outline-[var(--foreground-1)]
+    hover:border-border-2
+    transition duration-[120ms]
+  `;
+
+  const textareaStyles = `
+    ${baseStyles}
+    resize-none
+  `;
+
+  const inputStyles = `
+    ${baseStyles}
+    h-12
+  `;
+
+  if (multiline) {
+    return (
+      <textarea
+        value={value}
+        onChange={onChange}
+        placeholder={placeholder}
+        className={`${textareaStyles} ${className ?? ''}`}
+        style={{ height, resize }}
+        disabled={disabled}
+        id={id}
+        aria-describedby={ariaDescribedby}
+        aria-invalid={ariaInvalid}
+      />
+    );
+  }
+
+  return (
+    <input
+      type="text"
+      value={value}
+      onChange={onChange}
+      placeholder={placeholder}
+      className={`${inputStyles} ${className ?? ''}`}
+      disabled={disabled}
+      id={id}
+      aria-describedby={ariaDescribedby}
+      aria-invalid={ariaInvalid}
+    />
+  );
+};
+
+export default Input;
diff --git a/new-dti-website-redesign/src/app/components/LabeledInput.tsx b/new-dti-website-redesign/src/app/components/LabeledInput.tsx
new file mode 100644
index 000000000..d1b22ba44
--- /dev/null
+++ b/new-dti-website-redesign/src/app/components/LabeledInput.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import Input from './Input';
+
+type LabeledInputProps = {
+  label: string;
+  error?: string;
+  inputProps: React.ComponentProps<typeof Input>;
+  className?: string;
+};
+
+const LabeledInput: React.FC<LabeledInputProps> = ({ label, error, inputProps, className }) => {
+  const id = React.useId(); // generating a unique ID for the input
+  const errorId = `${id}-error`;
+
+  return (
+    <div className={`flex flex-col gap-2 ${className ?? ''}`}>
+      <div className="flex flex-col gap-1">
+        <label htmlFor={id} className="block text-sm font-medium text-foreground-3">
+          {label}
+        </label>
+
+        <Input
+          id={id} // input gets the id
+          aria-invalid={!!error} // link to the error
+          ariaDescribedby={error ? errorId : undefined} // so that screen reader can read the error
+          {...inputProps}
+        />
+      </div>
+
+      {error && (
+        <p className="text-accent-red" id={errorId}>
+          {error}
+        </p>
+      )}
+    </div>
+  );
+};
+
+export default LabeledInput;
diff --git a/new-dti-website-redesign/src/app/test/page.tsx b/new-dti-website-redesign/src/app/test/page.tsx
index f7ab271f4..aeab4c54c 100644
--- a/new-dti-website-redesign/src/app/test/page.tsx
+++ b/new-dti-website-redesign/src/app/test/page.tsx
@@ -4,6 +4,8 @@ import Link from 'next/link';
 import { Plus } from 'lucide-react';
 import Button from '../components/Button';
 import IconButton from '../components/IconButton';
+import Input from '../components/Input';
+import LabeledInput from '../components/LabeledInput';
 
 export default function TestPage() {
   return (
@@ -83,6 +85,67 @@ export default function TestPage() {
           </IconButton>
         </div>
       </div>
+
+      <div className="flex flex-col gap-6">
+        <h4>Inputs</h4>
+        <div className="flex gap-8">
+          <Input placeholder="Input placeholder" onChange={() => {}} className="w-128" />
+
+          <Input
+            placeholder="Input placeholder"
+            onChange={() => {}}
+            multiline
+            height={256}
+            className="w-128"
+          />
+        </div>
+
+        <div className="flex gap-8">
+          <LabeledInput
+            className="w-128"
+            label="Input label"
+            inputProps={{
+              onChange: () => {},
+              placeholder: 'Input placeholder'
+            }}
+          />
+
+          <LabeledInput
+            className="w-128"
+            label="Input label"
+            inputProps={{
+              onChange: () => {},
+              placeholder: 'Input placeholder',
+              multiline: true,
+              height: 256
+            }}
+          />
+        </div>
+
+        <div className="flex gap-8">
+          <LabeledInput
+            className="w-128"
+            label="Input label"
+            inputProps={{
+              onChange: () => {},
+              placeholder: 'Input placeholder'
+            }}
+            error="Input error message"
+          />
+
+          <LabeledInput
+            className="w-128"
+            label="Input label"
+            inputProps={{
+              onChange: () => {},
+              placeholder: 'Input placeholder',
+              multiline: true,
+              height: 256
+            }}
+            error="Input error message"
+          />
+        </div>
+      </div>
     </div>
   );
 }


Reviews:
user: login: clementroze
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T02:11:07Z
user: login: jujulcrane
body: The Input and LabeledInput function properly on my end. The code also looks good. It's great that you made it accessible! 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-16T14:11:50Z

Review comments:
diff_hunk: @@ -0,0 +1,79 @@
+import React from 'react';
+
+type InputProps = {
+  value?: string;
+  onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
+  placeholder?: string;
+  multiline?: boolean;
+  height?: number;
+  resize?: 'none' | 'both' | 'horizontal' | 'vertical';
+  disabled?: boolean;
+  className?: string;
+  id?: string;
+  ariaDescribedby?: string;
+  ariaInvalid?: boolean;
+};
+
+const Input: React.FC<InputProps> = ({
+  value,
+  onChange,
+  placeholder,
+  multiline = false,
+  height,
+  resize = 'none',
+  disabled = false,
+  className,
+  id,
+  ariaDescribedby,
+  ariaInvalid
+}) => {
+  const baseStyles = `
+    p-3 text-rg text-foreground-1 placeholder-foreground-3
+    bg-background-2 rounded-lg border border-border-1
+    focus-visible:outline focus-visible:outline-[2px] focus-visible:outline-offset-[-1px] focus-visible:outline-[var(--foreground-1)]
+    hover:border-border-2
+    transition duration-[120ms]
+  `;
+
+  const textareaStyles = `
+    ${baseStyles}
+    resize-none
+  `;
+
+  const inputStyles = `
+    ${baseStyles}
+    h-12
+  `;
+
+  if (multiline) {
+    return (
+      <textarea
path: new-dti-website-redesign/src/app/components/Input.tsx
user: login: clementroze
body: we want a `<textarea>` element rendered if there are several lines, but otherwise should just be `<input>`
created_at: 2025-04-16T02:04:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,39 @@
+import React from 'react';
+import Input from './Input';
+
+type LabeledInputProps = {
+  label: string;
+  error?: string;
+  inputProps: React.ComponentProps<typeof Input>;
+  className?: string;
+};
+
+const LabeledInput: React.FC<LabeledInputProps> = ({ label, error, inputProps, className }) => {
+  const id = React.useId(); // generating a unique ID for the input
+  const errorId = `${id}-error`;
path: new-dti-website-redesign/src/app/components/LabeledInput.tsx
user: login: clementroze
body: We need to generate a unique and stable id to associate the `<label>` with the `<input>` via `htmlFor`

It's also used to associate an error via `aria-describedby`
created_at: 2025-04-16T02:05:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,39 @@
+import React from 'react';
+import Input from './Input';
+
+type LabeledInputProps = {
+  label: string;
+  error?: string;
+  inputProps: React.ComponentProps<typeof Input>;
+  className?: string;
+};
+
+const LabeledInput: React.FC<LabeledInputProps> = ({ label, error, inputProps, className }) => {
+  const id = React.useId(); // generating a unique ID for the input
+  const errorId = `${id}-error`;
+
+  return (
+    <div className={`flex flex-col gap-2 ${className ?? ''}`}>
+      <div className="flex flex-col gap-1">
+        <label htmlFor={id} className="block text-sm font-medium text-foreground-3">
+          {label}
+        </label>
+
+        <Input
+          id={id} // input gets the id
+          aria-invalid={!!error} // link to the error
+          ariaDescribedby={error ? errorId : undefined} // so that screen reader can read the error
+          {...inputProps}
+        />
path: new-dti-website-redesign/src/app/components/LabeledInput.tsx
user: login: clementroze
body: basically this is setting the id attribute directly on `<input>` or `<textarea>`

for `aria-invalid`, this is an accessibility attribute. [More here](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid)

and `ariaDescribedby` ([docs](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby)) helps screen readers find the error and link the error msg via its id
created_at: 2025-04-16T02:07:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,79 @@
+import React from 'react';
+
+type InputProps = {
+  value?: string;
+  onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
+  placeholder?: string;
+  multiline?: boolean;
+  height?: number;
+  resize?: 'none' | 'both' | 'horizontal' | 'vertical';
+  disabled?: boolean;
+  className?: string;
+  id?: string;
+  ariaDescribedby?: string;
+  ariaInvalid?: boolean;
+};
+
+const Input: React.FC<InputProps> = ({
+  value,
+  onChange,
+  placeholder,
+  multiline = false,
+  height,
+  resize = 'none',
+  disabled = false,
+  className,
+  id,
+  ariaDescribedby,
+  ariaInvalid
+}) => {
+  const baseStyles = `
+    p-3 text-rg text-foreground-1 placeholder-foreground-3
+    bg-background-2 rounded-lg border border-border-1 min-w-64 min-h-12
path: new-dti-website-redesign/src/app/components/Input.tsx
user: login: clementroze
body: min width/height to protect when resizing textarea (if its enabled)
created_at: 2025-04-16T02:10:56Z
author_association: COLLABORATOR

PR comments:
user: login: dti-github-bot
created_at: 2025-04-16T01:41:18Z
body: [diff-counting] Significant lines: 181.
author_association: MEMBER
user: login: clementroze
created_at: 2025-04-16T14:15:12Z
body: > The Input and LabeledInput function properly on my end. The code also looks good. It's great that you made it accessible!

Awesome, thanks so much for the quick review @jujulcrane!
author_association: COLLABORATOR