Files
growstuff/app/javascript/components/PlantPartPicker.jsx
T
Brenda WallaceandClaude Opus 5 7e181982ff Upgrade Font Awesome to 6.7.2 (#4846)
* Restore the Ruby 4.0.6 upgrade

PR #4831 committed a stale Gemfile.lock over the newer one, taking
.ruby-version back to 3.2.3 and rolling back active_median, dalli,
parallel, rbs and selenium-webdriver. Dockerfile and
.devcontainer/Dockerfile still build on Ruby 4, so the Gemfile's version
check fails against the image.

Restores both files to what they were before that merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Upgrade Font Awesome from 5.15.1 to 6.7.2

Font names have moved on twice since this was pinned: the codebase still
carried Font Awesome 4 names that render as an empty box, and FA6 icons
such as fa-ellipsis-vertical were unavailable.

The 6.x gem no longer ships _font-awesome-sprockets.scss, but its
@font-face rules still call fa-font-path(), so application.scss defines
that against Sprockets' font-path helper. Its fonts live under
assets/fonts/font-awesome, which is already in the precompile list.

Icon names:
- The bare "fa" prefix is not a style in 6. Every "fa fa-<name>" becomes
  "fas fa-<name>" (38 call sites, all solid; the one brands icon already
  used fab).
- fa-sun-o was a Font Awesome 4 name and had been rendering as a box;
  now fa-sun.
- fa-pencil was likewise broken under 5, where it was fa-pencil-alt. It
  exists again in 6, so the existing name now works.
- activity_icon asked for 'fa-truck-pickup' through a helper that already
  adds the prefix, so it emitted "fa-fa-truck-pickup".

Verified that the solid, regular and brands fonts all resolve through
the asset pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* couple more places to upgrade font awesome

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:39:38 +09:30

126 lines
5.1 KiB
React

import React, {useEffect, useRef, useState} from 'react';
const normal = (text) => text.trim().toLowerCase();
// Parts that start with what was typed first (an exact match before any other),
// then parts that contain it. With nothing typed, all of them, with the
// suggested one (what this crop is usually harvested for) at the top.
function matching(parts, text, suggestedId) {
const term = normal(text);
if (!term) return [...parts].sort((a, b) => (b.id === suggestedId) - (a.id === suggestedId));
const rank = (part) => {
const name = normal(part.name);
if (name === term) return 0;
if (name.startsWith(term)) return 1;
return name.includes(term) ? 2 : 3;
};
return parts.filter((part) => rank(part) < 3).sort((a, b) => rank(a) - rank(b));
}
// One big question, "What part did you harvest?", with a text box that
// completes the answer from the plant parts we have ("fruit", "leaf", ...), in
// the same style as the crop picker. Choosing one calls onChoose(part); the
// dialog then moves on to how much.
//
// A combobox with a listbox of matches: Up and Down move through them, Enter
// chooses the highlighted one (or the top match if none is highlighted), and
// Escape closes the list before it would close the dialog.
export default function PlantPartPicker({id, parts, suggestedId, onChoose}) {
const [term, setTerm] = useState('');
const [open, setOpen] = useState(true); // the list starts open, showing every part
const [activeIndex, setActiveIndex] = useState(-1);
const list = useRef(null);
const listId = `${id}-results`;
const results = matching(parts, term, suggestedId);
const showing = open && results.length > 0;
const noMatch = term.trim() !== '' && results.length === 0;
useEffect(() => {
const option = activeIndex >= 0 && list.current && list.current.children[activeIndex];
if (option && option.scrollIntoView) option.scrollIntoView({block: 'nearest'});
}, [activeIndex]);
function onKeyDown(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
if (showing) setActiveIndex((activeIndex + 1) % results.length);
else setOpen(true);
} else if (event.key === 'ArrowUp' && showing) {
event.preventDefault();
setActiveIndex(activeIndex <= 0 ? results.length - 1 : activeIndex - 1);
} else if (event.key === 'Enter') {
// Never submit a form from here: choose a part, or do nothing.
event.preventDefault();
if (showing) onChoose(results[activeIndex >= 0 ? activeIndex : 0]);
} else if (event.key === 'Escape' && showing) {
// Close the list; a second Escape then closes the dialog.
event.stopPropagation();
setOpen(false);
}
}
const active = activeIndex >= 0 ? results[activeIndex] : null;
return (
<div className="crop-picker position-relative">
<label htmlFor={id} className="crop-picker-label">What part did you harvest?</label>
<div className="crop-picker-field">
<i className="fas fa-search crop-picker-icon" aria-hidden="true" />
<input
id={id}
type="text"
role="combobox"
className="crop-picker-input"
autoFocus
aria-autocomplete="list"
aria-expanded={showing}
aria-controls={listId}
aria-activedescendant={active ? `${id}-option-${active.id}` : undefined}
aria-describedby={`${id}-status`}
autoComplete="off"
placeholder="Start typing, e.g. fruit or leaf"
value={term}
onChange={(event) => {
setTerm(event.target.value);
setOpen(true);
setActiveIndex(-1);
}}
onKeyDown={onKeyDown}
/>
</div>
<div id={`${id}-status`} className="crop-picker-hint" role="status">
{showing
? `${results.length} ${results.length === 1 ? 'part' : 'parts'} found. Use the up and down arrow keys, then Enter, to choose one.`
: 'Type to search, then choose a plant part.'}
</div>
{noMatch && (
<div className="crop-picker-empty">
<i className="fas fa-info-circle" aria-hidden="true" /> No plant parts match &ldquo;{term}&rdquo;.
</div>
)}
{showing && (
<ul id={listId} ref={list} role="listbox" aria-label="Matching plant parts" className="crop-picker-options shadow">
{results.map((part, index) => (
<li
key={part.id}
id={`${id}-option-${part.id}`}
role="option"
aria-selected={index === activeIndex}
className={`crop-picker-option${index === activeIndex ? ' crop-picker-option-active' : ''}`}
// mousedown, not click, so the input keeps focus while we choose.
onMouseDown={(event) => {
event.preventDefault();
onChoose(part);
}}
onMouseEnter={() => setActiveIndex(index)}
>
{part.name}
{!term.trim() && part.id === suggestedId && <span className="crop-picker-usual"> · usual for this crop</span>}
</li>
))}
</ul>
)}
</div>
);
}