Browse Source

final polish of custom select

clovis 1 tháng trước cách đây
mục cha
commit
ec7f78fef1

+ 11 - 4
src/ClockBuilder.tsx

@@ -9,6 +9,7 @@ import ExpressionInput from "./assets/components/ExpressionInpux";
 import { useClockStore } from "./store/useClockStore";
 import ClockWizard from "./assets/components/ClockWizard";
 import ClockTimeline from "./assets/ClockTimeline";
+import Select from "./assets/components/Select";
 
 export default function ClockBuilder() {
   //  Subscribe to the Global Store
@@ -185,10 +186,16 @@ export default function ClockBuilder() {
         </div>
         <div className="field">
           <label>Throughput unit</label>
-          <select className={"dark"} value={displayUnit} onChange={(e) => setDisplayUnit(e.target.value as "s" | "m")}>
-            <option value="s">items/s</option>
-            <option value="m">items/min</option>
-          </select>
+          <Select
+            style={{ minWidth: 100 }}
+            options={[
+              { id: "s", label: "items/s" },
+              { id: "m", label: "items/min" },
+            ]}
+            isDark
+            value={displayUnit}
+            onChange={(e) => setDisplayUnit(e as "s" | "m")}
+          />
         </div>
         <div className="field">
           <label>Belt reference</label>

+ 12 - 12
src/assets/ClockTimeline/TimelineRow.tsx

@@ -1,4 +1,4 @@
-import { useRef } from "react";
+import { useRef, useState } from "react";
 import { useClockStore } from "../../store/useClockStore";
 import { useTimelineDrag } from "../../hooks/useTimelineDrag";
 import ExpressionInput from "../components/ExpressionInpux";
@@ -6,6 +6,8 @@ import Icon from "../icon";
 import styles from "./ClockTimeline.module.css";
 import { ACTION_PRESETS, expandBlockInstances, getPreset, type ClockBlock, type Signal } from "../types";
 import SignalEditorList from "./SignalEditorList";
+import Select from "../components/Select";
+import Preset from "../components/Preset";
 
 type PackedInstance = {
   id: string;
@@ -42,6 +44,7 @@ export default function TimelineRow({ rowId, step }: { rowId: string; step?: num
   const { onPointerDownBlock, onPointerMove, onPointerUp, pxToTick } = useTimelineDrag();
 
   const laneRef = useRef<HTMLDivElement>(null);
+  const [autofillType, setAutofillType] = useState("chest_to_chest");
 
   const row = rows[rowId];
   if (!row) return null;
@@ -141,19 +144,17 @@ export default function TimelineRow({ rowId, step }: { rowId: string; step?: num
           <ExpressionInput value={row.inserterCount} min={1} onCommit={(v) => updateRow(rowId, { inserterCount: v })} />
         </div>
         <div className="field horizontal">
-          <select defaultValue="chest_to_belt" id={`autofill-preset-${row.id}`}>
-            {ACTION_PRESETS.filter((p) => p.id !== "custom").map((p) => (
-              <option key={p.id} value={p.id}>
-                {p.label}
-              </option>
-            ))}
-          </select>
+          <Select
+            style={{ minWidth: 120 }}
+            value={autofillType}
+            onChange={setAutofillType}
+            options={ACTION_PRESETS.filter((p) => p.id !== "custom").map((p) => ({ ...p }))}
+          />
         </div>
         <button
           className="button-green"
           onClick={() => {
-            const select = document.getElementById(`autofill-preset-${row.id}`) as HTMLSelectElement | null;
-            autoFillRow(select?.value ?? "chest_to_belt");
+            autoFillRow(autofillType ?? "chest_to_belt");
           }}
         >
           Auto-fill ({row.inserterCount})
@@ -191,8 +192,7 @@ export default function TimelineRow({ rowId, step }: { rowId: string; step?: num
               onPointerDown={(e) => onPointerDownBlock(e, block, "move", laneRef.current)}
             >
               <div className={styles.blockIcons}>
-                {preset.fromItem && <Icon iconName={`item/${preset.fromItem}.png`} size={18} />}
-                {preset.toItem && <Icon iconName={`item/${preset.toItem}.png`} size={18} />}
+                <Preset preset={preset} size={16} arrow=" " />
               </div>
               <div
                 className={styles.resizeHandle}

+ 8 - 14
src/assets/SelectedBlockPanel.tsx

@@ -1,9 +1,10 @@
 import styles from "./SelectedBlockPanel.module.css";
 import { ACTION_PRESETS, getPreset } from "./types";
-import Icon from "./icon";
 import ExpressionInput from "./components/ExpressionInpux";
 import { useClockStore } from "../store/useClockStore";
 import { useMemo } from "react";
+import Preset from "./components/Preset";
+import Select from "./components/Select";
 
 export default function SelectedBlockPanel() {
   const { selectedBlockIds, removeBlocks, blocks, updateBlock } = useClockStore();
@@ -32,28 +33,21 @@ export default function SelectedBlockPanel() {
   return (
     <div className={styles.wrap}>
       <div className={styles.preview}>
-        {preset.fromItem && <Icon iconName={`item/${preset.fromItem}.png`} size={40} />}
-        <span className={styles.arrow}>→</span>
-        {preset.toItem && <Icon iconName={`item/${preset.toItem}.png`} size={40} />}
+        <Preset preset={preset} size={40} />
       </div>
 
       <div className={styles.fields}>
         <div className="field">
           <label>Action</label>
-          <select
+          <Select
+            style={{ minWidth: 120 }}
             value={block.presetId}
-            className="dark"
             onChange={(e) => {
-              const p = getPreset(e.target.value);
+              const p = getPreset(e);
               updateBlock(blockId, { presetId: p.id, duration: p.ticks + 1 });
             }}
-          >
-            {ACTION_PRESETS.map((p) => (
-              <option key={p.id} value={p.id}>
-                {p.label}
-              </option>
-            ))}
-          </select>
+            options={ACTION_PRESETS.filter((p) => p.id !== "custom").map((p) => ({ ...p }))}
+          />
         </div>
         <div className="field">
           <label>Start (tick)</label>

+ 18 - 0
src/assets/components/Preset.tsx

@@ -0,0 +1,18 @@
+import Icon from "../icon";
+import type { ActionPreset } from "../types";
+
+interface PresetProps {
+  preset: ActionPreset;
+  size?: number;
+  arrow?: string;
+}
+
+export default function Preset({ preset, arrow, size = 30 }: PresetProps) {
+  return (
+    <>
+      {preset.fromItem && <Icon iconName={`item/${preset.fromItem}.png`} size={size} />}
+      <span>{arrow ?? "→"}</span>
+      {preset.toItem && <Icon iconName={`item/${preset.toItem}.png`} size={size} />}
+    </>
+  );
+}

+ 92 - 0
src/assets/components/Select.module.css

@@ -0,0 +1,92 @@
+.customSelectWrap {
+  position: relative;
+  display: inline-block;
+  width: 100%;
+}
+.chevron {
+  font-size: 10px;
+  margin-left: 8px;
+  pointer-events: none;
+}
+
+/* The Dropdown Container */
+.customSelectMenu {
+  font-size: 14px;
+  position: absolute;
+  top: calc(100% + 4px);
+  left: 0;
+  width: 100%;
+  z-index: 100;
+
+  /* Matches your light theme focus background */
+  background: #f0dab4;
+  color: #000;
+  border: 1px solid #74624b;
+  box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.3);
+  border-radius: 4px;
+  max-height: 250px;
+  overflow-y: auto;
+}
+
+.customSelectMenu.dark {
+  background: #242324;
+  border-color: #646464;
+}
+
+/* Variant A: Simple List */
+.menuSimple {
+  padding: 4px 0;
+}
+.optionSimple {
+  padding: 2px 8px;
+  cursor: pointer;
+  color: #000;
+}
+.customSelectMenu.dark .optionSimple {
+  color: #ffe6c0;
+}
+
+.optionSimple:hover,
+.optionSimple.selected {
+  background: rgba(0, 0, 0, 0.1);
+}
+.customSelectMenu.dark .optionSimple:hover {
+  background: rgba(255, 255, 255, 0.1);
+}
+
+/* Variant B: Stacked Buttons */
+.menuStacked {
+  padding: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  background: transparent; /* Remove background so buttons float */
+  border: none;
+  box-shadow: none;
+}
+.optionStacked {
+  /* Reuse your trigger styles for the options to make them look like buttons */
+  background: #8e8e8e;
+  border-radius: 4px;
+  padding: 5px 8px;
+  cursor: pointer;
+  box-shadow:
+    inset 0px 4px 1px -2px #000,
+    inset 0px -4px 1px -2px #c5c5c5,
+    inset 2px 0px 1px 0px #5f5f5f,
+    inset -2px 0px 1px 0px #5f5f5f,
+    inset 0px -2px 2px 0px #5f5f5f,
+    0px 0px 4px 1px #2e2521;
+}
+.optionStacked:hover,
+.optionStacked.selected {
+  background: #f0dab4;
+  color: #000;
+  box-shadow:
+    inset 0px 4px 2px -2px #000,
+    inset 0px -1px 1px 0px #74624b,
+    inset 0px -4px 2px -2px #e0e0e0,
+    inset 2px 0px 2px 0px #a6885c,
+    inset -2px 0px 2px 0px #a6885c,
+    0px 0px 4px 1px #2e2521;
+}

+ 139 - 0
src/assets/components/Select.tsx

@@ -0,0 +1,139 @@
+import { useState, useRef, useEffect, type ReactNode, type CSSProperties } from "react";
+import styles from "./Select.module.css";
+import { createPortal } from "react-dom";
+export interface SelectOption {
+  id: string;
+  label: string;
+  [key: string]: any; // Allow any extra data (like icons, colors, etc.)
+}
+
+interface CustomSelectProps {
+  style?: CSSProperties;
+  className?: string;
+  options: SelectOption[];
+  value: string;
+  onChange: (value: string) => void;
+
+  // Custom renderers
+  renderValue?: (option: SelectOption | undefined) => ReactNode;
+  renderOption?: (option: SelectOption) => ReactNode;
+
+  // Styling
+  isDark?: boolean;
+  variant?: "simple" | "stacked";
+  placeholder?: string;
+}
+
+export default function Select({
+  style,
+  className,
+  options,
+  value,
+  onChange,
+  renderValue,
+  renderOption,
+  isDark = false,
+  variant = "simple",
+  placeholder = "Select...",
+}: CustomSelectProps) {
+  const [isOpen, setIsOpen] = useState(false);
+  const [menuCoords, setMenuCoords] = useState({ top: 0, left: 0, width: 0 });
+
+  const triggerRef = useRef<HTMLButtonElement>(null);
+  const menuRef = useRef<HTMLDivElement>(null);
+
+  const selectedOption = options.find((opt) => opt.id === value);
+
+  const toggleMenu = () => {
+    if (!isOpen && triggerRef.current) {
+      // Calculate exact screen position before opening
+      const rect = triggerRef.current.getBoundingClientRect();
+      setMenuCoords({
+        top: rect.bottom, // Place right below the button
+        left: rect.left,
+        width: rect.width, // Match the button's width
+      });
+    }
+    setIsOpen(!isOpen);
+  };
+  useEffect(() => {
+    if (!isOpen) return;
+
+    const handleClickOutside = (e: MouseEvent | PointerEvent) => {
+      const target = e.target as Node;
+      // If we clicked the trigger or inside the menu, do nothing
+      if (
+        (triggerRef.current && triggerRef.current.contains(target)) ||
+        (menuRef.current && menuRef.current.contains(target))
+      ) {
+        return;
+      }
+      // Otherwise, we clicked outside, so close it
+      setIsOpen(false);
+    };
+
+    const handleScroll = (e: Event) => {
+      if (menuRef.current && menuRef.current.contains(e.target as Node)) {
+        return;
+      }
+      setIsOpen(false);
+    };
+    document.addEventListener("pointerdown", handleClickOutside, true);
+    document.addEventListener("mousedown", handleClickOutside, true);
+    // Use capture phase (true) to catch all scroll events anywhere on the page
+    window.addEventListener("scroll", handleScroll, true);
+    window.addEventListener("resize", () => setIsOpen(false));
+
+    return () => {
+      document.removeEventListener("pointerdown", handleClickOutside, true);
+      document.removeEventListener("mousedown", handleClickOutside, true);
+      window.removeEventListener("scroll", handleScroll, true);
+      window.removeEventListener("resize", () => setIsOpen(false));
+    };
+  }, [isOpen]);
+
+  const menuContent = isOpen ? (
+    <div
+      className={`${styles.customSelectMenu} ${isDark ? "dark" : ""} ${variant === "simple" ? styles.menuSimple : styles.menuStacked}`}
+      style={{
+        position: "fixed",
+        top: `${menuCoords.top + 4}px`,
+        left: `${menuCoords.left}px`,
+        width: `${menuCoords.width}px`,
+      }}
+    >
+      {options.map((option) => (
+        <div
+          key={option.id}
+          className={`${variant === "simple" ? styles.optionSimple : styles.optionStacked} ${
+            option.id === value ? "selected" : ""
+          }`}
+          onClick={() => {
+            onChange(option.id);
+            setIsOpen(false);
+          }}
+        >
+          {renderOption ? renderOption(option) : option.label}
+        </div>
+      ))}
+    </div>
+  ) : null;
+  return (
+    <>
+      <button
+        ref={triggerRef}
+        type="button"
+        className={`customSelectTrigger ${isOpen ? "isOpen" : ""} ${isDark ? "dark" : ""} ${className ?? ""}`}
+        style={style}
+        onClick={toggleMenu}
+      >
+        <div style={{ flex: 1, overflow: "hidden" }}>
+          {selectedOption ? (renderValue ? renderValue(selectedOption) : selectedOption.label) : placeholder}
+        </div>
+        <span className={styles.chevron}>{isOpen ? "▲" : "▼"}</span>
+      </button>
+      {/* DROPDOWN MENU */}
+      {isOpen && createPortal(menuContent, document.body)}
+    </>
+  );
+}

+ 18 - 3
src/index.css

@@ -449,6 +449,7 @@ input:focus {
   align-items: center;
 }
 select,
+.customSelectTrigger,
 input[type="text"],
 input[type="password"],
 input[type="email"],
@@ -473,7 +474,9 @@ textarea {
     inset 0px -2px 2px 0px #5f5f5f,
     0px 0px 4px 1px #2e2521;
 }
-select,
+select:focus,
+.customSelectTrigger:focus,
+.customSelectTrigger.isOpen,
 input[type="text"]:focus,
 input[type="password"]:focus,
 input[type="email"]:focus,
@@ -493,16 +496,28 @@ textarea:focus {
 }
 
 input.dark,
-select.dark {
+select.dark,
+.customSelectTrigger.dark {
   background-color: #242324;
   border: 1px solid #646464;
   color: #ffe6c0;
 }
 input:focus.dark,
-select:focus.dark {
+select:focus.dark,
+.customSelectTrigger.dark:focus,
+.customSelectTrigger.dark.isOpen {
   outline: 2px solid #f1be64;
   outline-offset: 1px;
 }
+.customSelectTrigger {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  user-select: none;
+  text-align: left;
+  width: 100%;
+}
 textarea {
   width: 100%;
   height: 8.1em;