Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add select primitive #863

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/core/primitives/Dropdown/Select/SelectPrimitive.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import SandboxEditor from '~/components/tools/SandboxEditor/SandboxEditor';
import SelectPrimitive from '.';

const options = ['New Tab','New Window','New InPrivate Window']

export default {
title: 'Primitives/SelectPrimitive',
component: SelectPrimitive,
render: (args: React.JSX.IntrinsicAttributes) => <SandboxEditor>
<div>
<SelectPrimitive options={options} onChange={() => {}} {...args} />

</div>
</SandboxEditor>

}

export const All = {
args: {
children: 'Select Option',
}
}
38 changes: 38 additions & 0 deletions src/core/primitives/Dropdown/Select/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Primitive from '~/core/primitives/Primitive';
import React, { useState } from 'react';

type SelectPrimitiveProps = {
children?: React.ReactNode,
options: string[],
value?: string | null,
onChange?: (value: string) => void;
}

const SelectPrimitive = ({ children, options, value, onChange, ...props }:SelectPrimitiveProps) => {

const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(value ?? null)

const handleClick = () => {
setIsOpen(!isOpen)
}

const handleSelect = (option: string) => {
setSelected(option)
onChange?.(option)
setIsOpen(false)
}
return (
<>
<Primitive.button onClick={handleClick} {...props}>
{children}
</Primitive.button>
{isOpen && options.map((option, index) => (
<div key={index} onClick={() => handleSelect(option)}>{option}</div>
))}
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add styling for dropdown positioning.

The dropdown options are rendered without any positioning, which could lead to layout issues. Consider adding proper styling:

-        {isOpen && options.map((option, index) => (
+        {isOpen && (
+          <div className="absolute top-full left-0 w-full mt-1 bg-white border rounded-md shadow-lg">
+            {options.map((option, index) => (
             <div key={index} onClick={() => handleSelect(option)}>{option}</div>
+            ))}
+          </div>
         ))}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{isOpen && options.map((option, index) => (
<div key={index} onClick={() => handleSelect(option)}>{option}</div>
))}
{isOpen && (
<div className="absolute top-full left-0 w-full mt-1 bg-white border rounded-md shadow-lg">
{options.map((option, index) => (
<div key={index} onClick={() => handleSelect(option)}>{option}</div>
))}
</div>
)}

</>
);

};
Comment on lines +11 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add accessibility and keyboard navigation support.

The current implementation lacks essential features for accessibility and usability:

  • Missing ARIA attributes (aria-expanded, aria-haspopup)
  • Missing keyboard navigation (arrow keys, Enter, Escape)
  • Missing focus management
  • Missing dropdown positioning (might overlap with other content)

Here's a suggested implementation:

 const SelectPrimitive = ({ children, options, value, onChange, ...props }:SelectPrimitiveProps) => {
+    const dropdownRef = useRef<HTMLDivElement>(null);
     const [isOpen, setIsOpen] = useState(false);
     const [selected, setSelected] = useState(value ?? null)
+    const [focusedIndex, setFocusedIndex] = useState(-1);
     
     const handleClick = () => {
         setIsOpen(!isOpen)
     }
     
     const handleSelect = (option: string) => {
         setSelected(option)
         onChange?.(option)
         setIsOpen(false)
     }
+
+    const handleKeyDown = (e: React.KeyboardEvent) => {
+        switch(e.key) {
+            case 'ArrowDown':
+                e.preventDefault();
+                if (!isOpen) {
+                    setIsOpen(true);
+                }
+                setFocusedIndex(prev => Math.min(prev + 1, options.length - 1));
+                break;
+            case 'ArrowUp':
+                e.preventDefault();
+                setFocusedIndex(prev => Math.max(prev - 1, 0));
+                break;
+            case 'Enter':
+                e.preventDefault();
+                if (focusedIndex >= 0) {
+                    handleSelect(options[focusedIndex]);
+                }
+                break;
+            case 'Escape':
+                e.preventDefault();
+                setIsOpen(false);
+                break;
+        }
+    }
+
+    useEffect(() => {
+        const handleClickOutside = (event: MouseEvent) => {
+            if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+                setIsOpen(false);
+            }
+        };
+
+        document.addEventListener('mousedown', handleClickOutside);
+        return () => document.removeEventListener('mousedown', handleClickOutside);
+    }, []);

     return (
-        <>
+        <div ref={dropdownRef} className="relative">
           <Primitive.button 
+            aria-haspopup="listbox"
+            aria-expanded={isOpen}
             onClick={handleClick}
+            onKeyDown={handleKeyDown}
             {...props}
           >
             {children}
          </Primitive.button>
         {isOpen && options.map((option, index) => (
-            <div key={index} onClick={() => handleSelect(option)}>{option}</div>
+            <div
+                key={index}
+                role="option"
+                aria-selected={selected === option}
+                className={`cursor-pointer ${focusedIndex === index ? 'bg-gray-100' : ''}`}
+                onClick={() => handleSelect(option)}
+            >
+                {option}
+            </div>
         ))}
-        </>
+        </div>
     );
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const SelectPrimitive = ({ children, options, value, onChange, ...props }:SelectPrimitiveProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(value ?? null)
const handleClick = () => {
setIsOpen(!isOpen)
}
const handleSelect = (option: string) => {
setSelected(option)
onChange?.(option)
setIsOpen(false)
}
return (
<>
<Primitive.button onClick={handleClick} {...props}>
{children}
</Primitive.button>
{isOpen && options.map((option, index) => (
<div key={index} onClick={() => handleSelect(option)}>{option}</div>
))}
</>
);
};
const SelectPrimitive = ({ children, options, value, onChange, ...props }: SelectPrimitiveProps) => {
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(value ?? null);
const [focusedIndex, setFocusedIndex] = useState(-1);
const handleClick = () => {
setIsOpen(!isOpen);
};
const handleSelect = (option: string) => {
setSelected(option);
onChange?.(option);
setIsOpen(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
}
setFocusedIndex(prev => Math.min(prev + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (focusedIndex >= 0) {
handleSelect(options[focusedIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
break;
}
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div ref={dropdownRef} className="relative">
<Primitive.button
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...props}
>
{children}
</Primitive.button>
{isOpen && options.map((option, index) => (
<div
key={index}
role="option"
aria-selected={selected === option}
className={`cursor-pointer ${focusedIndex === index ? 'bg-gray-100' : ''}`}
onClick={() => handleSelect(option)}
>
{option}
</div>
))}
</div>
);
};
🧰 Tools
🪛 ESLint

[error] 11-13: Block must not be padded by blank lines.

(padded-blocks)


[error] 34-36: Block must not be padded by blank lines.

(padded-blocks)


export default SelectPrimitive
Loading