This is a component for lists of cards representing files, that have an icon and a filename.
notebook.json
{
"dataFiles": [
["colors.json.md", "thumbnail.svg"]
]
}
FileCard.js
export class FileCard extends HTMLElement {
static observedAttributes = ['selected']
constructor() {
super()
this.attachShadow({mode: 'open'})
this.iconEl = document.createElement('div')
this.iconEl.classList.add('icon')
this.nameEl = document.createElement('div')
}
connectedCallback() {
const style = document.createElement('style')
style.textContent = `
:host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-width: 128px;
min-height: 128px;
font-family: monospace;
font-weight: 700;
font-size: 10.5px;
border: 2px solid transparent;
user-select: none;
}
:host(:focus-visible), :host(:focus-visible[selected]) {
outline: none;
border: 2px solid color-mix(in srgb, blue, white 40%);
}
:host([selected]) {
border-color: blue;
}
.icon {
width: 84px;
height: 84px;
background: #bbb;
display: flex;
flex-direction: column;
align-items: stretch;
}
.icon img {
flex-grow: 1;
}
`
this.shadowRoot.append(style)
this.tabIndex = -1
this.shadowRoot.append(this.iconEl, this.nameEl)
this.addEventListener('focus', e => {
if (this.scrollIntoViewIfNeeded) {
this.scrollIntoViewIfNeeded()
} else {
this.scrollIntoView?.()
}
this.tabIndex = 0
if (e.relatedTarget?.parentElement === this.parentElement) {
e.relatedTarget.tabIndex = -1
}
})
}
get name() {
return this.nameEl.innerText
}
set name(name) {
this.nameEl.innerText = name
}
get filename() {
return this.name.endsWith('.md') ? this.name : `${this.name}.md`
}
set image(data) {
this.iconEl.replaceChildren(Object.assign(
document.createElement('img'), {src: data}
))
}
}
FileCardList.js
export class FileCardList extends HTMLElement {
icons = {
left: `
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 24 24">
<path d="m14 17l-5-5l5-5z"/>
</svg>
`,
right: `
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 24 24">
<path d="M10 17V7l5 5z"/>
</svg>
`,
}
constructor() {
super()
this.leftBtn = document.createElement('button')
this.leftBtn.tabIndex = -1
this.leftBtn.innerHTML = this.icons.left
this.leftBtn.addEventListener('click', () => {
this.listEl.scroll({
left: Math.max(0, this.listEl.scrollLeft - Math.floor(this.listEl.clientWidth * 0.85)),
behavior: 'smooth',
})
})
this.rightBtn = document.createElement('button')
this.rightBtn.tabIndex = -1
this.rightBtn.innerHTML = this.icons.right
this.rightBtn.addEventListener('click', () => {
this.listEl.scroll({
left: Math.min(
this.listEl.scrollWidth - this.listEl.clientWidth,
this.listEl.scrollLeft + Math.floor(this.listEl.clientWidth * 0.85)
),
behavior: 'smooth',
})
})
this.attachShadow({mode: 'open'})
this.headerEl = document.createElement('h2')
const listWrapEl = document.createElement('div')
listWrapEl.classList.add('list-wrap')
this.listEl = document.createElement('div')
this.listEl.classList.add('list')
this.listEl.addEventListener('click', e => this.childClicked(e.target))
this.listEl.addEventListener('keydown', e => {
if (e.which === 13) {
this.childClicked(e.target)
}
if (e.which == 37) {
const prev = e.target.previousElementSibling
if (prev) {
prev.focus()
this.childClicked(prev)
}
}
if (e.which == 39) {
const next = e.target.nextElementSibling
if (next) {
next.focus()
this.childClicked(next)
}
}
})
this.listEl.addEventListener('scroll', () => {
this.updateArrows()
})
this.listResizeObserver = new ResizeObserver(() => {
this.updateArrows()
})
this.listResizeObserver.observe(this.listEl)
listWrapEl.append(this.leftBtn, this.listEl, this.rightBtn)
// listWrapEl.append(this.listEl)
this.shadowRoot.append(this.headerEl, listWrapEl)
}
connectedCallback() {
const style = document.createElement('style')
style.textContent = `
.list-wrap {
display: grid;
grid-template-rows: min-content;
grid-template-columns: min-content 1fr min-content;
gap: 0px;
color: #bfcfcd;
background-color: #2b172a;
padding: 5px 2px;
border-radius: 10px;
align-items: center;
}
.list {
flex-grow: 1;
display: flex;
flex-direction: row;
gap: 8px;
color: #bfcfcd;
background-color: #2b172a;
padding: 6px 0;
border-radius: 10px;
overflow-x: scroll;
-ms-overflow-style: none;
scrollbar-width: none;
}
.list::-webkit-scrollbar {
display: none;
}
button {
all: unset;
}
button svg {
color: #bfcfcd;
width: 32px;
height: 32px;
}
button:disabled svg {
color: #888;
}
`
this.shadowRoot.append(style)
setTimeout(() => {
this.updateArrows()
}, 10)
}
childClicked(target) {
if (target !== this.listEl && !target.hasAttribute('selected')) {
this.listEl.querySelectorAll('[selected]')?.forEach?.(el => {
el.removeAttribute('selected')
})
target.setAttribute('selected', '')
this.dispatchEvent(new CustomEvent('select-item'), {bubbles: true})
}
}
updateArrows() {
const listEl = this.listEl
if (listEl.scrollLeft < 3) {
this.leftBtn.setAttribute('disabled', '')
} else {
this.leftBtn.removeAttribute('disabled')
}
if (listEl.scrollLeft + listEl.offsetWidth > listEl.scrollWidth - 20) {
this.rightBtn.setAttribute('disabled', '')
} else {
this.rightBtn.removeAttribute('disabled')
}
}
get name() {
return this.headerEl.innerText
}
set name(value) {
this.headerEl.innerText = value
}
get items() {
return this.listEl.children
}
set items(value) {
this.listEl.replaceChildren(...value)
if (value.length > 0) {
value[0].tabIndex = 0
}
}
get selectedItem() {
return this.listEl.querySelector('[selected]')
}
}
ExampleApp.js
export class ExampleApp extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
this.dataTemplates = [
'new.md', 'colors.json', 'image.png', 'example-notebook.md'
]
this.notebookTemplates = {
'new.md': [
'create.md',
],
'colors.json': [
'palette.md',
'shapes.md',
],
'image.png': [
'image-filters.md',
'histogram.md',
],
'example-notebook.md': [
'list.md',
'tabbed.md',
'overlay.md',
],
}
this.dataSelect = document.createElement('file-card-list')
this.dataSelect.name = 'Data'
this.dataSelect.items = this.dataTemplates.map((template, i) => {
const el = document.createElement('file-card')
el.name = template
if (i === 0) {
el.setAttribute('selected', true)
el.image = `data:image/svg+xml;base64,${btoa(Macchiato.data['colors.json/thumbnail.svg'])}`
}
return el
})
this.dataSelect.addEventListener('select-item', e => {
this.updateNotebookItems()
})
this.notebookSelect = document.createElement('file-card-list')
this.notebookSelect.name = 'Notebook'
this.updateNotebookItems()
this.notebookSelect.addEventListener('select-item', e => {
})
this.selectPane = document.createElement('div')
this.selectPane.append(this.dataSelect, this.notebookSelect)
this.selectPane.classList.add('select')
this.viewPane = document.createElement('div')
this.viewPane.classList.add('view-pane')
this.shadowRoot.append(this.selectPane, this.viewPane)
}
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 1fr;
grid-template-rows: 1fr;
gap: 10px;
height: 100vh;
margin: 0;
padding: 0;
color: #bfcfcd;
}
@media (max-width: 600px) {
:host {
height: auto;
grid-template-columns: 1fr;
grid-template-rows: auto 100vh;
}
}
div.select {
display: flex;
flex-direction: column;
padding: 10px;
overflow-y: auto;
}
div.view-pane {
display: flex;
flex-direction: column;
padding: 10px;
}
div.view-pane iframe {
flex-grow: 1;
border: none;
padding: 10px;
border-radius: 10px;
background-color: #2b172a;
}
`
this.shadowRoot.append(style)
}
updateNotebookItems() {
this.notebookSelect.items = this.notebookTemplates[
this.dataSelect.selectedItem.name
].map((template, i) => {
const el = document.createElement('file-card')
el.name = template
if (i === 0) {
el.setAttribute('selected', true)
}
return el
})
}
}
app.js
import {FileCard} from '/FileCard.js'
import {FileCardList} from '/FileCardList.js'
import {ExampleApp} from '/ExampleApp.js'
customElements.define('file-card', FileCard)
customElements.define('file-card-list', FileCardList)
customElements.define('example-app', ExampleApp)
async function setup() {
document.body.append(document.createElement('example-app'))
}
setup()
Icon svg in icons
: google material-design-icons, Apache 2.0
Other content: Apache 2.0