DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our picker splits at nine items, and four separate rules decide when a row may move

Munchable asks new users three list questions in a row: which gut conditions you are eating for, which allergies you have, and which ingredient groups you would rather not buy. Seven items, fourteen items, and a handful more.

They all go through one component. Writing it took an afternoon. Getting the rows to hold still took considerably longer, and that is the part worth writing down.

You can walk the whole flow at app.munchable.app, which is the same React Native app the phone runs, compiled for the web. The conditions screen and the allergies screen immediately after it use the same component with different data, and they look like two different controls, which is the point.

Nine

A wall of twenty rows is not a choice, it is a scroll. But splitting a list into "common ones" and "the rest behind a disclosure" is itself a cost: now the user has to wonder what is hidden.

So the split is conditional on the list being long enough to be worth it:

/**
 * The list is only split once it is long enough that scanning it costs
 * something. Below this every item is simply a row, which is what the seven
 * conditions want; the fourteen allergies get the common set and a search.
 */
const SPLIT_AT = 9;
Enter fullscreen mode Exit fullscreen mode

Seven conditions render as seven rows with no search field and no "show more". Fourteen allergies render as the seven most common, a search field, and a disclosure holding the other seven. Same component, same props shape, and nobody had to decide twice.

The common set is not a guess, and I think this is where most teams quietly cheat. Ours is documented in the data file:

/**
 * The ones shown before the search: the allergens with the highest adult
 * prevalence in UK and US surveys. The other seven are EU-list entries that
 * most people never have to look for, so they sit behind "Show more" rather
 * than making everybody scroll past them.
 */
export const ALLERGENS_COMMON: readonly AllergenId[] = [
  'peanut', 'tree-nut', 'milk', 'egg', 'gluten', 'crustacean', 'fish',
];
Enter fullscreen mode Exit fullscreen mode

If you cannot write that comment, you do not have a common set, you have a list in the order you happened to type it.

The search matches what people call it, not what we call it

Every item carries keywords that are never rendered:

{
  id: 'gerd',
  title: 'Acid reflux / GERD',
  keywords: ['heartburn', 'acid', 'reflux', 'gord', 'oesophageal'],
},
Enter fullscreen mode Exit fullscreen mode

Nobody with reflux types "GERD" first. They type "heartburn". Somebody with coeliac disease may spell it "celiac", and somebody typing on a phone will not bother with the diacritic in a word that has one. So the fold strips both case and accents:

/** Lowercase and strip diacritics, so "coeliac" finds "Coeliac" and "é" finds "e". */
function fold(s: string): string {
  return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
}
Enter fullscreen mode Exit fullscreen mode

The search reads title, helper text and keywords through that fold. It is eight lines of code and it is the difference between a list people can use and a list people give up on.

No search may dead end

If a query matches nothing, the screen does not show an empty box. It says what the list actually covers:

Munchable checks the 14 allergens that labels must declare. If yours is not here, it cannot be checked yet.

That sentence lives next to the data it describes, because it is a claim about the data:

/** The honest boundary of the list, shown when a search finds nothing. */
export const ALLERGIES_NOT_LISTED = '...';
Enter fullscreen mode Exit fullscreen mode

The health-preferences list has its own version, ending in an invitation to tell us what is missing, because unlike the allergen list that one can grow on request. Two lists, two honest boundaries, neither of them a generic "no results".

The part that took the longest: rows that hold still

Here is the actual hard problem. The list is split, so some rows are behind a disclosure. The user has chosen some of them. What happens to a chosen row that lives in the hidden half?

Obvious answer: hoist it into view, so nothing the user selected is ever hidden behind a control they would have to remember to open. That is right, and it is about a quarter of the behaviour. The rest is about when hoisting is allowed to happen, because every hoist is a row moving on screen, and rows moving on screen under a finger that is still tapping is how a user ends up selecting something they did not mean to.

Four rules, and they are deliberately not symmetric.

One: while the disclosure is open, nothing is pinned.

const pinnedItems = showRest ? [] : restItems.filter((i) => pinned.has(i.id));
Enter fullscreen mode Exit fullscreen mode

Every item is already on screen in its own place. Hoisting the row the user just tapped out of the middle of the list and up the page would move the thing under their finger, for no gain at all.

Two: pinning happens at collapse. Closing the list is the exact moment something could go out of sight, so that is the moment anything selected gets pinned:

const collapse = () => {
  setPinned((prev) => {
    const next = new Set(prev);
    for (const id of selected) next.add(id);
    return next;
  });
  setShowRest(false);
};
Enter fullscreen mode Exit fullscreen mode

Three: selecting from a collapsed list, or from search results, pins immediately. Otherwise clearing the search box would make the thing you just chose disappear:

if (!showRest && !chosen.has(id) && !pinned.has(id)) {
  setPinned((prev) => new Set(prev).add(id));
}
Enter fullscreen mode Exit fullscreen mode

Four: deselecting does not unpin. This is the one that looks like a bug in review and is the most important of the four. When you untick a pinned row, it stays exactly where it is:

// Deselecting deliberately leaves the row where it is, so a row never vanishes
// out from under the finger that just tapped it. It rejoins the rest of the
// list the next time the screen is opened.
Enter fullscreen mode Exit fullscreen mode

Untick, and the row you just touched would otherwise vanish and drag every row below it upwards. If you mistapped and wanted to correct it, the correct row is now somewhere else. The state is deliberately sticky for one screen lifetime, and the tidy-up happens on the next mount when nobody's finger is near.

pinned is seeded from whatever was already selected on arrival, so opening the same screen from Settings a month later shows your choices immediately rather than three of them and a "Show 7 more".

Selected but not matching

One more state, easy to miss. You have three allergies selected and you search for "sesame". The results show sesame. Where did the other three go?

const missed = items.filter((i) => chosen.has(i.id) && !matches.some((m) => m.id === i.id));
Enter fullscreen mode Exit fullscreen mode

They are named in a line underneath: "Also selected: peanut, milk, egg." Not as rows, because they are not results and tapping them is not what you are here for, but present, because a screen that appears to have forgotten your allergy list is alarming in a way that a food app really cannot afford.

The flow around it changes length

The onboarding steps are an array, not a state machine, and one step is conditional:

const steps = [
  'welcome',
  'framing',
  'region',
  'conditions',
  ...(hasLactose ? ['lactose'] : []),
  'allergies',
  'healthy',
  'done',
];
Enter fullscreen mode Exit fullscreen mode

Pick lactose intolerance on the conditions screen and an extra question about how much lactose you can usually handle appears after it. Do not pick it and the flow is one screen shorter, because asking someone to rate a sensitivity they do not have is noise.

The reason this is a spread inside an array literal rather than anything cleverer is that the alternative was a step enum with a next() that branches, and every branch is a place for the back button to land somewhere wrong. steps[idx - 1] is always correct by construction, including across a recomputation, because removing the lactose step also removes it from the history.

The visible consequence is that there is no "step 4 of 7" indicator anywhere in this flow. We cannot honestly print a denominator that the user's own answers change halfway through, and a progress bar that jumps from 4/7 to 4/8 is worse than no progress bar. It is three screens of picking, it is obviously short, and the honest version of the reassurance is to make the flow short rather than to label it.

Accessibility is where the shortcuts show

The disclosure is a Pressable with an explicit state and a counted label:

accessibilityState={{ expanded: showRest }}
aria-expanded={showRest}
accessibilityLabel={showRest ? `Show fewer ${noun}` : `Show ${hiddenItems.length} more ${noun}`}
Enter fullscreen mode Exit fullscreen mode

"Show 7 more allergies" tells a screen reader user the size of the thing they are about to open. "Show more" does not. noun is a prop for exactly this sentence, which is why the component takes a prop that looks at first glance like it should have been derived.

Both accessibilityState and aria-expanded are set because this component renders to a native view on a phone and to DOM in the browser build, and neither attribute covers both.

Try it

Go to app.munchable.app and run through onboarding. Search "heartburn" on the conditions screen. Select an allergy from behind "Show 7 more", collapse it, and watch where the row goes. Then untick it and notice that it does not go anywhere.

The whole component is about 200 lines, and maybe 40 of them are the list. The rest is this.

Top comments (1)

Collapse
 
mohith_kumar_05846f3211f3 profile image
Mohith kumar •

Love the level of detail here. Onboarding pickers carry more weight than they look like: every extra option adds hesitation at the moment people are least committed. Splitting at nine is a nice practical threshold.