0

On my main page, I render a table that shows some orders. When the user clicks on any of the orders, a Dialog opens up with an additional table showing the products related to the order.

The first two attributes of every product are the "code" and the "quantity", they're wrapped in an Input component so that the user can easily change them on the spot. Also, the table always renders a "dummy product", a row with those two fields empty: the user can insert the code and the quantity, and then if a product with that code exists, it'll get added to the product list of the order.

Here's some code to help:

This is the Dialog that opens via the "TableCell" trigger, the order the user clicked.

export default function Order({
  cell,
  className,
}: {
  cell: Cell<OrderType, unknown>;
  className: string;
}) {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <TableCell key={cell.id} className={className}>
          {flexRender(cell.column.columnDef.cell, cell.getContext())}
        </TableCell>
      </DialogTrigger>
      <DialogContent className="w-[90vw] max-w-screen max-h-screen h-[90vh] flex space-x-10">
        <OrderTable order={cell.row.original} />
      </DialogContent>
    </Dialog>
  );
}

The actual table that renders the products:

const createNewProduct = (): ProductsInOrderType => {
  return {
    product: {
      id: -1,
      name: "",
      code: "",
      desc: "",
      price: 0,
      rice: 0,
    },
    product_id: -1,
    order_id: -1,
    quantity: 0,
    total: 0,
  };
};

export default function OrderTable({ order }: { order: OrderType }) {
  const [products, setProducts] = useState<ProductsInOrderType[]>([
    ...order.products,
    createNewProduct(),
  ]);

  // function to handle the code/quantity input fields
  const handleFieldChange = (key: string, value: any, index: number) => {...}

  // function to fetch and add the new product
  const addProduct = () => {...}

  const columns = getColumns(handleFieldChange, products);
  const table = getTable(products, columns);

  return (
    <div className="w-full flex space-x-10">
      <div className="h-full w-[80%] rounded-md border">
        <Table>
          <TableHeader className="sticky top-0 z-30 bg-background">
            {table.getRowModel().rows?.length > 0 &&
              table.getHeaderGroups().map((headerGroup) => (
                <TableRow key={headerGroup.id}>
                  {headerGroup.headers.map((header, index) => (
                    <TableHead key={header.id}>
                      {header.isPlaceholder
                        ? null
                        : flexRender(
                            header.column.columnDef.header,
                            header.getContext()
                          )}
                    </TableHead>
                  ))}
                </TableRow>
              ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
                className="hover:cursor-pointer"
              >
                {row.getVisibleCells().map((cell, index) => (
                  <TableCell
                    key={cell.id}
                    className={cn("h-12 text-2xl", index == 2 && "p-0")}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))}
            {table.getRowModel().rows.length === 0 && (
              <TableRow>
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center"
                >
                  Nessun risultato!
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>

      {/* <Actions /> */}
    </div>
  );
}

And finally the columns:

type CreateColumnParams = {
  accessorKey: keyof ProductsInOrderType | string;
  headerLabel: string;
  cellContent: (row: Row<ProductsInOrderType>, refs: any) => React.ReactNode;
};

function createColumn({
  accessorKey,
  headerLabel,
  cellContent,
}: CreateColumnParams): ColumnDef<ProductsInOrderType> {
  return {
    accessorKey,
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
      >
        {headerLabel}
        <ArrowsDownUp className="ml-2 h-4 w-4" />
      </Button>
    ),
    cell: ({ row, column }) => {
      return cellContent(row, column);
    },
  };
}

export default function getColumns(
  handleFieldChange: (key: string, value: any, index: number) => void,
  products: ProductsInOrderType[]
): ColumnDef<ProductsInOrderType>[] {
  const columns = [
    createColumn({
      accessorKey: "code",
      headerLabel: "Codice",
      cellContent: (row) => {
        return (
          <Input
            ref={(ref) => setInputRef(ref, row.index, 0)}
            className="max-w-28 text-2xl"
            
            defaultValue={products[row.index].product.code}
            onKeyDown={(e: any) => {
              handleFocus(e, row.index, 0);
              if (e.key === "Enter") {
                handleFieldChange("code", e.target.value, row.index);
              }
            }}
          />
        );
      },
    }),

    createColumn({
      accessorKey: "quantity",
      headerLabel: "Quantità",
      cellContent: (row) => {
        return (
          <Input
            ref={(ref) => setInputRef(ref, row.index, 1)}
            type="number"
            className="max-w-20 text-2xl"
            defaultValue={
              products[row.index].quantity == 0
                ? undefined
                : (products[row.index].quantity as number)
            }
            onKeyDown={(e: any) => {
              handleFocus(e, row.index, 1);
              if (e.key === "Enter") {
                handleFieldChange("quantity", e.target.value, row.index);
              }
            }}
          />
        );
      },
    }),

    [.. other columns]
  ];

  const inputRefs = useRef(new Map<string, HTMLInputElement | null>()).current;

  const handleFocus = (
    e: React.KeyboardEvent<HTMLInputElement>,
    rowIndex: number,
    colIndex: number
  ) => {
    if (e.key === "Enter" || e.key === "ArrowRight") {
      e.preventDefault();

      if (colIndex === 1) {
        moveToInput(rowIndex + 1, 0);
      } else {
        moveToInput(rowIndex, colIndex + 1);
      }
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      moveToInput(rowIndex + 1, colIndex);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      moveToInput(rowIndex - 1, colIndex);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      if (colIndex === 0) {
        moveToInput(rowIndex - 1, 1);
      } else {
        moveToInput(rowIndex, colIndex - 1);
      }
    }
  };

  const moveToInput = (rowIndex: number, colIndex: number) => {
    inputRefs.get(`${rowIndex}-${colIndex}`)?.focus();
  };

  const setInputRef = (ref: any, rowIndex: number, colIndex: number) => {
    if (ref) {
      inputRefs.set(`${rowIndex}-${colIndex}`, ref);
    }
  };

  return columns;
}

Now the problem:
This project requires that whenever the user types inside the "code" input and then press the "enter" key, the focus will shift to the "quantity" field. But, if the user tries this, the focus won't go where it's supposed to go, but rather on the Dialog containing the Table for some, I think, accessibility reasons. The arrows instead work flawlessly.

The only half-fix I found was putting autoFocus={row.original.product_id == -1} on the code input. If the product_id of that row is -1, that means it's the dummy product and it must be highlighted, which does make sense after all.

But this partially works: if I type the code, and then press enter, the handleFieldChange fires but no focus change. But then, if I press again enter, handleFieldChange fires again and the focus goes to the quantity:

  • Enter 1 time: function fires but no focus change
  • Enter 2 times: function fires and focus change

This happens when going from the "code" field > to the "quantity" field: if I type on the quantity and then press enter (one time), the new product will be created, and a new dummy product will be added with the automatic focus on the empty code. It's close to the correct behaviour, but it still sucks the user needs to double-enter to go to the quantity field. I don't know why this happens, maybe it has something to do with the refs used? Why two times?

Update: On the dummy product, if I insert first the quantity and press enter, the focus goes to the code (because it has autofocus), then if I insert the code and press enter one time, everything works as expected. This weirdly works but it's not normal to insert the quantity first and then the code, y'know.

Either way, my theory is that there's some sort of listener on the Dialog that listens for the "enter" key and triggers a default behaviour. I tried to override some onSomeEvent of the Dialog but no luck. Checked the docs and searched online, but no one was talking about this default focus behaviour. Maybe there's a method to make the Dialog not focusable?

What I'd like is to keep my autofocus on the dummy product code field, which I like cause it facilitates the addition of the products, but the focus still needs to shift after just one enter key press.

I'm using the UI library "shadcn" (based on "Radix UI"), both for the table and the Dialog, if it can help.

Hope I've been exhaustive enough, maybe too much. Any help is appreciated, thanks!

0