feat(slice-modal): Bundle tier for picking presets from imported .bbscfg

Closes the loop on the bundle work: users who imported a Printer
  Preset Bundle via Settings → Slicer Bundles can now pick it in the
  SliceModal and slice through the bundle dispatch path the backend
  already supports.

  UX:
  - New "Slicer bundle" picker at the top of the modal, rendered only
    when at least one bundle is imported (GET /slicer/bundles non-empty)
  - Selecting a bundle replaces cloud/local/standard preset dropdowns
    with bundle-scoped pickers (process + per-slot filament names from
    the bundle). Printer is implicit (each .bbscfg has exactly one).
  - Submit routes through SliceRequest.bundle so the backend skips
    PresetRef resolution and asks the sidecar to materialise the JSON
    triplet from the stored bundle by name.
  - "None" leaves the modal on the original preset triplet path.

  Frontend types: SliceBundleSpec + bundle?: SliceBundleSpec on SliceRequest.
This commit is contained in:
maziggy
2026-05-06 09:55:14 +02:00
parent 7e1105dcb6
commit a50958e426
7 changed files with 924 additions and 478 deletions
@@ -26,6 +26,7 @@ vi.mock('../../api/client', () => ({
getArchivePlates: vi.fn(),
getLibraryFileFilamentRequirements: vi.fn(),
getArchiveFilamentRequirements: vi.fn(),
listSlicerBundles: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
},
@@ -40,6 +41,7 @@ const mockApi = api as unknown as {
getArchivePlates: ReturnType<typeof vi.fn>;
getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
listSlicerBundles: ReturnType<typeof vi.fn>;
};
function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -119,6 +121,10 @@ describe('SliceModal', () => {
plate_id: 1,
filaments: [],
});
// Default: no bundles imported. Bundle-tier tests override this with a
// populated array; everything else inherits the empty default so the
// modal renders the original (preset-only) layout.
mockApi.listSlicerBundles.mockResolvedValue([]);
});
it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -957,4 +963,171 @@ describe('SliceModal', () => {
expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-GREY' });
});
});
// -------------------------------------------------------------------------
// Bundle tier — picking an imported .bbscfg replaces the cloud/local/standard
// dropdown set with bundle-scoped pickers and routes the slice through the
// backend's bundle dispatch shape (no PresetRefs in the body).
// -------------------------------------------------------------------------
describe('Bundle tier', () => {
const sampleBundle = {
id: 'abc123def456abcd',
printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
printer: ['# Bambu Lab H2D 0.4 nozzle'],
process: [
'# 0.20mm Standard @BBL H2D',
'# 0.16mm Standard @BBL H2D',
],
filament: [
'# Bambu PLA Basic @BBL H2D',
'# Bambu PETG HF @BBL H2D 0.4 nozzle',
],
version: '02.06.00.50',
};
it('hides the bundle picker when no bundles are imported', async () => {
// Default beforeEach already returns []; assert the picker isn't
// rendered so users without bundles see the original layout.
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
onClose: vi.fn(),
});
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
expect(screen.queryByText(/slicer bundle/i)).toBeNull();
});
it('renders the bundle picker when at least one bundle is imported', async () => {
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
onClose: vi.fn(),
});
await waitFor(() =>
expect(screen.getByText(/slicer bundle/i)).toBeDefined(),
);
// The bundle option is in the dropdown.
const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
expect(
Array.from(bundleSelect.options).map((o) => o.textContent),
).toContain('# Bambu Lab H2D 0.4 nozzle');
});
it('replaces preset dropdowns with bundle-scoped pickers when a bundle is selected', async () => {
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
onClose: vi.fn(),
});
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
const user = userEvent.setup();
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
// First select is the bundle picker (new top-of-modal dropdown).
await user.selectOptions(selects[0], sampleBundle.id);
// Wait for the bundle-mode UI to take over: process options should
// now reflect the bundle's process names.
await waitFor(() => {
expect(
screen.getByText('# 0.20mm Standard @BBL H2D'),
).toBeDefined();
});
// The static printer label shows the bundle's printer. Both the
// <option> in the bundle picker and the read-only <div> below
// contain this text, so use getAllByText.
const printerNameMatches = screen.getAllByText('# Bambu Lab H2D 0.4 nozzle');
expect(printerNameMatches.length).toBeGreaterThanOrEqual(2);
// Cloud/local/standard preset names from the original tier no longer
// appear in the visible dropdowns (the bundle replaced them).
const visibleSelects = screen.getAllByRole('combobox') as HTMLSelectElement[];
const allOptionTexts = visibleSelects.flatMap((sel) =>
Array.from(sel.options).map((o) => o.textContent ?? ''),
);
// Cloud printer name shouldn't be in any visible dropdown anymore.
expect(allOptionTexts).not.toContain('My Custom X1C');
});
it('submits bundle dispatch shape (no PresetRefs) when a bundle is selected', async () => {
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
mockApi.sliceLibraryFile.mockResolvedValue({
job_id: 99,
status: 'pending',
status_url: '/api/v1/slice-jobs/99',
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
onClose: vi.fn(),
});
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
const user = userEvent.setup();
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
await user.selectOptions(selects[0], sampleBundle.id);
// Wait for bundle-mode dropdowns to render.
await waitFor(() =>
expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
);
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
await waitFor(() => {
const [fileId, body] = mockApi.sliceLibraryFile.mock.calls[0];
expect(fileId).toBe(100);
expect(body.bundle).toEqual({
bundle_id: sampleBundle.id,
printer_name: '# Bambu Lab H2D 0.4 nozzle',
process_name: '# 0.20mm Standard @BBL H2D',
filament_names: ['# Bambu PLA Basic @BBL H2D'],
});
// The preset triplet must NOT be in the body — bundle dispatch
// skips PresetRef resolution entirely on the backend.
expect(body.printer_preset).toBeUndefined();
expect(body.process_preset).toBeUndefined();
expect(body.filament_presets).toBeUndefined();
});
});
it('switching back to "None" restores the preset triplet path', async () => {
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
mockApi.sliceLibraryFile.mockResolvedValue({
job_id: 100,
status: 'pending',
status_url: '/api/v1/slice-jobs/100',
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
onClose: vi.fn(),
});
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
const user = userEvent.setup();
const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
await user.selectOptions(bundleSelect, sampleBundle.id);
await waitFor(() =>
expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
);
// Flip back to None.
await user.selectOptions(bundleSelect, '');
await waitFor(() => {
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
// After de-selecting bundle, the printer dropdown's first option
// should be one of the original cloud/local/standard names.
const printerOptions = Array.from(selects[1].options).map((o) => o.textContent);
expect(printerOptions).toContain('My Custom X1C');
});
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
await waitFor(() => {
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
expect(body.bundle).toBeUndefined();
expect(body.printer_preset).toBeDefined();
});
});
});
});
+12
View File
@@ -1155,6 +1155,13 @@ export interface PresetRef {
source: PresetSource;
id: string;
}
export interface SliceBundleSpec {
bundle_id: string;
printer_name: string;
process_name: string;
// Per-slot filament names in plate order. Index 0 = slot 1, etc.
filament_names: string[];
}
export interface SliceRequest {
printer_preset_id?: number;
process_preset_id?: number;
@@ -1167,6 +1174,11 @@ export interface SliceRequest {
// backend validator promotes a singular into a one-element list when this
// is omitted, so legacy single-color clients keep working unchanged.
filament_presets?: PresetRef[];
// Bundle dispatch: when set, the backend skips PresetRef resolution and
// picks the JSON triplet from a sidecar-stored .bbscfg by name. Mutually
// exclusive with the preset fields above (validator accepts both, but
// dispatch ignores the preset side when bundle is set).
bundle?: SliceBundleSpec;
plate?: number;
export_3mf?: boolean;
}
+308 -47
View File
@@ -1,4 +1,4 @@
import { Cloud, CloudOff, Cog, Loader2, X } from 'lucide-react';
import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -6,7 +6,10 @@ import {
api,
type PresetRef,
type PresetSource,
type SliceBundleSpec,
type SliceJobProgress,
type SliceRequest,
type SlicerBundle,
type SlicerCloudStatus,
type UnifiedPreset,
type UnifiedPresetsBySlot,
@@ -245,6 +248,14 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// entry per AMS slot the plate uses. Pre-pick (effect below) initialises
// each slot from the source plate's required (type, colour).
const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
// Bundle dispatch (alternative to the preset triplet). When non-null, the
// SliceModal hides the cloud/local/standard preset dropdowns and shows
// bundle-scoped pickers (process + per-slot filament from the chosen
// bundle's contents). Submit routes through the backend's bundle dispatch
// (`SliceRequest.bundle`) which skips PresetRef resolution.
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
const [bundleProcessName, setBundleProcessName] = useState<string | null>(null);
const [bundleFilamentNames, setBundleFilamentNames] = useState<(string | null)[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// null = plate not yet picked (or single-plate / non-3MF — picker is skipped
// and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
@@ -321,6 +332,24 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
enabled: !platesQuery.isLoading && !needsPlatePicker,
});
// Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
// configured / no bundles imported yet; the bundle picker hides itself
// in that case so users without bundles see the original modal layout.
const bundlesQuery = useQuery({
queryKey: ['slicerBundles'],
queryFn: api.listSlicerBundles,
staleTime: 60_000,
enabled: !platesQuery.isLoading && !needsPlatePicker,
// Bundle listing is a hard 503 when the sidecar is offline; don't
// retry tight loops in that case.
retry: false,
});
const selectedBundle: SlicerBundle | null = useMemo(() => {
if (!selectedBundleId || !bundlesQuery.data) return null;
return bundlesQuery.data.find((b) => b.id === selectedBundleId) ?? null;
}, [selectedBundleId, bundlesQuery.data]);
const isBundleMode = selectedBundle != null;
// Printer / process pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when
// presets first arrive; subsequent re-renders preserve any manual choice.
useEffect(() => {
@@ -348,29 +377,82 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
});
}, [presetsQuery.data, filamentSlots]);
// Bundle-mode auto-pick: when the user picks a bundle (or the slot count
// changes after the picker is open), default the process to the bundle's
// first listed process and every filament slot to the bundle's first
// listed filament. Plain string match — bundles store delta files keyed
// by user preset name, no scoring needed since the user picks per-slot
// afterwards if the default is wrong.
useEffect(() => {
if (!selectedBundle) {
// Reset bundle picks when bundle is cleared so re-selection
// re-defaults rather than carrying stale values.
setBundleProcessName(null);
setBundleFilamentNames([]);
return;
}
setBundleProcessName((current) => {
// Preserve a manual pick if it still exists in the bundle; otherwise
// re-default. Same shape as the preset auto-pick effect above.
if (current && selectedBundle.process.includes(current)) return current;
return selectedBundle.process[0] ?? null;
});
setBundleFilamentNames((current) => {
if (current.length === filamentSlots.length && current.every((n) => n != null)) {
return current;
}
const fallback = selectedBundle.filament[0] ?? null;
return filamentSlots.map((_, i) => current[i] ?? fallback);
});
}, [selectedBundle, filamentSlots]);
const enqueueMutation = useMutation({
mutationFn: async () => {
if (
!printerPreset ||
!processPreset ||
filamentPresets.length === 0 ||
filamentPresets.some((r) => r == null)
) {
throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
let body: SliceRequest;
if (isBundleMode) {
// Bundle dispatch path. The selected bundle's first printer is the
// implicit printer choice (every .bbscfg carries exactly one).
if (
!selectedBundle ||
!bundleProcessName ||
bundleFilamentNames.length === 0 ||
bundleFilamentNames.some((n) => n == null)
) {
throw new Error(t('slice.bundleAllRequired', 'Bundle process and every filament slot must be picked'));
}
const bundleSpec: SliceBundleSpec = {
bundle_id: selectedBundle.id,
printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name,
process_name: bundleProcessName,
filament_names: bundleFilamentNames as string[],
};
body = {
bundle: bundleSpec,
...(selectedPlate != null ? { plate: selectedPlate } : {}),
};
} else {
if (
!printerPreset ||
!processPreset ||
filamentPresets.length === 0 ||
filamentPresets.some((r) => r == null)
) {
throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
}
body = {
printer_preset: printerPreset,
process_preset: processPreset,
// The first slot also goes into the legacy singular field so the
// backend's older callers / clients keep behaving the same — the
// backend validator prefers `filament_presets` when both are set.
filament_preset: filamentPresets[0] as PresetRef,
filament_presets: filamentPresets as PresetRef[],
// Always send a concrete plate number when the source is multi-plate;
// omit otherwise so the backend default applies for STL / single-plate
// 3MF sources where the concept doesn't apply.
...(selectedPlate != null ? { plate: selectedPlate } : {}),
};
}
const body = {
printer_preset: printerPreset,
process_preset: processPreset,
// The first slot also goes into the legacy singular field so the
// backend's older callers / clients keep behaving the same — the
// backend validator prefers `filament_presets` when both are set.
filament_preset: filamentPresets[0] as PresetRef,
filament_presets: filamentPresets as PresetRef[],
// Always send a concrete plate number when the source is multi-plate;
// omit otherwise so the backend default applies for STL / single-plate
// 3MF sources where the concept doesn't apply.
...(selectedPlate != null ? { plate: selectedPlate } : {}),
};
if (source.kind === 'libraryFile') {
return api.sliceLibraryFile(source.id, body);
}
@@ -392,10 +474,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// is desktop-Studio only. If we can match the source's printer model to a
// SliceModal-known model and the user's chosen printer profile names a
// different model, surface a warning before they click Slice.
//
// For bundle mode, the bundle's printer_preset_name plays the same role
// as the picked PresetRef's resolved name in preset mode.
const sourcePrinterModel = platesQuery.data?.source_printer_model ?? null;
const printerProfileName = printerPreset
? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
: null;
const printerProfileName = isBundleMode
? selectedBundle?.printer_preset_name.replace(/^# /, '') ?? null
: printerPreset
? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
: null;
// Profile names follow `<model> <nozzle> nozzle` (e.g. "Bambu Lab H2D 0.4
// nozzle"). The CLI compat check uses the model prefix; substring match
// catches both standard and locally-imported user-named profiles that
@@ -417,13 +504,19 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// auto-pick fills these once filamentSlots arrives)
// - no printer-mismatch warning is up (clicking would silently fall
// back to embedded settings and produce a wrong-printer file)
const isReady =
printerPreset != null &&
processPreset != null &&
filamentReqsQuery.isSuccess &&
filamentPresets.length > 0 &&
filamentPresets.every((r) => r != null) &&
!printerMismatch;
const isReady = isBundleMode
? selectedBundle != null &&
bundleProcessName != null &&
filamentReqsQuery.isSuccess &&
bundleFilamentNames.length > 0 &&
bundleFilamentNames.every((n) => n != null) &&
!printerMismatch
: printerPreset != null &&
processPreset != null &&
filamentReqsQuery.isSuccess &&
filamentPresets.length > 0 &&
filamentPresets.every((r) => r != null) &&
!printerMismatch;
const isEnqueuing = enqueueMutation.isPending;
// Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
@@ -501,22 +594,63 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
{presetsQuery.data && (
<>
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
<PresetDropdown
label={t('slice.printer', 'Printer profile')}
slot="printer"
data={presetsQuery.data}
value={printerPreset}
onChange={setPrinterPreset}
disabled={isEnqueuing}
/>
<PresetDropdown
label={t('slice.process', 'Process profile')}
slot="process"
data={presetsQuery.data}
value={processPreset}
onChange={setProcessPreset}
disabled={isEnqueuing}
/>
{/* Bundle picker — only renders when at least one .bbscfg has
been imported via Settings → Slicer Bundles. Lets the user
trade the cloud/local/standard tier for a single curated
triplet from a previously-uploaded BambuStudio bundle. */}
{bundlesQuery.data && bundlesQuery.data.length > 0 && (
<BundlePicker
bundles={bundlesQuery.data}
selectedId={selectedBundleId}
onChange={setSelectedBundleId}
disabled={isEnqueuing}
/>
)}
{/* Preset triplet — hidden when a bundle is selected so the
user only sees one tier at a time. The bundle's process +
filament dropdowns render below in their stead. */}
{!isBundleMode && (
<>
<PresetDropdown
label={t('slice.printer', 'Printer profile')}
slot="printer"
data={presetsQuery.data}
value={printerPreset}
onChange={setPrinterPreset}
disabled={isEnqueuing}
/>
<PresetDropdown
label={t('slice.process', 'Process profile')}
slot="process"
data={presetsQuery.data}
value={processPreset}
onChange={setProcessPreset}
disabled={isEnqueuing}
/>
</>
)}
{isBundleMode && selectedBundle && (
<>
{/* Bundle's printer is implicit (each .bbscfg has exactly
one). Show it as a read-only label so the user can
verify the printer they're slicing for. */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('slice.printer', 'Printer profile')}
</label>
<div className="px-3 py-2 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary text-white text-sm">
{selectedBundle.printer_preset_name}
</div>
</div>
<BundleStringDropdown
label={t('slice.process', 'Process profile')}
options={selectedBundle.process}
value={bundleProcessName}
onChange={setBundleProcessName}
disabled={isEnqueuing}
/>
</>
)}
{/* Filament reqs may need a server-side preview-slice for
unsliced project files (single-pass, then cached). Show a
scoped spinner so the user sees the printer/process
@@ -526,6 +660,40 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
requestId={previewRequestId}
sourceName={source.filename}
/>
) : isBundleMode && selectedBundle ? (
filamentSlots.map((slot, idx) => {
const isUsed = slot.used_in_plate !== false;
const baseLabel =
filamentSlots.length > 1
? t('slice.filamentSlot', {
index: idx + 1,
type: slot.type,
defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
})
: t('slice.filament', 'Filament profile');
const label = isUsed
? baseLabel
: `${baseLabel} ${t('slice.notUsedByPlate', '— not used by this plate')}`;
return (
<BundleStringDropdown
key={`bundle-filament-${idx}`}
label={label}
options={selectedBundle.filament}
value={bundleFilamentNames[idx] ?? null}
onChange={(name) =>
setBundleFilamentNames((current) => {
const next = current.length === filamentSlots.length
? [...current]
: filamentSlots.map((_, i) => current[i] ?? null);
next[idx] = name;
return next;
})
}
disabled={isEnqueuing || !isUsed}
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
/>
);
})
) : (
filamentSlots.map((slot, idx) => {
// Slots flagged by the backend as not used by the
@@ -734,3 +902,96 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchCo
</label>
);
}
// Top-of-modal bundle picker. The "None" option leaves the user on the
// cloud/local/standard tier path; selecting a bundle id flips the modal
// into bundle dispatch mode (see SliceModal state above).
interface BundlePickerProps {
bundles: SlicerBundle[];
selectedId: string | null;
onChange: (id: string | null) => void;
disabled?: boolean;
}
function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerProps) {
const { t } = useTranslation();
return (
<label className="block">
<span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
<Package className="w-3.5 h-3.5" />
{t('slice.bundle', 'Slicer bundle')}
</span>
<select
value={selectedId ?? ''}
onChange={(e) => onChange(e.target.value || null)}
disabled={disabled}
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
>
<option value="">
{t('slice.bundleNone', '— None (pick presets individually) —')}
</option>
{bundles.map((b) => (
<option key={b.id} value={b.id}>
{b.printer_preset_name}
</option>
))}
</select>
</label>
);
}
// Plain-string dropdown used for bundle-mode process / filament selectors.
// Bundles store presets as a flat list of names within their printer-tied
// directory, so a `<select>` of strings is enough — no source tier, no
// optgroups. Same swatch / disabled affordances as the cloud/local/standard
// PresetDropdown above so the visual rhythm of the form stays consistent.
interface BundleStringDropdownProps {
label: string;
options: string[];
value: string | null;
onChange: (next: string | null) => void;
disabled?: boolean;
swatchColor?: string;
}
function BundleStringDropdown({
label,
options,
value,
onChange,
disabled,
swatchColor,
}: BundleStringDropdownProps) {
const { t } = useTranslation();
return (
<label className="block">
<span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
{swatchColor && (
<span
className="inline-block w-3 h-3 rounded-sm border border-black/20"
style={{ backgroundColor: swatchColor || 'transparent' }}
aria-hidden
/>
)}
<span>{label}</span>
</span>
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
disabled={disabled || options.length === 0}
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
>
<option value="">
{options.length === 0
? t('slice.noPresetsForSlot', 'No presets available')
: t('slice.selectPreset', '— Select a preset —')}
</option>
{options.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
);
}