Skip to content

Latest commit

 

History

History
565 lines (517 loc) · 14.8 KB

tabs-draggable.md

File metadata and controls

565 lines (517 loc) · 14.8 KB

Tabs

This is a component for tabs.

notebook.json

{
  "dataFiles": [
    ["colors.json.md", "thumbnail.svg"]
  ],
  "importFiles": [
    ["menu.md", "dropdown.js"]
  ]
}

TabItem.js

export class TabItem extends HTMLElement {
  constructor() {
    super()
    this.language = navigator.language
    this.attachShadow({mode: 'open'})
    this.codeMirror = true
    this.headerEl = document.createElement('div')
    this.headerEl.classList.add('header')
    this.shadowRoot.appendChild(this.headerEl)
    this.nameEl = document.createElement('label')
    this.nameEl.classList.add('name')
    this.nameEl.setAttribute('spellcheck', 'false')
    this.headerEl.appendChild(this.nameEl)
    this.menuBtn = document.createElement('button')
    this.menuBtn.innerHTML = this.icons.menu
    this.headerEl.appendChild(this.menuBtn)
    this.menu = document.createElement('m-menu-dropdown')
    this.shadowRoot.appendChild(this.menu)
  }

  connectedCallback() {
    this.shadowRoot.adoptedStyleSheets = [this.constructor.styles]

    if (!this.classList.contains('drag')) {
      this.nameEl.addEventListener('input', e => {
        this.contentEl.name = this.name
      })
      this.nameEl.addEventListener('blur', () => {
        this.nameEl.contentEditable = 'false'
        if (this.isNew) {
          this.selected = true
          this.isNew = false
        }
      })
      this.nameEl.addEventListener('keydown', e => {
        if (e.which === 13) {
          e.preventDefault()
          const isNew = this.isNew
          this.nameEl.blur()
          if (isNew) {
            this.dispatchEvent(new CustomEvent(
              'ready-to-edit', {bubbles: true}
            ))
          }
          return false
        }
      })
      this.headerEl.addEventListener('pointerdown', e => {
        if (e.isPrimary && this.nameEl.contentEditable !== 'true' && !this.menu.contains(e.target)) {
          this.headerEl.setPointerCapture(e.pointerId)
          e.preventDefault()
          this.pointerDown = true
          this.pointerOnMenu = this.menuBtn.contains(e.target)
          this.moved = false
          const rect = this.getBoundingClientRect()
          this.offsetX = e.clientX - rect.left
          this.offsetY = e.clientY - rect.top
        }
      })
      this.headerEl.addEventListener('pointermove', e => {
        if (!this.moved) {
          this.moved = true
          if (this.pointerDown) {
            this.tabList.dragItem.name = this.name
            this.tabList.dragItem.selected = this.selected
            this.tabList.dragItem.classList.add('dragging')
          }
        }
        if (this.pointerDown) {
          this.tabList.dragItem.setDragPosition(
            e.clientX - this.offsetX, e.clientY - this.offsetY
          )
          const hoverTab = [
            ...this.tabList.shadowRoot.elementsFromPoint(e.clientX, e.clientY)
          ].find(el => (
            el !== this.tabList.dragItem && el.tagName === 'TAB-ITEM'
          ))
          if (this.hoverTab !== hoverTab) {
            if (this.hoverTab) {
              this.hoverTab.classList.remove('drop-hover')
            }
            if (hoverTab) {
              hoverTab.classList.add('drop-hover')
            }
            this.hoverTab = hoverTab
          }
        }
      })
      this.headerEl.addEventListener('pointerup', e => {
        this.tabList.dragItem.classList.remove('dragging')
        if (!this.moved) {
          if (this.pointerOnMenu) {
            this.openMenu()
          } else {
            this.selected = true            
          }
        }
        this.moved = false
        this.pointerDown = false
      })
      this.headerEl.addEventListener('lostpointercapture', e => {
        this.tabList.dragItem.classList.remove('dragging')
        if (this.hoverTab) {
          this.hoverTab.classList.remove('drop-hover')
          this.hoverTab = undefined
        }
      })
    }
  }

  openMenu() {
    this.menu.clear()
    this.menu.add(this.text.addLeft, () => {
      this.dispatchEvent(new CustomEvent(
        'click-add', {bubbles: true, detail: {direction: 'left'}}
      ))
    })
    this.menu.add(this.text.addRight, () => {
      this.dispatchEvent(new CustomEvent(
        'click-add', {bubbles: true, detail: {direction: 'right'}}
      ))
    })
    if (this.previousElementSibling) {
      this.menu.add(this.text.moveLeft, () => {
        this.dispatchEvent(new CustomEvent(
          'click-move', {bubbles: true, detail: {direction: 'left'}}
        ))
      })
    }
    if (this.nextElementSibling) {
      this.menu.add(this.text.moveRight, () => {
        this.dispatchEvent(new CustomEvent(
          'click-move', {bubbles: true, detail: {direction: 'right'}}
        ))
      })
    }
    if (this.nextElementSibling || this.previousElementSibling) {
      this.menu.add(this.text.delete, () => {
        (this.previousElementSibling ?? this.nextElementSibling).selected = true
        this.contentEl.remove()
        this.remove()
      })
    }
    this.menu.add(this.text.rename, () => {
      this.rename()
    })
    this.menu.open(this.menuBtn)
  }

  rename() {
    this.nameEl.contentEditable = 'true'
    const range = document.createRange()
    const sel = window.getSelection()
    range.setStart(this.nameEl, this.nameEl.childNodes.length)
    range.collapse(true)
    sel.removeAllRanges()
    sel.addRange(range)
    this.nameEl.focus()
  }

  set name(name) {
    this.nameEl.innerText = name
    //this.setFileType(name)
  }

  get name() {
    return this.nameEl.innerText
  }

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

  set selected(value) {
    if (value) {      
      this.classList.add('selected')
    } else {
      this.classList.remove('selected')
    }
    if (!this.classList.contains('drag')) {
      this.contentEl.selected = value
      if (value) {
        for (const el of [...(this.parentElement?.children ?? [])].filter(el => el !== this)) {
          el.selected = false
        }
      }
    }
  }

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

  icons = {
    menu: `
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 14 24">
        <path fill="currentColor" d="M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2"/>
      </svg>
    `,
  }

  textEn = {
    addLeft: 'Add left',
    addRight: 'Add right',
    moveLeft: 'Move left',
    moveRight: 'Move right',
    rename: 'Rename',
    delete: 'Delete',
  }

  textEs = {
    addLeft: 'Añadir izquierda',
    addRight: 'Añadir derecha',
    moveLeft: 'Mover izquierda',
    moveRight: 'Mover derecha',
    rename: 'Cambiar nombre',
    delete: 'Borrar',
  }

  get language() {
    return this._language
  }

  set language(language) {
    this._language = language
    this.text = this.langEs ? this.textEs : this.textEn
  }

  get langEs() {
    return /^es\b/.test(this.language)
  }

  static get styles() {
    if (!this._styles) {
      this._styles = new CSSStyleSheet()
      this._styles.replaceSync(`
        :host {
          display: flex;
          flex-direction: column;
          align-items: stretch;
        }
        :host(.drag) {
          position: absolute;
          top: var(--drag-top, 0px);
          left: var(--drag-left, 0px);
          display: none;
        }
        :host(.drag.dragging) {
          display: block;
        }
        div.header {
          display: flex;
          flex-direction: row;
          align-items: stretch;
          padding-left: 3px 0;
          border-radius: var(--radius, 5px);
          color: var(--fg, #070707);
          background-color: var(--bg, rgb(212,212,216));
          align-items: center;
          user-select: none;
        }
        :host(.selected) div.header {
          background-color: var(--bg-selected, rgb(15,118,110));
          color: var(--fg-selected, #e7e7e7);
        }
        :host(.drop-hover) div.header, :host(.selected.drop-hover) div.header {
          background-color: var(--bg-drop-hover, rgb(122, 122, 126));
          color: var(--fg, #070707);
        }
        div.header > * {
          background: inherit;
          color: inherit;
          border: none;
        }
        .name {
          flex-grow: 1;
          padding: 0 5px;
          font: inherit;
          font-family: monospace;
          outline: none;
          white-space: nowrap;
        }
        div.header 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;
        }
        button {
          padding: 0 4px;
          border-radius: 5px;
        }
        svg {
          height: 24px;
          width: 10px;
          margin-right: -3px;
          opacity: 33%;
        }
        :host(.selected) svg {
          opacity: 75%;
        }
      `)
    }
    return this._styles
  }
}

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', e => this.childClicked(e))
    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 = `
      :host {
        overflow-x: auto;
      }
      .list {
        display: flex;
        flex-direction: row;
        gap: 3px;
        color: #111;
        overflow-x: auto;
        scrollbar-width: thin;
      }
    `
    this.shadowRoot.append(style)
  }

  childClicked(e) {
    if (e.target !== this.listEl && !e.target.hasAttribute('selected')) {
      this.listEl.querySelectorAll('[selected]')?.forEach?.(el => {
        el.removeAttribute('selected')
      })
      e.target.setAttribute('selected', '')
      this.dispatchEvent(new CustomEvent('select-item'), {bubbles: true})
    }
  }

  handleAdd(e) {
    const direction = e.detail.direction
    const tabEl = document.createElement('tab-item')
    const contentEl = this.createContentEl(tabEl)
    tabEl.contentEl = contentEl
    tabEl.tabList = this
    contentEl.codeMirror = this.codeMirror
    const position = direction == 'left' ? 'beforebegin' : 'afterend'
    e.target.insertAdjacentElement(position, tabEl)
    e.target.contentEl.insertAdjacentElement(position, contentEl)
    setTimeout(() => {
      tabEl.isNew = true
      tabEl.nameEl.setAttribute('contenteditable', '')
      tabEl.nameEl.focus()
    }, 50)
  }

  handleMove(e) {
    const direction = e.detail.direction
    const siblingEl = (
      direction == 'left' ?
      e.target.previousElementSibling :
      e.target.nextElementSibling
    )
    if (siblingEl) {
      const position = direction == 'left' ? 'beforebegin' : 'afterend'
      siblingEl.insertAdjacentElement(position, e.target)
    }
    const contentSiblingEl = (
      direction == 'left' ?
      e.target.contentEl.previousElementSibling :
      e.target.contentEl.nextElementSibling
    )
    if (contentSiblingEl) {
      const position = direction == 'left' ? 'beforebegin' : 'afterend'
      contentSiblingEl.insertAdjacentElement(position, e.target.contentEl)
    }
  }

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

  set items(value) {
    for (const tabItem of value) {
      tabItem.tabList = this
    }
    this.listEl.replaceChildren(...value)
  }

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

ExampleView.js

export class ExampleView extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({mode: 'open'})
    this.tabList = document.createElement('tab-list')
    const tabItems = Array(20).fill('').map((_, i) => {
      const el = document.createElement('tab-item')
      el.name = `Tab ${i}`
      el.contentEl = document.createElement('example-item')
      el.contentEl.name = el.name
      if (i === 0) {
        el.selected = true
        el.contentEl.selected = true
      }
      return el
    })
    this.tabList.items = tabItems
    this.tabList.createContentEl = tabEl => {
      return document.createElement('example-item')
    }
    this.tabContent = document.createElement('div')
    this.tabContent.append(...tabItems.map(tabItem => tabItem.contentEl))
    this.shadowRoot.append(this.tabList, this.tabContent)
  }

  connectedCallback() {
    const globalStyle = document.createElement('style')
    globalStyle.textContent = `
      body {
        margin: 0;
        padding: 0;
        background-color: #55391b;
      }
    `
    document.head.append(globalStyle)
    const style = document.createElement('style')
    style.textContent = `
      :host {
        display: grid;
        grid-template-columns: 1fr;
        grid-template-rows: 1fr;
        margin: 0;
        padding: 10px;
        color: #bfcfcd;
        background: #fff;
      }
    `
    this.shadowRoot.append(style)
  }
}

ExampleItem.js

export class ExampleItem extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({mode: 'open'})
    this.p = document.createElement('p')
    this.shadowRoot.append(this.p)
  }

  connectedCallback() {
    const style = document.createElement('style')
    style.textContent = `
      :host {
        display: none;
      }
      :host(.selected) {
        display: block;
      }
    `
    this.shadowRoot.append(style)
  }

  set selected(value) {
    if (value) {
      this.classList.add('selected')
    } else {
      this.classList.remove('selected')
    }
  }

  set name(value) {
    this.p.innerText = value
  }

  get name() {
    return this.p.innerText
  }
}

app.js

import {Dropdown} from "/menu/dropdown.js"
import {TabItem} from '/TabItem.js'
import {TabList} from '/TabList.js'
import {ExampleItem} from '/ExampleItem.js'
import {ExampleView} from '/ExampleView.js'

customElements.define('m-menu-dropdown', Dropdown)
customElements.define('tab-item', TabItem)
customElements.define('tab-list', TabList)
customElements.define('example-item', ExampleItem)
customElements.define('example-view', ExampleView)

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

setup()

License

Icon svg in icons: google material-design-icons, Apache 2.0

Other content: Apache 2.0