ソースを参照

Changed layout

maziggy 4 週間 前
コミット
2368445378

ファイルの差分が大きいため隠しています
+ 0 - 0
CHANGELOG.md


+ 67 - 1
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -8,7 +8,7 @@
  * the tracker — not here.
  */
 
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
@@ -1517,6 +1517,72 @@ describe('SliceModal', () => {
 
 // Pure-function tests for the filament slot picker. Pinned as a separate
 // describe so the contract is visible without needing the modal mount.
+/**
+ * The slice dialog switches to a two-column layout once there is room for it,
+ * and the process-settings panel then owns the right-hand column. The global
+ * test setup pins matchMedia to `matches: false`, so every other test in this
+ * file exercises the narrow single-stack path; these override it.
+ */
+describe('SliceModal — process settings layout', () => {
+  const setViewport = (wide: boolean) => {
+    Object.defineProperty(window, 'matchMedia', {
+      writable: true,
+      value: (query: string) => ({
+        matches: wide,
+        media: query,
+        onchange: null,
+        addListener: () => {},
+        removeListener: () => {},
+        addEventListener: () => {},
+        removeEventListener: () => {},
+        dispatchEvent: () => true,
+      }),
+    });
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Cube.stl',
+      plates: [],
+      is_multi_plate: false,
+    });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Cube.stl',
+      plate_id: 1,
+      filaments: [],
+    });
+  });
+
+  afterEach(() => setViewport(false));
+
+  it('keeps the panel collapsed behind a disclosure in the narrow layout', async () => {
+    setViewport(false);
+    renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
+
+    const header = await screen.findByRole('button', { name: /Process settings/ });
+    expect(header).toBeEnabled();
+    expect(screen.queryByPlaceholderText('Search settings')).not.toBeInTheDocument();
+
+    await userEvent.setup().click(header);
+    await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+  });
+
+  it('opens the panel without a click once it has a column of its own', async () => {
+    setViewport(true);
+    renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
+
+    // No disclosure to operate: the panel is the column, so its header is
+    // inert rather than offering to collapse something that has room.
+    await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+    expect(screen.getByRole('button', { name: /Process settings/ })).toBeDisabled();
+  });
+});
+
 describe('pickFilamentForSlot — printer-compat contract (#1851)', () => {
   // Index that recognises @BBL H2C / @BBL A1 tokens via the canonical
   // PRINTER_MODEL_MAP. Real production data comes through

+ 64 - 41
frontend/src/components/SliceModal.tsx

@@ -15,6 +15,7 @@ import {
 } from '../api/client';
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
 import { useToast } from '../contexts/ToastContext';
+import { useIsWideLayout } from '../hooks/useIsWideLayout';
 import { PlatePickerModal } from './PlatePickerModal';
 import SlicerSettingsPanel from './SlicerSettingsPanel';
 import type { DesignOverride, PlateFilament } from '../types/plates';
@@ -264,6 +265,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   const [processOverrides, setProcessOverrides] = useState<Record<string, SettingValue>>({});
   const [serializedProcessOverrides, setSerializedProcessOverrides] = useState<Record<string, string | string[]>>({});
   const [settingsExpanded, setSettingsExpanded] = useState(false);
+  // Wide enough for the two-column layout, where the panel has a column to
+  // itself and so is always open. The disclosure only exists for the narrow
+  // single-stack layout, in which 348 unfolded options would bury the preset
+  // pickers above them.
+  const isWideLayout = useIsWideLayout();
+  const panelOpen = isWideLayout || settingsExpanded;
 
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // with one pick, or save the current selection as a new pipeline.
@@ -614,7 +621,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       }}
     >
       <div
-        className="w-full max-w-xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
+        className="w-full max-w-xl lg:max-w-5xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
         onClick={(e) => e.stopPropagation()}
       >
         {/* Header */}
@@ -685,6 +692,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   status === 'ok' (returns null in that case), but the Refresh
                   button stays visible regardless so users can pick up cloud /
                   bundled changes even when sign-in is healthy. */}
+              {/* Two columns once there is room for them. The left keeps the
+                  "what am I slicing with" decisions together; the right gives
+                  the process-settings panel a column of its own, which is the
+                  only way 348 options are comfortable to work through. Below
+                  lg both collapse back into the original single stack. */}
+              <div className="lg:grid lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-5 lg:items-start">
+                <div className="space-y-4 min-w-0">
               {/* Slicer Pipelines (#1425): apply a saved preset bundle to all
                   four slots, or save the current selection as a pipeline.
                   Pipelines are managed in Settings → Workflow → Pipelines. */}
@@ -906,46 +920,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 </div>
               )}
 
-              {/* Process settings, mirroring OrcaSlicer's own Print Settings
-                  tabs. Collapsed by default: the common case is slicing with a
-                  preset as-is, and 348 options unfolded would bury the preset
-                  pickers above. Hidden entirely in embedded mode, where no
-                  process JSON is sent for these to patch. */}
-              {!useEmbedded && (
-                <div className="rounded border border-bambu-dark-tertiary p-3">
-                  <button
-                    type="button"
-                    onClick={() => setSettingsExpanded((v) => !v)}
-                    className="flex w-full items-center justify-between gap-2 text-left"
-                  >
-                    <span className="text-sm text-white">
-                      {t('slice.processSettings', 'Process settings')}
-                      <span className="block text-xs text-bambu-gray/70">
-                        {t('slice.processSettingsHint', "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.")}
-                      </span>
-                    </span>
-                    <span className="shrink-0 text-xs text-bambu-gray">
-                      {Object.keys(serializedProcessOverrides).length > 0
-                        ? t('slice.processSettingsChanged', '{{count}} changed', {
-                            count: Object.keys(serializedProcessOverrides).length,
-                          })
-                        : t('slice.processSettingsUnchanged', 'Preset defaults')}
-                    </span>
-                  </button>
-                  {settingsExpanded && (
-                    <div className="mt-3 border-t border-bambu-dark-tertiary pt-3">
-                      <SlicerSettingsPanel
-                        values={processOverrides}
-                        onChange={(values, serialized) => {
-                          setProcessOverrides(values);
-                          setSerializedProcessOverrides(serialized);
-                        }}
-                        disabled={isEnqueuing}
-                      />
-                    </div>
-                  )}
-                </div>
-              )}
               {/* Bed-type override (#1337). Always visible, always enabled.
                   The backend patches curr_bed_type on the resolved process
                   JSON before forwarding to the sidecar. */}
@@ -1047,6 +1021,55 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   );
                 })
               )}
+                </div>
+
+                {/* Right column: the settings panel. It owns this column, so
+                    there is nothing to collapse it out of the way of — the
+                    disclosure below lg exists only because the single-column
+                    stack cannot afford 348 options unfolded. */}
+                <div className="mt-4 lg:mt-0 min-w-0">
+                {/* Process settings, mirroring OrcaSlicer's own Print Settings
+                    tabs. Hidden entirely in embedded mode, where no process
+                    JSON is sent for these to patch. */}
+                {!useEmbedded && (
+                  <div className="rounded border border-bambu-dark-tertiary p-3">
+                    <button
+                      type="button"
+                      onClick={() => setSettingsExpanded((v) => !v)}
+                      aria-expanded={panelOpen}
+                      disabled={isWideLayout}
+                      className="flex w-full items-center justify-between gap-2 text-left lg:cursor-default"
+                    >
+                      <span className="text-sm text-white">
+                        {t('slice.processSettings', 'Process settings')}
+                        <span className="block text-xs text-bambu-gray/70">
+                          {t('slice.processSettingsHint', "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.")}
+                        </span>
+                      </span>
+                      <span className="shrink-0 text-xs text-bambu-gray">
+                        {Object.keys(serializedProcessOverrides).length > 0
+                          ? t('slice.processSettingsChanged', '{{count}} changed', {
+                              count: Object.keys(serializedProcessOverrides).length,
+                            })
+                          : t('slice.processSettingsUnchanged', 'Preset defaults')}
+                      </span>
+                    </button>
+                    {panelOpen && (
+                      <div className="mt-3 border-t border-bambu-dark-tertiary pt-3">
+                        <SlicerSettingsPanel
+                          values={processOverrides}
+                          onChange={(values, serialized) => {
+                            setProcessOverrides(values);
+                            setSerializedProcessOverrides(serialized);
+                          }}
+                          disabled={isEnqueuing}
+                        />
+                      </div>
+                    )}
+                  </div>
+                )}
+                </div>
+              </div>
             </>
           )}
 

+ 3 - 1
frontend/src/components/SlicerSettingsPanel.tsx

@@ -213,7 +213,9 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
           {t('slicerSettings.noMatches', 'No settings match this search.')}
         </p>
       ) : (
-        <div className="flex flex-col gap-4 max-h-[22rem] overflow-y-auto pr-1">
+        // Taller once the panel has a column of its own; the narrow cap keeps
+        // it from swallowing the single-column stack on small screens.
+        <div className="flex flex-col gap-4 max-h-[22rem] lg:max-h-[58vh] overflow-y-auto pr-1">
           {shownPages.map((p) => (
             <div key={p.page} className="flex flex-col gap-3">
               {query.trim() && <p className="text-[0.7rem] uppercase tracking-wide text-bambu-gray/70">{p.page}</p>}

+ 37 - 0
frontend/src/hooks/useIsWideLayout.ts

@@ -0,0 +1,37 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * Tailwind's `lg`. Kept in sync with the `lg:` classes it is paired with —
+ * components using this hook usually also switch layout via `lg:` utilities,
+ * and the two disagreeing produces a half-applied layout.
+ */
+const WIDE_LAYOUT_BREAKPOINT = 1024;
+
+/**
+ * True when there is room for a side-by-side layout.
+ *
+ * Prefer plain `lg:` classes where CSS alone can do the job. This exists for
+ * the cases where the *behaviour* differs rather than only the styling — a
+ * disclosure that collapses on narrow screens but is permanently open when it
+ * has its own column, for instance, which CSS cannot express on its own.
+ */
+export function useIsWideLayout(): boolean {
+  const [isWide, setIsWide] = useState(() =>
+    typeof window !== 'undefined' ? window.innerWidth >= WIDE_LAYOUT_BREAKPOINT : false
+  );
+
+  useEffect(() => {
+    const mediaQuery = window.matchMedia(`(min-width: ${WIDE_LAYOUT_BREAKPOINT}px)`);
+
+    const handleChange = (e: MediaQueryListEvent) => {
+      setIsWide(e.matches);
+    };
+
+    setIsWide(mediaQuery.matches);
+
+    mediaQuery.addEventListener('change', handleChange);
+    return () => mediaQuery.removeEventListener('change', handleChange);
+  }, []);
+
+  return isWide;
+}

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-D5dXOgnd.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-DcBH50JZ.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Bfjo96N3.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DQ9iPYXW.css">
+    <script type="module" crossorigin src="/assets/index-D5dXOgnd.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DcBH50JZ.css">
   </head>
   <body>
     <div id="root"></div>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません