Skip to content

Latest commit

 

History

History
710 lines (654 loc) · 20.6 KB

tabs-new.md

File metadata and controls

710 lines (654 loc) · 20.6 KB

Tabs (New)

This is a set of components for tabs that are draggable across different lists and closable. It is being built for notebook-view and app-view.

TODO:

  • Add drag and drop from tabs-draggable
  • Track the tab being dragged and style it differently
  • Allow cancelling drag with Esc and prevent dropping from changing what is selected
  • Move on drag
  • Allow moving to other Tab List
  • Remove context menu
  • Move context menu to example
  • Support dragging with one finger to move, and dragging with two fingers or mousewheel to scroll (with arrows)
  • Make it show an x on hover
  • Give extra space for scrollbar if it overflows
  • Roving keyboard navigation
  • Return focus after rename if renamed by double-click
  • Edit and go to position on double-click
  • Support New Tab icon that is included in the scrolling

notebook.json

{
  "dataFiles": [
    ["colors.json.md", "thumbnail.svg"]
  ],
  "importFiles": [
    ["split-pane.md", "split-view.js"]
  ]
}

This is an individual tab. It can be a tab that is used as a content container, or a tab that is being dragged. The tab that is being dragged has the drag class and doesn't handle events, but is just displayed while dragging.

The dragging is done manually with pointer capture, so the dragged tab is an HTML element instead of an image.

TabItem.js

export class TabItem extends HTMLElement {
  constructor() {
    super()
    this.closable = true
    this.attachShadow({mode: 'open'})
    this.mainEl = document.createElement('div')
    this.mainEl.classList.add('main')
    this.shadowRoot.appendChild(this.mainEl)
    this.nameEl = document.createElement('label')
    this.nameEl.classList.add('name')
    this.mainEl.append(this.nameEl)
  }

  connectedCallback() {
    this.shadowRoot.adoptedStyleSheets = [this.constructor.styles]
    if (!this.classList.contains('drag')) {
      this.initEvents()
    }
    if (this.closable) {
      const closeButton = document.createElement('button')
      closeButton.innerText = '✕'
      closeButton.classList.add('close')
      closeButton.addEventListener('click', () => {
        this.dispatchEvent(new CustomEvent('tabClose', {composed: true}))
      })
      this.mainEl.append(closeButton)
    }
  }

  initEvents() {
    this.mainEl.addEventListener('pointerdown', e => {
      if (e.isPrimary && !e.target.classList.contains('close')) {
        this.mainEl.setPointerCapture(e.pointerId)
        e.preventDefault()
        this.pointerDown = true
        this.moved = false
        const rect = this.getBoundingClientRect()
        this.offsetX = e.clientX - rect.left
        this.offsetY = e.clientY - rect.top
      }
    })
    this.mainEl.addEventListener('pointermove', e => {
      if (this.pointerDown) {
        if (!this.moved) {
          this.moved = true
          this.classList.add('drag-source')
          this.tabList.dragging = true
          this.tabList.dragItem.name = this.name
          this.tabList.dragItem.selected = this.selected
          this.tabList.dragItem.classList.add('dragging')
        }
        if (!this.moveLoopActive) {
          this.moveLoop()
        }
        this.tabList.dragItem.setDragPosition(
          e.clientX - this.offsetX, e.clientY - this.offsetY
        )
        this.pointerMoveEvent = e
      }
    })
    this.mainEl.addEventListener('pointerup', e => {
      this.classList.remove('drag-source')
      this.tabList.dragItem.classList.remove('dragging')
      if (this.moved) {
        if (this.hoverTab) {
          if (this.hoverTab.tagName === 'TAB-ITEM') {
            const hoverIndex = [...this.parentElement.children].indexOf(this.hoverTab)
            const myIndex = [...this.parentElement.children].indexOf(this)
            if (hoverIndex === -1) {
              const sourceTabList = this.tabList
              const destTabList = this.tabList.tabLists.find(tl => tl.appendDropArea === this.hoverTab)
              const changeSelected = this.selected
              const otherTabToSelect = changeSelected ? (this.previousElementSibling ?? this.nextElementSibling) : undefined
              if (otherTabToSelect) {
                otherTabToSelect.selected = true
              }
              this.hoverTab.insertAdjacentElement('beforebegin', this)
              if (changeSelected) {
                this.selected = true
              }
              if (sourceTabList !== destTabList && sourceTabList.listEl.children.length === 0) {
                sourceTabList.dispatchEvent(new CustomEvent('tabSelect', {bubbles: true, composed: true}))
              }
            } else {
              const position = (hoverIndex > myIndex) ? 'afterEnd' : 'beforeBegin'
              this.hoverTab.insertAdjacentElement(position, this)
            }
          } else {
            const sourceTabList = this.tabList
            const destTabList = this.tabList.tabLists.find(tl => tl.appendDropArea === this.hoverTab)
            const changeSelected = this.selected && (destTabList !== sourceTabList)
            const moveToEmpty = destTabList !== sourceTabList && destTabList.listEl.children.length === 0
            const otherTabToSelect = changeSelected ? (this.previousElementSibling ?? this.nextElementSibling) : undefined
            destTabList.listEl.insertAdjacentElement('beforeEnd', this)
            if (changeSelected) {
              this.selected = true
              if (otherTabToSelect) {
                otherTabToSelect.selected = true
              }
            } else if (moveToEmpty) {
              this.selected = true
            }
            if (sourceTabList !== destTabList && sourceTabList.listEl.children.length === 0) {
              sourceTabList.dispatchEvent(new CustomEvent('tabSelect', {bubbles: true, composed: true}))
            }
            if (changeSelected && moveToEmpty) {
              this.dispatchEvent(new CustomEvent('tabSelect', {bubbles: true, composed: true}))
            }
          }
        }
      } else if (!this.tabList.dragging) {
        if (this.selected && this.preview) {
          this.dispatchEvent(new CustomEvent('clickPreview', {bubbles: true, composed: true}))
        } else if (!this.selected) {
          this.selected = true
        }
      }
      this.moved = false
      this.pointerDown = false
      this.tabList.dragging = false
    })
    this.mainEl.addEventListener('lostpointercapture', e => {
      this.pointerMoveEvent = undefined
      this.tabList.dragItem.classList.remove('dragging')
      if (this.hoverTab) {
        this.hoverTab.classList.remove('drag-hover')
        this.hoverTab = undefined
      }
      this.tabList.dragging = false
    })
  }

  async *pointerMoveEvents() {
    if (this.pointerMoveEvent) {
      const {pointerMoveEvent} = this
      this.pointerMoveEvent = undefined
      yield pointerMoveEvent
    }
    for (let i=0; i < 100000; i++) {
      await this.constructor.delay(25)
      if (this.pointerMoveEvent) {
        const {pointerMoveEvent} = this
        this.pointerMoveEvent = undefined
        yield pointerMoveEvent
      } else {
        return
      }
    }
  }

  findHoverTab(e) {
    for (const tabList of this.tabList.tabLists) {
      const tabsFromPoint = tabList.tabsFromPoint(e.clientX, e.clientY)
      const hoverTab = tabsFromPoint.find(el => el !== this)
      if (hoverTab !== undefined) {
        return hoverTab
      }
    }
  }

  async moveLoop() {
    this.moveLoopActive = true
    for await (const e of this.pointerMoveEvents()) {
      const hoverTab = this.findHoverTab(e)
      if (this.hoverTab !== hoverTab) {
        if (this.hoverTab) {
          this.hoverTab.classList.remove('drag-hover')
        }
        if (hoverTab) {
          hoverTab.classList.add('drag-hover')
        }
        this.hoverTab = hoverTab
      }
    }
    this.moveLoopActive = false
  }

  set name(value) {
    this._name = value
    this.nameEl.innerText = this.name + (this.suffix ?? '')
  }

  get name() {
    return this._name
  }

  set suffix(value) {
    this._suffix = value
    this.nameEl.innerText = this.name + (this.suffix ?? '')
  }

  get suffix() {
    return this._suffix
  }

  get selected() {
    return this.hasAttribute('selected')
  }

  set selected(value) {
    const selectedTabs = (
      this.tabList !== undefined ? [...this.tabList.listEl.querySelectorAll(':scope > tab-item[selected]')] : []
    ).filter(el => el !== this)
    if (value !== this.selected || (value === true && selectedTabs.length > 0)) {
      if (value === true) {
        if (!this.classList.contains('drag')) {
          for (const tab of selectedTabs) {
            tab.selected = false
          }
        }
        this.setAttribute('selected', '')
        if (!this.classList.contains('drag')) {
          this.dispatchEvent(new CustomEvent('tabSelect', {bubbles: true, composed: true}))
        }
      } else {
        this.removeAttribute('selected')
      }
    }
  }

  get deleted() {
    return this.classList.contains('deleted')
  }

  set deleted(value) {
    this.classList.toggle('deleted', value)
  }

  get preview() {
    return this.classList.contains('preview')
  }

  set preview(value) {
    this.classList.toggle('preview', value)
  }

  get tabList() {
    return this.getRootNode().host
  }

  setDragPosition(x, y) {
    this.style.setProperty('--drag-left', `${x}px`)
    this.style.setProperty('--drag-top', `${y}px`)
  }

  static delay(ms) {
    return new Promise((resolve, _) => {
      setTimeout(resolve, ms)
    })
  }

  static get styles() {
    if (!this._styles) {
      this._styles = new CSSStyleSheet()
      this._styles.replaceSync(`
        :host {
          display: flex;
          flex-direction: column;
          align-items: stretch;
        }
        div.main {
          display: flex;
          flex-direction: row;
          align-items: stretch;
          padding: 3px 4px;
          border-radius: var(--radius, 5px);
          color: var(--fg, #b9b9bc);
          background-color: var(--bg, #484850);
          align-items: center;
          min-width: 50px;
          text-align: center;
        }
        div.main button.close {
          unset: all;
          font-size: 11px;
          margin-bottom: -2px;
          padding: 4px;
          color: #bbb;
          font-weight: bold;
        }
        :host(:not([selected])) button.close {
          display: none;
        }
        div.main button.close:hover {
          color: white;
        }
        @media (hover: hover) {
          div.main button.close {
            display: none;
          }
        }
        :host([selected]) div.main {
          background-color: var(--bg-selected, #0e544f);
          color: var(--fg-selected, #e7e7e7);
        }
        :host(:hover) div.main {
          background-color: var(--bg-hover, #52525b);
          color: var(--fg-hover, #c7c7c7);
          position: relative;
        }
        :host(:hover:not(.drag-source)[selected]) div.main:has(button.close) .name {
          mask-image: linear-gradient(to left, transparent 12px, var(--fg-hover, #c7c7c7) 30px);
        }
        :host(:hover:not(.drag-source)[selected]) div.main button.close {
          position: absolute;
          right: 4px;
          display: block;
        }
        :host([selected]:hover) div.main {
          background-color: var(--bg-selected-hover, #0c6860);
          color: var(--fg-selected-hover, #f7f7f7);
        }
        :host(.drag-hover) div.main {
          background-color: var(--bg-drag-hover, #64646d);
          color: var(--fg, #c7c7c7);
          pointer-events: none;
        }
        .name {
          flex-grow: 1;
          padding: 0 5px;
          font: inherit;
          font-family: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,
          Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif;
          outline: none;
          white-space: nowrap;
          user-select: none;
        }
        div.main button svg {
          margin-bottom: -3px;
        }
        div.content {
          display: flex;
          flex-direction: column;
          align-items: center;
          min-height: 5px;
        }
        div.content.collapsed > * {
          display: none;
        }
        label {
          padding: 0;
          margin: 0;
        }
        div.main > button {
          all: unset;
          padding: 0 4px 0 2px;
          border-radius: 5px;
        }
        svg {
          height: 24px;
          width: 10px;
          margin-right: -3px;
          opacity: 50%;
        }
        :host([selected]) svg {
          opacity: 75%;
        }
        :host(.drag) {
          position: absolute;
          top: var(--drag-top, 0px);
          left: var(--drag-left, 0px);
          display: none;
        }
        :host(.drag.dragging) {
          display: block;
          z-index: 1000;
        }
        :host(.deleted) {
          text-decoration: line-through;
          text-decoration-thickness: 15%;
          text-decoration-color: var(--fg, #b9b9bc);
        }
        :host(.deleted) .name::before, :host(.deleted) .name::after {
          content: "\\00a0";
        }
        :host(.preview) {
          font-style: italic;
          font-weight: 300;
        }
      `)
    }
    return this._styles
  }
}

This is a sequential list of tabs, that appear next to each other. It contains TabGroup, which enables tabs to be dragged from one tab list to another.

TabList.js

export class TabList extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({mode: 'open'})
    this.listEl = document.createElement('div')
    this.listEl.classList.add('list')
    this.listEl.addEventListener('click-add', e => { this.handleAdd(e) })
    this.listEl.addEventListener('click-move', e => { this.handleMove(e) })
    this.dragItem = document.createElement('tab-item')
    this.dragItem.classList.add('drag')
    this.shadowRoot.append(this.listEl, this.dragItem)
  }

  connectedCallback() {
    const style = document.createElement('style')
    style.textContent = `
      .list {
        display: flex;
        flex-direction: row;
        gap: 3px;
        color: #111;
        overflow-x: auto;
        padding: 3px;
        padding-right: 0;
      }
      .list:empty {
        padding-left: 0;
        padding-right: 0;
        min-height: 28px;
      }
    `
    this.shadowRoot.append(style)
  }

  tabsFromPoint(x, y) {
    return [
      ...this.shadowRoot.elementsFromPoint(x, y),
      ...(this.appendDropArea ? this.appendDropArea.getRootNode().elementsFromPoint(x, y) : [])
    ].filter(el => (
      (el !== this.dragItem && el.tagName === 'TAB-ITEM') || el === this.appendDropArea
    ))
  }

  get tabs() {
    return this.listEl.children
  }

  set tabs(value) {
    this.listEl.replaceChildren(...value)
  }

  get tabLists() {
    return this.tabGroup?.tabLists ?? [this]
  }

  get selectedItem() {
    return this.listEl.querySelector('[selected]')
  }

  get dragging() {
    if (this.tabGroup) {
      return this.tabGroup.dragging
    } else {
      if (typeof this._dragging === 'number') {
        return Date.now() < this._dragging
      } else {
        return Boolean(this._dragging)
      }
    }
  }

  set dragging(value) {
    if (this.tabGroup) {
      this.tabGroup.dragging = value
    } else {
      if (value) {
        this._dragging = true
      } else {
        this._dragging = Date.now() + 50
      }
    }
  }

  static TabGroup = class {
    set tabLists(value) {
      this._tabLists = value
    }
  
    get tabLists() {
      return this._tabLists
    }
  
    get tabs() {
      return this.tabLists.map(tabList => tabList.tabs).flat()
    }

    get dragging() {
      if (typeof this._dragging === 'number') {
        return Date.now() < this._dragging
      } else {
        return Boolean(this._dragging)
      }
    }
  
    set dragging(value) {
      if (value) {
        this._dragging = true
      } else {
        this._dragging = Date.now() + 50
      }
    }
  }
}

ExampleView.js

export class ExampleView extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'})
    this.shadowRoot.adoptedStyleSheets = [this.constructor.styles]
    if (![...document.adoptedStyleSheets].includes(this.constructor.globalStyles)) {
      document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.constructor.globalStyles]
    }
    this.split = document.createElement('split-view')
    this.split.vertical = true
    this.split.addEventListener('split-view-resize', e => {
      const y = e.detail.offsetY - this.offsetTop
      this.style.setProperty('--top-area-height', `${y}px`)
    })
    const {TabGroup} = customElements.get('tab-list')
    this.tabGroup = new TabGroup()
    this.topTabList = this.createTabs(5, 'A')
    this.topTabBlankArea = document.createElement('div')
    this.topTabBlankArea.classList.add('drop')
    this.topTabList.appendDropArea = this.topTabBlankArea
    this.topTabList.tabGroup = this.tabGroup
    this.topTabList.tabs[4].preview = true
    this.topAreaHeader = document.createElement('div')
    this.topAreaHeader.classList.add('header')
    this.topAreaHeader.append(this.topTabList, this.topTabBlankArea)
    this.topArea = document.createElement('top-area')
    this.topArea.append(this.topAreaHeader)
    this.bottomTabList = this.createTabs(5, 'B')
    this.bottomTabBlankArea = document.createElement('div')
    this.bottomTabBlankArea.classList.add('drop')
    this.bottomTabList.appendDropArea = this.bottomTabBlankArea
    this.bottomTabList.tabGroup = this.tabGroup
    this.bottomAreaHeader = document.createElement('div')
    this.bottomAreaHeader.classList.add('header')
    this.bottomAreaHeader.append(this.bottomTabList, this.bottomTabBlankArea)
    this.bottomArea = document.createElement('bottom-area')
    this.bottomArea.append(this.bottomAreaHeader)
    this.bottomTabList.tabGroup = this.tabGroup
    this.tabGroup.tabLists = [this.topTabList, this.bottomTabList]
    this.shadowRoot.append(this.topArea, this.split, this.bottomArea)
    this.addEventListener('tabClose', e => {
      const tab = e.composedPath()[0]
      const toSelect = tab.selected ? (tab.previousElementSibling ?? tab.nextElementSibling ?? undefined) : undefined
      e.composedPath()[0].remove()
      if (toSelect !== undefined) {
        toSelect.selected = true
      }
    })
    this.addEventListener('clickPreview', e => {
      const tab = e.composedPath()[0]
      tab.preview = false
    })
    this.topTabList.tabs[0].deleted = true
  }

  createTabs(n, prefix) {
    const tabList = document.createElement('tab-list')
    const tabs = Array(n).fill('').map((_, i) => {
      const el = document.createElement('tab-item')
      el.name = `Tab ${prefix}${i + 1}`
      if (i === 0) {
        el.selected = true
      }
      if (i % 2 === 1) {
        el.closable = false
      }
      return el
    })
    tabList.tabs = tabs
    return tabList
  }

  static get styles() {
    if (!this._styles) {
      this._styles = new CSSStyleSheet()
      this._styles.replaceSync(`
        :host {
          display: grid;
          grid-template-rows: var(--top-area-height, 50%) min-content 1fr;
          box-sizing: border-box;
          color: #d7d7d7;
          height: 80vh;
        }
        *, *:before, *:after {
          box-sizing: inherit;
        }
        tab-list {
          grid-row: 1;
          grid-column: 1;
        }
        split-view {
          background: #273737;
        }
        :host > split-view {
          min-height: 3px;
        }
        .top-area, .bottom-area {
          display: grid;
          grid-template-columns: 1fr;
          grid-template-rows: min-content 1fr;
        }
        .header {
          display: grid;
          grid-template-columns: max-content 1fr;
        }
        .drop {
          padding: 3px;
          grid-row: 1;
          grid-column: 2;
          background-clip: content-box;
        }
        .drop.drag-hover {
          background-color: #8889;
          border-radius: 8px;
        }
      `)
    }
    return this._styles
  }

  static get globalStyles() {
    if (!this._globalStyles) {
      this._globalStyles = new CSSStyleSheet()
      this._globalStyles.replaceSync(`
        html {
          box-sizing: border-box;
        }
        body {
          margin: 0;
        }
        *, *:before, *:after {
          box-sizing: inherit;
        }
      `)
    }
    return this._globalStyles
  }
}

app.js

import {SplitView} from '/split-pane/split-view.js'
import {TabItem} from '/TabItem.js'
import {TabList} from '/TabList.js'
import {ExampleView} from '/ExampleView.js'

customElements.define('split-view', SplitView)
customElements.define('tab-item', TabItem)
customElements.define('tab-list', TabList)
customElements.define('example-view', ExampleView)

async function setup() {
  document.body.append(document.createElement('example-view'))
}

setup()