mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
feat(print-modal): show each printer slot's colour in the filament mapping (issue #3159)
The Print / Schedule dialog's filament mapping is where the colour a slice asked for is compared against the colour actually loaded, and only the left-hand side of that comparison had a swatch. The slot, and every slot in its dropdown, was text -- and the text cannot be trusted: a slot's colour name is resolved from the Color Catalog or, failing that, from hue, so a third-party beige is announced as "Orange". A "Color mismatch" warning then gives no way to tell a real mismatch from two names for the same hex without opening the printer card in another tab, which on a farm swapping twenty or thirty non-Bambu colours between machines is a check made many times a day. Each slot now carries its colour and its hex, and the slot whose colour is exactly the one the slice asked for is ticked. This works for a slot bound to an inventory spool and for one configured through Configure Slot or on the printer itself: the second kind has no inventory row behind it, and the printer's own tray colour is then what draws. A bound spool contributes what a tray record cannot -- SlotSpoolIdentity gains extra_colors and effect_type, so a two-tone or glittery spool draws as itself rather than as its base colour. The same treatment goes to the filament-override picker used for model-based assignment. It is the same choice on the other dispatch path, and leaving it text-only would have made one decision read two ways. Both controls stop being <select>s to do it, because an <option> renders text and nothing else. SlotPicker keeps what the select gave for free -- arrow, Home/End, Enter and Escape keys, listbox semantics, and the border colouring that encodes match, same-type-different-colour and not-loaded -- and is portaled with position:fixed so it is not clipped by the dialog's own scroll container, flipping above the row when there is no room below.
This commit is contained in:
@@ -5,8 +5,10 @@ import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palett
|
||||
import { api } from '../../api/client';
|
||||
import type { SlotSpoolIdentity } from '../../api/client';
|
||||
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
|
||||
import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
|
||||
import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE, normalizeColorForCompare } from '../../utils/amsHelpers';
|
||||
import { disambiguateColorNames, getColorName } from '../../utils/colors';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
import { displayHex } from './displayHex';
|
||||
import { useFilamentLabels } from './useFilamentLabels';
|
||||
import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
|
||||
import type { FilamentMappingProps, RackGroupInfo } from './types';
|
||||
@@ -490,11 +492,32 @@ export function FilamentMapping({
|
||||
</span>
|
||||
{/* Arrow */}
|
||||
<span className="text-bambu-gray">→</span>
|
||||
{/* Slot selector dropdown */}
|
||||
<select
|
||||
value={item.loaded?.globalTrayId ?? ''}
|
||||
onChange={(e) => handleSlotChange(slotId, e.target.value)}
|
||||
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
|
||||
{/*
|
||||
Slot picker. Not a <select>: an <option> renders text only,
|
||||
and the colour of the slot is the whole point of this row
|
||||
(#3159) -- colour names come from the catalogue or from hue,
|
||||
so a third-party beige reads as "Orange" and the mismatch
|
||||
warning cannot be judged without seeing both swatches.
|
||||
|
||||
#1722: every loaded slot is offered for every filament row,
|
||||
regardless of which extruder the slot is wired to. Before that
|
||||
change a slot was only listed when its extruder matched the
|
||||
filament's slicer-assigned nozzle (item.nozzle_id), which
|
||||
locked users out of cross-extruder picks even when they'd
|
||||
intentionally loaded the required filament into the "other"
|
||||
AMS. The L/R badge on the filament row still tells the user
|
||||
what the slicer planned; the picker trusts the user to pick
|
||||
based on their physical setup. Printer firmware accepts or
|
||||
rejects the ams_mapping at start-print -- failure is loud,
|
||||
not silent.
|
||||
*/}
|
||||
<SlotPicker
|
||||
value={item.loaded?.globalTrayId != null ? String(item.loaded.globalTrayId) : ''}
|
||||
onChange={(next) => handleSlotChange(slotId, next)}
|
||||
placeholder={`-- ${t('printModal.selectSlot')} --`}
|
||||
ariaLabel={t('printModal.slotForFilament', { name: resolvedName })}
|
||||
exactMatchLabel={t('printModal.exactColorMatch')}
|
||||
className={`${
|
||||
item.status === 'match'
|
||||
? 'border-bambu-green/50 text-bambu-green'
|
||||
: item.status === 'type_only'
|
||||
@@ -502,24 +525,7 @@ export function FilamentMapping({
|
||||
: 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
|
||||
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
|
||||
title={item.isManual ? t('printModal.manuallySelected') : t('printModal.autoMatched')}
|
||||
>
|
||||
<option value="" className="bg-bambu-dark text-bambu-gray">
|
||||
-- {t('printModal.selectSlot')} --
|
||||
</option>
|
||||
{/*
|
||||
#1722: every loaded slot is offered for every filament row,
|
||||
regardless of which extruder the slot is wired to. Before this
|
||||
change a slot was only listed when its extruder matched the
|
||||
filament's slicer-assigned nozzle (item.nozzle_id), which
|
||||
locked users out of cross-extruder picks even when they'd
|
||||
intentionally loaded the required filament into the "other"
|
||||
AMS. The L/R badge on the filament row still tells the user
|
||||
what the slicer planned; the dropdown now trusts the user to
|
||||
pick based on their physical setup. Printer firmware accepts
|
||||
or rejects the ams_mapping at start-print — failure is loud,
|
||||
not silent.
|
||||
*/}
|
||||
{loadedFilaments.map((f) => {
|
||||
options={loadedFilaments.map((f) => {
|
||||
const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
|
||||
const remainingLabel = remainingWeight != null
|
||||
? t('printModal.slotRemainingShort', {
|
||||
@@ -536,13 +542,24 @@ export function FilamentMapping({
|
||||
// two views agree. Not translated: L and R are the letters on
|
||||
// the machine.
|
||||
const ftsBadge = ftsInlet == null ? '' : ` [${FTS_INLET_SIDE[ftsInlet]}]`;
|
||||
return (
|
||||
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
|
||||
{f.label}: {f.spoolName || f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
|
||||
</option>
|
||||
);
|
||||
return {
|
||||
value: String(f.globalTrayId),
|
||||
label: `${f.label}: ${f.spoolName || f.traySubBrands || f.type} (${f.colorName})`,
|
||||
meta: `${remainingLabel}${ftsBadge}`,
|
||||
rgba: f.color,
|
||||
extraColors: f.extraColors,
|
||||
effectType: f.effectType,
|
||||
subtype: f.spoolSubtype,
|
||||
hex: displayHex(f.color),
|
||||
// The hex the slice asked for, against the hex the
|
||||
// printer reports -- the comparison the colour names
|
||||
// cannot be trusted to make.
|
||||
exactMatch:
|
||||
normalizeColorForCompare(item.color) !== '' &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(item.color),
|
||||
};
|
||||
})}
|
||||
</select>
|
||||
/>
|
||||
{/* Status icon */}
|
||||
{item.status === 'match' ? (
|
||||
<Check className="w-3 h-3 text-bambu-green" />
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Circle, RotateCcw, Palette } from 'lucide-react';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
import { canonicalFilamentType } from '../../utils/amsHelpers';
|
||||
import { canonicalFilamentType, normalizeColorForCompare } from '../../utils/amsHelpers';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
import { displayHex } from './displayHex';
|
||||
import { useFilamentLabels } from './useFilamentLabels';
|
||||
import type { FilamentReqsData } from './types';
|
||||
|
||||
@@ -123,30 +125,42 @@ export function FilamentOverride({
|
||||
</span>
|
||||
{/* Arrow */}
|
||||
<span className="text-bambu-gray">→</span>
|
||||
{/* Override dropdown — only compatible (same-type) filaments */}
|
||||
<select
|
||||
{/* Override picker — only compatible (same-type) filaments.
|
||||
Carries a swatch for the same reason the mapping row does
|
||||
(#3159): this is the model-mode half of the same choice,
|
||||
and leaving it text-only would make the two paths read
|
||||
differently for one decision. Model mode pools filaments
|
||||
across every printer of the model, so there is no slot
|
||||
binding behind an entry and the swatch is the plain tray
|
||||
colour -- solid, with no multi-colour stops to draw. */}
|
||||
<SlotPicker
|
||||
value={isOverridden ? `${override.type}|${override.color}` : ''}
|
||||
onChange={(e) => handleChange(req.slot_id, e.target.value)}
|
||||
onChange={(next) => handleChange(req.slot_id, next)}
|
||||
placeholder={`${t('printModal.originalFilament')}: ${resolvedName} (${colorLabel})`}
|
||||
placeholderOption={{ rgba: req.color, hex: displayHex(req.color) }}
|
||||
disabled={compatible.length === 0}
|
||||
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
|
||||
ariaLabel={t('printModal.overrideForFilament', { name: resolvedName })}
|
||||
exactMatchLabel={t('printModal.exactColorMatch')}
|
||||
className={
|
||||
isOverridden
|
||||
? 'border-blue-500/50 dark:border-blue-400/50 text-blue-700 dark:text-blue-400'
|
||||
: 'border-bambu-gray/30 text-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<option value="" className="bg-bambu-dark text-bambu-gray">
|
||||
{t('printModal.originalFilament')}: {resolvedName} ({colorLabel})
|
||||
</option>
|
||||
{compatible.map((f, idx) => (
|
||||
<option
|
||||
key={`${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`}
|
||||
value={`${f.type}|${f.color}`}
|
||||
className="bg-bambu-dark text-white"
|
||||
>
|
||||
{f.tray_sub_brands || f.type} ({getColorName(f.color, f.tray_sub_brands)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
options={compatible.map((f, idx) => ({
|
||||
// The value has to stay `TYPE|COLOR` -- `handleChange`
|
||||
// parses it -- but two spools of the same type and colour
|
||||
// on different printers collapse to one value, so the key
|
||||
// keeps the sub-brand and the index as it did before.
|
||||
value: `${f.type}|${f.color}`,
|
||||
key: `${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`,
|
||||
label: `${f.tray_sub_brands || f.type} (${getColorName(f.color, f.tray_sub_brands)})`,
|
||||
rgba: f.color,
|
||||
hex: displayHex(f.color),
|
||||
exactMatch:
|
||||
normalizeColorForCompare(req.color) !== '' &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color),
|
||||
}))}
|
||||
/>
|
||||
{/* Reset button */}
|
||||
{isOverridden ? (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
import { FilamentSwatch } from '../FilamentSwatch';
|
||||
|
||||
/**
|
||||
* One-line filament picker that can show a colour, which a `<select>` cannot.
|
||||
*
|
||||
* The Print / Schedule dialog is where the slicer's colour is compared against
|
||||
* what is actually in the machine, and until now the right-hand side of that
|
||||
* comparison was text only (#3159). Colour names are not a substitute: they are
|
||||
* resolved from the Color Catalog or from hue, so a third-party beige reads as
|
||||
* "Orange" and a "Color mismatch" warning gives no way to tell a real mismatch
|
||||
* from two names for the same hex. `<option>` renders text and nothing else, so
|
||||
* the swatch means this cannot be a native select.
|
||||
*
|
||||
* What is kept from the select it replaces: full keyboard operation (arrows,
|
||||
* Home/End, Enter, Escape), `role="listbox"` semantics, and the caller's own
|
||||
* border colouring, which encodes match / type-only / mismatch on the mapping
|
||||
* row and overridden / original on the override row.
|
||||
*
|
||||
* Deliberately not a shared "Select": the rows are a swatch, a name, a hex and
|
||||
* a badge, and this is the second such control in the app after
|
||||
* `spool-form/PresetPicker` — which documents the same reasoning. A shared
|
||||
* abstraction over two call sites with different row shapes would be guessing
|
||||
* at the third.
|
||||
*/
|
||||
|
||||
export interface SlotPickerOption {
|
||||
/** The value handed back to `onChange`; mirrors the old `<option value>`. */
|
||||
value: string;
|
||||
/** Main text of the row. */
|
||||
label: string;
|
||||
/** RRGGBBAA (or RRGGBB) without `#`. Absent renders no swatch at all rather
|
||||
* than a grey one, so "no colour known" never looks like a grey spool. */
|
||||
rgba?: string | null;
|
||||
/** Comma-separated extra hex stops for a multi-colour spool. */
|
||||
extraColors?: string | null;
|
||||
/** Effect overlay name, as `FilamentSwatch` understands it. */
|
||||
effectType?: string | null;
|
||||
/** Filament subtype; `Multicolor` renders the stops as a conic sweep. */
|
||||
subtype?: string | null;
|
||||
/** Muted suffix after the label — remaining grams, FTS inlet badge. */
|
||||
meta?: string;
|
||||
/** Shown in the dropdown row so two similar colours can be told apart by
|
||||
* their hex rather than by two names that resolved the same way. */
|
||||
hex?: string | null;
|
||||
/** Marks the row whose colour is exactly the one the slice asked for. */
|
||||
exactMatch?: boolean;
|
||||
/** React key, when `value` is not unique. Model-mode pools filaments across
|
||||
* every printer of a model, so two spools of the same type and colour
|
||||
* collapse to one `TYPE|COLOR` value while still being two rows. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface SlotPickerProps {
|
||||
/** Currently chosen option value, or '' for the placeholder row. */
|
||||
value: string;
|
||||
options: SlotPickerOption[];
|
||||
/** What the '' row reads as. */
|
||||
placeholder: string;
|
||||
/** Colour for the '' row, when it has one (the override panel's '' row is
|
||||
* the 3MF's own filament, which does have a colour). */
|
||||
placeholderOption?: Omit<SlotPickerOption, 'value' | 'label'>;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
/** Native tooltip on the trigger. */
|
||||
title?: string;
|
||||
/** Border / text classes the caller uses to encode status. */
|
||||
className?: string;
|
||||
/** Tooltip for the exact-match marker. */
|
||||
exactMatchLabel?: string;
|
||||
}
|
||||
|
||||
const SWATCH_CLASS = 'w-3 h-3';
|
||||
|
||||
export function SlotPicker({
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
placeholderOption,
|
||||
onChange,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
title,
|
||||
className = '',
|
||||
exactMatchLabel,
|
||||
}: SlotPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [coords, setCoords] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The placeholder is row 0, so an index addresses both it and the options
|
||||
// with one number — which is what the arrow keys and aria-activedescendant
|
||||
// need.
|
||||
const rows: SlotPickerOption[] = [
|
||||
{ ...(placeholderOption ?? {}), value: '', label: placeholder },
|
||||
...options,
|
||||
];
|
||||
const selectedIndex = Math.max(
|
||||
0,
|
||||
rows.findIndex((r) => r.value === value),
|
||||
);
|
||||
const selected = rows[selectedIndex];
|
||||
|
||||
const place = useCallback(() => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Flip above when the row sits low enough that a downward list would run
|
||||
// off the viewport. The dialog body scrolls, so a mapping row near its
|
||||
// bottom is the ordinary case, not the edge one.
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
const height = Math.min(listRef.current?.scrollHeight ?? 0, 240);
|
||||
const flip = below < height + 8 && rect.top > below;
|
||||
setCoords({
|
||||
top: flip ? Math.max(8, rect.top - height - 4) : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) place();
|
||||
}, [open, place]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (triggerRef.current?.contains(target) || listRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
// Escape closes this control WITHOUT closing the dialog around it, which a
|
||||
// bare document listener would otherwise let happen — same guard
|
||||
// `PresetPicker` documents.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
// The list is portaled to <body>, so it does not travel with the dialog's
|
||||
// own scroll container: follow it rather than leaving it behind.
|
||||
const reposition = () => place();
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('scroll', reposition, true);
|
||||
window.addEventListener('resize', reposition);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
const choose = (index: number) => {
|
||||
onChange(rows[index].value);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const openAt = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleTriggerKey = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (!open) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openAt(selectedIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(rows.length - 1, i + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(0, i - 1));
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(0);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(rows.length - 1);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
choose(activeIndex);
|
||||
} else if (e.key === 'Tab') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const listId = `slot-picker-${ariaLabel.replace(/\W+/g, '-')}`;
|
||||
|
||||
const renderSwatch = (row: SlotPickerOption) =>
|
||||
row.rgba ? (
|
||||
<FilamentSwatch
|
||||
rgba={row.rgba}
|
||||
extraColors={row.extraColors}
|
||||
effectType={row.effectType}
|
||||
subtype={row.subtype}
|
||||
className={SWATCH_CLASS}
|
||||
effectSize="table"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
// On the combobox, not on the listbox: focus stays on the trigger
|
||||
// while the list is open, and this is what tells a screen reader which
|
||||
// row the arrow keys are on.
|
||||
aria-activedescendant={open ? `${listId}-${activeIndex}` : undefined}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => (open ? setOpen(false) : openAt(selectedIndex))}
|
||||
onKeyDown={handleTriggerKey}
|
||||
className={`flex-1 min-w-0 flex items-center gap-1.5 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary text-left focus:outline-none focus:ring-1 focus:ring-bambu-green disabled:opacity-50 ${className}`}
|
||||
>
|
||||
{renderSwatch(selected)}
|
||||
<span className="flex-1 min-w-0 truncate">
|
||||
{selected.label}
|
||||
{selected.meta ? <span className="text-bambu-gray">{selected.meta}</span> : null}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 shrink-0 opacity-70" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed z-[60] max-h-60 overflow-y-auto rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary p-1 shadow-xl"
|
||||
style={{
|
||||
top: coords?.top ?? -9999,
|
||||
left: coords?.left ?? -9999,
|
||||
width: coords?.width,
|
||||
// Hide until placed, so there is no flash at (-9999,-9999) —
|
||||
// same trick FilamentHoverCard uses.
|
||||
visibility: coords ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{rows.map((row, index) => (
|
||||
<button
|
||||
key={row.key ?? `${row.value}-${index}`}
|
||||
id={`${listId}-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
// What `<option value>` carried before this was a listbox: the
|
||||
// identity of the choice, separate from how it reads.
|
||||
data-value={row.value}
|
||||
aria-selected={index === selectedIndex}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => choose(index)}
|
||||
className={`w-full flex items-center gap-1.5 text-left px-2 py-1.5 rounded-md text-xs ${
|
||||
index === selectedIndex
|
||||
? 'bg-bambu-green/15 text-bambu-green'
|
||||
: index === activeIndex
|
||||
? 'bg-bambu-dark text-white'
|
||||
: 'text-white'
|
||||
}`}
|
||||
>
|
||||
{renderSwatch(row)}
|
||||
<span className="flex-1 min-w-0 truncate" title={row.label}>
|
||||
{row.label}
|
||||
{row.meta ? <span className="text-bambu-gray">{row.meta}</span> : null}
|
||||
</span>
|
||||
{row.hex ? (
|
||||
<span className="shrink-0 font-mono text-[10px] uppercase text-bambu-gray">{row.hex}</span>
|
||||
) : null}
|
||||
{row.exactMatch ? (
|
||||
<span title={exactMatchLabel} className="shrink-0 text-bambu-green">
|
||||
<Check className="w-3 h-3" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlotPicker;
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The hex to print next to a swatch in the slot picker (#3159).
|
||||
*
|
||||
* Shown because two slots whose colour names resolved the same way -- both
|
||||
* "Orange", one a beige -- are otherwise indistinguishable in the list, and
|
||||
* comparing them against the slice's colour is the reason the picker exists.
|
||||
*
|
||||
* Alpha is kept only when it carries information: a translucent spool prints
|
||||
* all eight digits, an opaque one prints six, so a Clear filament is not shown
|
||||
* as if it were solid. Returns null for anything unreadable, and the caller
|
||||
* then prints no hex rather than a misleading one.
|
||||
*/
|
||||
export function displayHex(color: string | null | undefined): string | null {
|
||||
if (!color) return null;
|
||||
const clean = color.trim().replace(/^#/, '');
|
||||
if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(clean)) return null;
|
||||
const keepAlpha = clean.length === 8 && clean.substring(6, 8).toLowerCase() !== 'ff';
|
||||
return `#${(keepAlpha ? clean : clean.substring(0, 6)).toUpperCase()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user