Skip to content

Instantly share code, notes, and snippets.

@marcysutton
Created February 24, 2025 22:14
Show Gist options
  • Save marcysutton/71db541cfed0bd46948442fb7efc533f to your computer and use it in GitHub Desktop.
Save marcysutton/71db541cfed0bd46948442fb7efc533f to your computer and use it in GitHub Desktop.
React Combobox - Safari/VO Hacking
const Listbox = React.forwardRef((props, ref) => {
return (
<div role="listbox" id={props.id} ref={ref}>
{props.children}
</div>
)
})
const Option = (props) => {
const {isPlaceholder, value, label, selected = false} = props;
return (
<div role="option" tabIndex="0" data-label={label} data-value={value} data-placeholder={isPlaceholder} aria-selected={selected}>{label}</div>
)
}
const ComboboxOpener = React.forwardRef(({isOpened, label, value, ariaControls}, ref) => {
if (!value) value = label;
return (
<div ref={ref} data-value={value} role="combobox" tabIndex={0} aria-expanded={isOpened} aria-autocomplete="none" aria-controls={ariaControls} aria-haspopup="listbox" aria-label={label}>{value}</div>
// <input ref={ref} role="combobox" aria-expanded={isOpened} aria-autocomplete="none" aria-controls={ariaControls} aria-haspopup="listbox" aria-label={label} placeholder={label} value={value} />
)
});
const Combobox = () => {
const openerRef = React.useRef(null);
const listboxRef = React.useRef(null);
const [isOpened, setIsOpened] = React.useState(true);
const [selectedOption, setSelectedOption] = React.useState();
const [focusedOptionIndex, setFocusedOptionIndex] = React.useState();
const clickHandler = (e) => {
if (e.target.role === 'option') {
if (!e.target.dataset.placeholder) {
setSelectedOption(e.target.dataset.label);
}
openerRef.current.focus();
}
}
/* Note: key commands not fully developed, as this is a minimal reproduction */
const keyDownHandler = (e) => {
console.log(e.key);
if (e.target.role === 'combobox') {
if (e.key === 'ArrowDown') {
} else if (e.key === 'ArrowUp') {
}
}
}
const keyUpHandler = (e) => {
}
const labelText = "Opener Text"
const placeholderText = "Choose An Option";
return (
<div className="custom-combobox" onClick={clickHandler} onKeyDown={keyDownHandler} onKeyUp={keyUpHandler}>
<ComboboxOpener isOpened label={labelText} placeholder={placeholderText} ref={openerRef} value={selectedOption} ariaControls="listbox1" />
<Listbox id="listbox1" ref={listboxRef}>
<Option isPlaceholder value="placeholder" label="Choose An Option" />
<Option value="item-1" label="Item 1" />
<Option value="item-2" label="Item 2" />
<Option value="item-3" label="Item 3" />
</Listbox>
</div>
)
}
const App = () => (
<>
<h1>React Combobox - Minimal Reproduction</h1>
<Combobox />
</>
)
// Clear the existing HTML content
document.body.innerHTML = '<div id="app"></div>';
// Render your React component instead
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<App />);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
.custom-combobox {
position: relative;
[role="combobox"] {
border: 1px solid;
border-radius: 10px;
cursor: pointer;
display: inline-block;
padding: 0.5em;
&:focus {
outline: 1px solid red;
}
}
[role="listbox"] {
border: 1px solid;
border-radius: 10px;
display: flex;
flex-direction: column;
margin-top: 0.5em;
max-width: 300px;
}
[role="option"] {
border-bottom: 1px solid #ccc;
cursor: pointer;
padding: 0.5em;
-webkit-user-select: none;
user-select: none;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment