← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/sequence/sequence-toolbar.tsx

9.83 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import type { ReactNode } from "react";

import { WorkbenchTools } from "../ui/workbench-tools";
import type { SequenceWorkspaceArtifactPublisher } from "../views/workbench-persistence";
import { ExportActions } from "./export-actions";
import { SEQUENCE_WRAP_WIDTHS } from "./sequence-line-layout";
import { getCompatibleSequencePalettes } from "./sequence-palette";
import type {
  SequenceDocument,
  SequencePaletteId,
  SequenceRecord,
  SequenceSelection,
} from "./types";

export function SequenceToolbar({
  document,
  coordinateValue,
  displayOptions,
  featureCount,
  onClearSelection,
  onCoordinateChange,
  onCoordinateJump,
  onNextFeature,
  onNextSearchHit,
  onPaletteChange,
  onPreviousFeature,
  onPreviousSearchHit,
  publishWorkspaceArtifact,
  onQueryChange,
  onToggleFeatures,
  onToggleQuality,
  onToggleTranslation,
  onWrapWidthChange,
  paletteId,
  query,
  record,
  searchHitCount,
  selection,
  showFeatures,
  showQuality,
  showTranslation,
  sourceRevision,
  wrapWidth,
}: {
  document: SequenceDocument;
  coordinateValue: string;
  displayOptions?: ReactNode;
  featureCount: number;
  onClearSelection: () => void;
  onCoordinateChange: (value: string) => void;
  onCoordinateJump: () => void;
  onNextFeature: () => void;
  onNextSearchHit: () => void;
  onPaletteChange: (paletteId: SequencePaletteId) => void;
  onPreviousFeature: () => void;
  onPreviousSearchHit: () => void;
  publishWorkspaceArtifact?: SequenceWorkspaceArtifactPublisher;
  onQueryChange: (query: string) => void;
  onToggleFeatures: () => void;
  onToggleQuality: () => void;
  onToggleTranslation: () => void;
  onWrapWidthChange: (wrapWidth: number) => void;
  paletteId: SequencePaletteId;
  query: string;
  record: SequenceRecord;
  searchHitCount: number;
  selection?: SequenceSelection;
  showFeatures: boolean;
  showQuality: boolean;
  showTranslation: boolean;
  sourceRevision: number;
  wrapWidth: number;
}): React.ReactElement {
  const paletteOptions = getCompatibleSequencePalettes(record.molecule);
  const hasTranslation = record.features.some(
    ({ translation, type }) =>
      type.toLowerCase() === "cds" && translation != null,
  );
  return (
    <div className="bio-sequence-toolbar border-b border-token-border bg-token-main-surface-primary px-4 py-3">
      <div className="bio-sequence-toolbar-primary flex flex-wrap items-center gap-2">
        <label className="flex min-w-48 flex-1 items-center gap-2 rounded-md border border-token-border bg-token-input-background px-3 py-2 text-sm focus-within:border-token-focus-border">
          <span className="text-token-text-secondary">Find</span>
          <input
            aria-label="Search sequence"
            className="min-w-0 flex-1 bg-transparent text-token-text-primary outline-none placeholder:text-token-text-tertiary"
            onChange={(event) => onQueryChange(event.target.value)}
            placeholder="Motif, primer, or sequence"
            value={query}
          />
          {query.length === 0 ? null : (
            <span className="shrink-0 text-xs tabular-nums text-token-text-secondary">
              {searchHitCount.toLocaleString()} matches
            </span>
          )}
        </label>
        <div className="flex items-center gap-1" hidden={query.length === 0}>
          <button
            className={actionClass}
            disabled={searchHitCount === 0}
            onClick={onPreviousSearchHit}
            type="button"
          >
            Prev hit
          </button>
          <button
            className={actionClass}
            disabled={searchHitCount === 0}
            onClick={onNextSearchHit}
            type="button"
          >
            Next hit
          </button>
        </div>
        <form
          className="flex items-center gap-1.5 rounded-md border border-token-border bg-token-input-background px-2 py-1.5 focus-within:border-token-focus-border"
          onSubmit={(event) => {
            event.preventDefault();
            onCoordinateJump();
          }}
        >
          <input
            aria-label="Jump to coordinate"
            className="w-24 bg-transparent text-sm tabular-nums text-token-text-primary outline-none placeholder:text-token-text-tertiary"
            inputMode="numeric"
            onChange={(event) => onCoordinateChange(event.target.value)}
            placeholder="Position"
            value={coordinateValue}
          />
          <button className={actionClass} type="submit">
            Jump
          </button>
        </form>
        {selection == null ? null : (
          <button
            className={actionClass}
            onClick={onClearSelection}
            title="Clear selection (Esc)"
            type="button"
          >
            Clear selection
          </button>
        )}
      </div>
      <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-token-text-secondary">
        <span className="font-medium uppercase">{document.format}</span>
        <span>{record.molecule}</span>
        {record.topology == null ? null : <span>{record.topology}</span>}
        <span className="tabular-nums">
          {record.length.toLocaleString()} residues
        </span>
        <span className="tabular-nums">
          {featureCount.toLocaleString()} feature{featureCount === 1 ? "" : "s"}
        </span>
      </div>
      <WorkbenchTools
        className="bio-sequence-toolbar-options"
        group="sequence-display"
        label="Sequence display and sharing"
        panels={[
          {
            id: "display",
            label: "Display",
            content: (
              <div className="space-y-4">
                <div className="flex flex-wrap items-center gap-4">
                  <label className="flex items-center gap-2 text-sm text-token-text-secondary">
                    Palette
                    <select
                      aria-label="Residue palette"
                      className={inputClass}
                      onChange={(event) =>
                        onPaletteChange(event.target.value as SequencePaletteId)
                      }
                      value={paletteId}
                    >
                      {paletteOptions.map(({ id, label }) => (
                        <option key={id} value={id}>
                          {label}
                        </option>
                      ))}
                    </select>
                  </label>
                  <label className="flex items-center gap-2 text-sm text-token-text-secondary">
                    Wrap
                    <select
                      aria-label="Sequence wrap width"
                      className={inputClass}
                      onChange={(event) =>
                        onWrapWidthChange(Number(event.target.value))
                      }
                      value={wrapWidth}
                    >
                      {SEQUENCE_WRAP_WIDTHS.map((width) => (
                        <option key={width} value={width}>
                          {width}
                        </option>
                      ))}
                    </select>
                  </label>
                </div>
                <div className="flex flex-wrap items-center gap-2">
                  <ToggleChip
                    active={showFeatures}
                    disabled={featureCount === 0}
                    onClick={onToggleFeatures}
                  >
                    Features
                  </ToggleChip>
                  <ToggleChip
                    active={showTranslation}
                    disabled={!hasTranslation}
                    onClick={onToggleTranslation}
                  >
                    Translation
                  </ToggleChip>
                  <ToggleChip
                    active={showQuality}
                    disabled={record.quality == null}
                    onClick={onToggleQuality}
                  >
                    FASTQ quality
                  </ToggleChip>
                  <button
                    className={actionClass}
                    disabled={featureCount === 0}
                    onClick={onPreviousFeature}
                    type="button"
                  >
                    Prev feature
                  </button>
                  <button
                    className={actionClass}
                    disabled={featureCount === 0}
                    onClick={onNextFeature}
                    type="button"
                  >
                    Next feature
                  </button>
                </div>
                {displayOptions}
              </div>
            ),
          },
          {
            id: "copy",
            label: "Copy & share",
            content: (
              <ExportActions
                document={document}
                publishWorkspaceArtifact={publishWorkspaceArtifact}
                record={record}
                selection={selection}
                sourceRevision={sourceRevision}
              />
            ),
          },
        ]}
      />
    </div>
  );
}

function ToggleChip({
  active,
  children,
  disabled,
  onClick,
}: {
  active: boolean;
  children: ReactNode;
  disabled: boolean;
  onClick: () => void;
}): React.ReactElement {
  return (
    <button
      aria-pressed={active}
      className={`${actionClass} ${active ? "bg-token-main-surface-secondary text-token-text-primary" : ""}`}
      disabled={disabled}
      onClick={onClick}
      type="button"
    >
      {children}
    </button>
  );
}

const actionClass =
  "rounded-md px-2.5 py-1.5 text-sm font-medium text-token-text-secondary hover:bg-token-main-surface-secondary hover:text-token-text-primary focus-visible:outline-2 focus-visible:outline-token-focus-border disabled:cursor-not-allowed disabled:opacity-40";
const inputClass =
  "min-w-0 rounded-md border border-token-border bg-token-input-background px-2 py-1.5 text-sm text-token-text-primary outline-none focus:border-token-focus-border";

SHA-256: 19a05305619bc57402419a39a04f1d5dc664a0f33d85ee45ccad28fd91e4a33d