Repository: nebari-dev/jupyterlab-launchpad (language: python)

Issue:
Add ability to resize table column widths
### Context

It would be nice to be able to resize the column widths in the tables. If I have a long environemnt name, it expands my table to beyond my screen size. I'm able to scroll to view, but it would be nice if I could control column width so I can adjust the table to my window. 

<img width="1403" alt="image" src="https://github.com/nebari-dev/jupyterlab-new-launcher/assets/15659110/4dadd6a2-a014-4aba-9d4f-6a51e83f0e28">



### Value and/or benefit

easier viewing for tables with long strings in them. 

### Anything else?

_No response_


Discussion / hints:

Fix patch:
diff --git a/src/components/base-table.tsx b/src/components/base-table.tsx
new file mode 100644
index 0000000..9cba6e5
--- /dev/null
+++ b/src/components/base-table.tsx
@@ -0,0 +1,243 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import React, { ReactElement, ReactNode, useState } from 'react';
+import { caretDownIcon, caretUpIcon } from '@jupyterlab/ui-components';
+
+export const TABLE_CLASS = 'jp-sortable-table';
+
+/**
+ * A namespace for Table.
+ */
+export namespace Table {
+  /**
+   * The state which will be restored from layout tracker.
+   */
+  export interface ISortState {
+    sortKey?: string | null;
+    sortDirection: -1 | 1;
+  }
+  /**
+   * The initialization options for the table.
+   */
+  export interface IOptions<T> extends Partial<ISortState> {
+    rows: IRow<T>[];
+    columns: IColumn<T>[];
+    onRowClick?: React.MouseEventHandler<HTMLTableRowElement>;
+    blankIndicator: () => ReactNode;
+  }
+  /**
+   * Table row with data to display.
+   */
+  export interface IRow<T> {
+    data: T;
+    key: string;
+  }
+  /**
+   * Column definition.
+   */
+  export interface IColumn<T> {
+    id: string;
+    label: string;
+    renderCell(data: T): ReactNode;
+    sort(a: T, b: T): number | undefined;
+    isAvailable?(): boolean;
+    isHidden?: boolean;
+    minWidth?: number;
+  }
+}
+
+/**
+ * Sortable table component for small datasets.
+ *
+ * For large datasets use `DataGrid` from `@lumino/datagrid`.
+ */
+export function Table<T>(props: Table.IOptions<T>) {
+  const [sortState, setSortState] = useState<Table.ISortState>({
+    sortKey: props.sortKey,
+    sortDirection: props.sortDirection || 1
+  });
+
+  const sort = (key: string) => {
+    if (key === sortState.sortKey) {
+      setSortState({
+        sortKey: key,
+        sortDirection: (sortState.sortDirection * -1) as -1 | 1
+      });
+    } else {
+      setSortState({ sortKey: key, sortDirection: 1 });
+    }
+  };
+
+  let rows = props.rows;
+  const sortedColumn = props.columns.filter(
+    column => column.id === sortState.sortKey
+  )[0];
+
+  if (sortedColumn) {
+    const sorter = sortedColumn.sort.bind(sortedColumn);
+    rows = props.rows.sort(
+      (a, b) => (sorter(a.data, b.data) ?? 0) * sortState.sortDirection
+    );
+  }
+
+  const visibleColumns = props.columns.filter(
+    column =>
+      (column.isAvailable ? column.isAvailable() : true) && !column.isHidden
+  );
+
+  const [isColumnResized, setColumnResized] = React.useState<
+    Record<string, boolean>
+  >({});
+
+  const elements = rows.map(row => {
+    const cells = visibleColumns.map(column => (
+      <td
+        key={column.id + '-' + row.key}
+        className={isColumnResized[column.id] ? 'jp-mod-col-resized' : ''}
+      >
+        {column.renderCell(row.data)}
+      </td>
+    ));
+
+    return (
+      <tr
+        key={row.key}
+        data-key={row.key}
+        onClick={props.onRowClick}
+        className={'jp-sortable-table-tr'}
+      >
+        {cells}
+      </tr>
+    );
+  });
+
+  const columnsHeaders = visibleColumns.map(column => (
+    <SortableTH
+      label={column.label}
+      id={column.id}
+      state={sortState}
+      key={column.id}
+      onSort={() => {
+        sort(column.id);
+      }}
+      onResize={() => {
+        setColumnResized({
+          ...isColumnResized,
+          [column.id]: true
+        });
+      }}
+      minWidth={column.minWidth}
+    />
+  ));
+
+  return (
+    <table className={TABLE_CLASS}>
+      <thead>
+        <tr className={'jp-sortable-table-tr'}>{columnsHeaders}</tr>
+      </thead>
+      <tbody>{elements}</tbody>
+    </table>
+  );
+}
+
+function SortableTH(props: {
+  id: string;
+  label: string;
+  state: Table.ISortState;
+  onSort: () => void;
+  onResize: () => void;
+  minWidth?: number;
+}): ReactElement {
+  const isSortKey = props.id === props.state.sortKey;
+  const sortIcon =
+    !isSortKey || props.state.sortDirection === 1 ? caretUpIcon : caretDownIcon;
+  const [columnWidth, setColumnWidth] = React.useState<number | null>(null);
+
+  const thRef = React.useRef<HTMLTableCellElement | null>(null);
+  const [resizeOngoing, setResizeOngoing] = React.useState<boolean>(false);
+  const [thLeft, setThLeft] = useState<number | null>(null);
+
+  requestAnimationFrame(() => {
+    if (thRef.current) {
+      const { left } = thRef.current.getBoundingClientRect();
+      setThLeft(left);
+    }
+  });
+
+  React.useEffect(() => {
+    const onPointerMove = (event: PointerEvent) => {
+      if (resizeOngoing) {
+        let left: number;
+        if (thLeft === null) {
+          if (!thRef.current) {
+            throw Error('Cannot resize: no reference to the current table');
+          }
+          console.warn('Resize cache for column was not available');
+          left = thRef.current.getBoundingClientRect().left;
+        } else {
+          left = thLeft;
+        }
+        setColumnWidth(event.clientX - left);
+        props.onResize();
+      }
+    };
+    const onPointerUp = (event: PointerEvent) => {
+      if (resizeOngoing) {
+        document.body.classList.remove(RESIZE_ACTIVE);
+        setResizeOngoing(false);
+        // do not sort when finishing resize
+        event.stopImmediatePropagation();
+      }
+    };
+    document.body.addEventListener('pointermove', onPointerMove);
+    document.body.addEventListener('pointerup', onPointerUp);
+    return () => {
+      document.body.removeEventListener('pointermove', onPointerMove);
+      document.body.removeEventListener('pointerup', onPointerUp);
+    };
+  });
+
+  const classes: string[] = [];
+  if (isSortKey) {
+    classes.push('jp-sorted-header');
+  }
+  if (resizeOngoing) {
+    classes.push('jp-header-resizing');
+  }
+  const RESIZE_HANDLE = 'jp-sortable-table-resize-handle';
+  const RESIZE_ACTIVE = 'jp-mod-resize-table';
+
+  return (
+    <th
+      key={props.id}
+      ref={thRef}
+      onClick={() => props.onSort()}
+      className={classes.join(' ')}
+      data-id={props.id}
+      style={{
+        width:
+          columnWidth !== null
+            ? `${Math.max(props.minWidth ?? 50, columnWidth)}px`
+            : ''
+      }}
+      onPointerDown={event => {
+        if (
+          event.target instanceof HTMLElement &&
+          event.target.className === RESIZE_HANDLE
+        ) {
+          document.body.classList.add(RESIZE_ACTIVE);
+          setResizeOngoing(true);
+        }
+      }}
+    >
+      <div className="jp-sortable-table-th-wrapper">
+        <label>{props.label}</label>
+        <sortIcon.react tag="span" className="jp-sort-icon" />
+      </div>
+      <div className={RESIZE_HANDLE}></div>
+    </th>
+  );
+}
diff --git a/src/components/table.tsx b/src/components/table.tsx
index 614f529..fc05093 100644
--- a/src/components/table.tsx
+++ b/src/components/table.tsx
@@ -5,12 +5,8 @@ import { ReadonlyJSONObject } from '@lumino/coreutils';
 import { Time } from '@jupyterlab/coreutils';
 import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import { TranslationBundle } from '@jupyterlab/translation';
-import {
-  FilterBox,
-  Table,
-  UseSignal,
-  MenuSvg
-} from '@jupyterlab/ui-components';
+import { FilterBox, UseSignal, MenuSvg } from '@jupyterlab/ui-components';
+import { Table } from './base-table';
 import * as React from 'react';
 import { ISettingsLayout, CommandIDs, IKernelItem } from '../types';
 import { starIcon } from '../icons';
@@ -41,6 +37,14 @@ function columnLabelFromKey(key: string): string {
   return key[0].toUpperCase() + key.substring(1);
 }
 
+function EllipsedCell(props: React.PropsWithChildren<{ title?: string }>) {
+  return (
+    <div className="jp-ellipsis-wrapper" title={props.title}>
+      <div className="jp-ellipsis">{props.children}</div>
+    </div>
+  );
+}
+
 export function KernelTable(props: {
   trans: TranslationBundle;
   items: IKernelItem[];
@@ -87,14 +91,17 @@ export function KernelTable(props: {
           const kernelMeta = item.metadata?.kernel as
             | ReadonlyJSONObject
             | undefined;
-          if (!kernelMeta) {
-            return '-';
-          }
-          const value = kernelMeta[metadataKey];
-          if (typeof value === 'string') {
-            return value;
-          }
-          return JSON.stringify(value);
+          const render = () => {
+            if (!kernelMeta) {
+              return '-';
+            }
+            const value = kernelMeta[metadataKey];
+            if (typeof value === 'string') {
+              return value;
+            }
+            return JSON.stringify(value);
+          };
+          return <EllipsedCell>{render()}</EllipsedCell>;
         },
         sort: (a: IKernelItem, b: IKernelItem) => {
           const aKernelMeta = a.metadata?.kernel as
@@ -140,23 +147,7 @@ export function KernelTable(props: {
       id: 'kernel',
       label: trans.__('Kernel'),
       renderCell: (row: IKernelItem) => (
-        <>
-          <span
-            className="jp-LauncherCard-icon"
-            onClick={() => props.onClick(row)}
-          >
-            {row.kernelIconUrl ? (
-              <img
-                src={row.kernelIconUrl}
-                className="jp-Launcher-kernelIcon"
-                alt={row.label}
-              />
-            ) : (
-              <div className="jp-LauncherCard-noKernelIcon">
-                {row.label[0].toUpperCase()}
-              </div>
-            )}
-          </span>
+        <EllipsedCell>
           <span
             className={KERNEL_ITEM_CLASS}
             onClick={event => {
@@ -171,9 +162,25 @@ export function KernelTable(props: {
             }}
             tabIndex={0}
           >
-            {row.label}
+            <span
+              className="jp-LauncherCard-icon"
+              onClick={() => props.onClick(row)}
+            >
+              {row.kernelIconUrl ? (
+                <img
+                  src={row.kernelIconUrl}
+                  className="jp-Launcher-kernelIcon"
+                  alt={row.label}
+                />
+              ) : (
+                <div className="jp-LauncherCard-noKernelIcon">
+                  {row.label[0].toUpperCase()}
+                </div>
+              )}
+            </span>
+            <span className="jp-TableKernelItem-label">{row.label}</span>
           </span>
-        </>
+        </EllipsedCell>
       ),
       sort: (a: IKernelItem, b: IKernelItem) => a.label.localeCompare(b.label)
     },
@@ -185,12 +192,20 @@ export function KernelTable(props: {
         return (
           <UseSignal signal={row.refreshLastUsed}>
             {() => {
-              return row.lastUsed ? (
-                <span title={Time.format(row.lastUsed)}>
-                  {Time.formatHuman(row.lastUsed)}
-                </span>
-              ) : (
-                trans.__('Never')
+              return (
+                <EllipsedCell
+                  title={
+                    row.lastUsed
+                      ? Time.format(row.lastUsed)
+                      : trans.__(
+                          'No information about last use of this kernel is available in the layout database'
+                        )
+                  }
+                >
+                  {row.lastUsed
+                    ? Time.formatHuman(row.lastUsed)
+                    : trans.__('Never')}
+                </EllipsedCell>
               );
             }}
           </UseSignal>
diff --git a/style/base.css b/style/base.css
index ac1d235..95aa6ff 100644
--- a/style/base.css
+++ b/style/base.css
@@ -148,10 +148,17 @@
 }
 
 .jp-TableKernelItem {
-  display: inline-block;
   line-height: var(--jp-icon-size);
+}
+
+.jp-TableKernelItem > .jp-LauncherCard-icon {
+  margin-right: 4px;
+  vertical-align: top;
+  height: var(--jp-icon-size);
+}
+
+.jp-TableKernelItem-label {
   vertical-align: top;
-  padding-left: 4px;
 }
 
 .jp-NewLauncher-table th[data-id='icon'],
@@ -245,3 +252,95 @@
   height: 52px;
   width: 52px;
 }
+
+.jp-NewLauncher-table .jp-sortable-table-tr > td {
+  white-space: nowrap;
+}
+
+.jp-sortable-table-tr > td.jp-mod-col-resized > .jp-ellipsis-wrapper {
+  white-space: nowrap;
+  position: absolute;
+  inset: 0;
+}
+
+.jp-sortable-table-tr
+  > td.jp-mod-col-resized
+  > .jp-ellipsis-wrapper
+  > .jp-ellipsis {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  /* TODO share values for padding and height from `.jp-sortable-table-tr > td` via variable; not top padding is a sum of bottom and top in plain td to align text. */
+  padding: 5px 12px 1px;
+}
+
+.jp-sortable-table-tr > td > .jp-starIconButton {
+  white-space: nowrap;
+}
+
+.jp-sortable-table-tr > th,
+.jp-sortable-table-tr > td {
+  padding: 3px 12px;
+}
+
+td > .jp-starIconButton {
+  position: relative;
+  top: 1px;
+}
+
+/* To contribute upstream  */
+
+.jp-sortable-table-resize-handle {
+  width: 4px;
+  position: absolute;
+  right: 0;
+  top: 0;
+  height: 100%;
+  cursor: ew-resize;
+}
+
+.jp-sortable-table-tr > th {
+  overflow: hidden;
+  box-sizing: border-box;
+  height: 24px;
+  padding: 0;
+  -webkit-user-select: none;
+}
+
+.jp-sortable-table-resize-handle:hover {
+  background: var(--jp-layout-color3);
+}
+
+.jp-header-resizing > .jp-sortable-table-resize-handle {
+  background: var(--jp-brand-color1) !important;
+}
+
+body.jp-mod-resize-table * {
+  cursor: ew-resize;
+}
+
+body.jp-mod-resize-table .jp-sortable-table {
+  pointer-events: none;
+}
+
+/*
+This will likely be needed upstream when no ellipsis wrapping is used.
+.jp-sortable-table-tr > td > * {
+  white-space: normal;
+}
+*/
+
+.jp-sortable-table-th-wrapper > label {
+  overflow: hidden;
+  font-weight: 500;
+}
+
+.jp-sortable-table-th-wrapper {
+  width: inherit;
+  box-sizing: border-box;
+  padding: 4px 12px 2px;
+
+  /* fill-available is only needed when heading does not collapse
+  /* min-width: -webkit-fill-available; */
+}


Test patch:
diff --git a/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-linux.png b/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-linux.png
index 1d02fa0..4043a38 100644
Binary files a/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-linux.png and b/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-linux.png differ
diff --git a/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-search-in-individual-linux.png b/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-search-in-individual-linux.png
index 7b0bc77..25b7721 100644
Binary files a/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-search-in-individual-linux.png and b/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-search-in-individual-linux.png differ
diff --git a/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-with-starred-linux.png b/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-with-starred-linux.png
index dd75088..abc175c 100644
Binary files a/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-with-starred-linux.png and b/ui-tests/tests/jupyterlab_new_launcher.spec.ts-snapshots/launcher-with-starred-linux.png differ