Where were going we don't need forms!

Juls G

Recently had the chance to rework the project creation UI for Contra projects a bit. The most fun + challenging part was adding a dialog select input that made adding skills, tools and clients to a project without needing to go into a separate flow.

The problem: select/text input w the 💊s

Across the web you see this pattern quite a bit. Its easy to implement with some library like downshift, but it requires a form.
No boxes please!
No boxes please!
Blehh 🫤 Where we're going we don't need forms (at least visually!)

The solution: the floating select

Sevan designed a great approach to this that ditches the form. We basically only need an anchor (1), a trigger (2), a floating select (3):
The elements
The elements
The pills can then float freely with the content while we hide the tools to add them. We are still technically using a form, but we hide it completely and only display the elements that matter WHEN they matter.
Looking great!
Looking great!

Setting up w floating UI

1. useState and useFloating

const [isOpen, setIsOpen] = useState(false);
const { reference, ...rolesFloatingProps } = useFloating({
middleware: [offset(4), flip()],
open: isOpen,
placement: 'bottom-start',
});

2. List ref and useInteractions

Check out the floating docs for more details on the actual hooks. The list navigation seems a bit wordy but it works like a charm.
const listRef = useRef<Array<HTMLElement | null>>([]);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
[
useRole(context, { role: 'listbox' }),
useDismiss(context, { escapeKey: true, outsidePress: false }),
useListNavigation(context, {
activeIndex,
listRef,
loop: true,
onNavigate: (index) => {
onNavigate?.();
setActiveIndex(index);
},
orientation: 'vertical',
virtual: true,
}),
],
);

useLayoutEffect(() => {
requestAnimationFrame(() => {
if (activeIndex !== null) {
listRef.current[activeIndex]?.scrollIntoView({ block: 'nearest' });
}
});
}, [activeIndex]);

3. Setting up the ui components

       {isOpen ? (
<DialogSelect
$strategy={strategy}
$x={x}
$y={y}
ref={floating}
>
<DialogSelect.Input
{...getReferenceProps({
aria-autocomplete': 'list',
autoFocus: true,
onBlur: (event) => {
if (event.target !== document.activeElement) {
onClose();
}
},
onChange: (event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
},
onKeyDown: handleKeyDown,
placeholder: 'Search skills...',
tabIndex: -1,
value: inputValue,
})}
/>

<DialogSelect.List
{...getFloatingProps()}
>
{skills.map((skill) => (
<DialogSelect.ListItem
key={id}
{...getItemProps({
id,
onClick: () => handleSelect(skill),
onKeyDown: () => handleSelect(skill),
ref: (node) => {
listRef.current[index] = node;
},
})}
>
{skill.name}
</DialogSelect.ListItem>
)}
</DialogSelect.List>
</DialogSelect>
) : null}
And voila! Just like that we have much better experience that kicks the old form.
Like this project

Posted Aug 11, 2023

Who said capturing user input can't be fun? We recently redesigned our project creation at Contra and this is where we landed at.

Psychologist - Holistic Wellbeing Brand Identity
Ditch Linktree: How Contra Pro Can Improve Your Brand Identity
Ditch Linktree: How Contra Pro Can Improve Your Brand Identity
3 ideas to kickstart UX data insights at your early startup
3 ideas to kickstart UX data insights at your early startup