Skip to content

Latest commit

 

History

History
295 lines (240 loc) · 8.8 KB

file_browser.md

File metadata and controls

295 lines (240 loc) · 8.8 KB

File Browser Example

A simple file browser example built with regular-table. Also a great introduction to row_headers, and how to use them to achieve group-like and tree-like behavior. For this example, we'll want the latter.

<regular-table id="regularTable"></regular-table>

Tree-like row_headers

regular-table will merge consecutive <th> defined in row_headers with the same content, but it will prefer rowspan to colspan, inserting empty <th> when necessary to fill-in gaps, since table-cell elements cannot overlap. Knowing this, it is easy to fine-tune header structure and behavior with empty cells. In this case, we want to modify the basic group-like row_headers layout to support tree-like asymmetric groups. Typically, when representing groups of rows via row_headers, for example a file structure like so:

  • Dir_1
    • Dir_2
      • File_1
    • File_2

... one may think to implement a regular-table Virtual Data Model using a row_headers parameter like this:

[["Dir_1"], ["Dir_1", "Dir_2"], ["Dir_1", "Dir_2", "File_1"], ["Dir_1", "File_2"]]

This will render group-like row headers, with the consecutive "Dir_1" and "Dir_2" elements merged via rowspan. The resulting headers visually indicate all content on the right-hand side belong to the directory. This is exactly what column headers do, but it is not very like a file-tree; each directory "level" will determine its respective column's minimum width, and deeply assymmetric trees will yield wide row headers.

Dir_1-
Dir_2-
File_1
File_2

Group-like row headers are nice for always keeping the entire directory path in view regardless of scroll position, but for a more tree-like like experience, we can instead replace the consecutive duplicates with "".

[["Dir_1"], ["", "Dir_2"], ["", "", "File_1"], ["", "File_2"]]

The new consecutive "" will still merge via rowspan, excluding the first row, but regular-table will detect that a <th> lacks a rowspan, and instead merge trailing undefined/empty values via colspan to produce one long <th> for each row header group, as in the HTML below. In this tree-like layout, no content will exclusively occupy any but the last column of row_headers, and these empty columns can then be sized via CSS to create trees of any geometry, where e.g. "directory" group rows overlap the columns of their children as-in a conventional file tree.

Dir_1
-Dir_2
-File_1
File_2

Despite this long-winded explanation, the implementation in Javascript is fairly straightforward, and for our purposes, we only need create one such path for row_headers at a time.

function new_path(n, name) {
    return Array(n).fill("").concat([name]);
}

File System

We can use a regular 2D Array, row oriented, for the file system listing state itself, including file metadata like size and the open/closed state of directory rows.

const COLUMNS = [["size"], ["kind"], ["modified"], ["writable"]];
const DATA = Array.from(generateDirContents());

These file-metadata rows are fake, but for the purposes of an example, they are worth putting "B Movie"-level effort into making look like a "real" file system.

function new_row(type) {
    const scale = Math.random() > 0.5 ? "kb" : "mb";
    const size = numberFormat(Math.pow(Math.random(), 2) * 1000);
    const date = dateFormat(new Date());
    return [`${size} ${scale}`, type, date, true];
}

For the fake file system contents themselves, we will generate directory contents on the fly as directories are opened and closed by the user.

function* generateDirContents(n = 0) {
    for (let i = 0; i < 5; i++) {
        yield {
            path: new_path(n, `Dir_${i}`),
            row: new_row("directory"),
            is_open: false,
        };
    }
    for (let i = 0; i < 5; i++) {
        yield {
            path: new_path(n, `File_${i}`),
            row: new_row("file"),
        };
    }
}

Open and close directory operations are applied via DATA.splice(), mutating the Array reference directly and inserting or stripping elements as needed.

function closeDir(y) {
    const path = DATA[y].path;
    while (y + 2 < DATA.length && DATA[y + 1].path.length > path.length) {
        DATA.splice(y + 1, 1);
    }
}

function openDir(y) {
    const new_contents = generateDirContents(DATA[y].path.length);
    DATA.splice(y + 1, 0, ...Array.from(new_contents));
}

function toggleDir(y) {
    const { is_open } = DATA[y];
    if (is_open) {
        closeDir(y);
    } else {
        openDir(y);
    }

    DATA[y].is_open = !is_open;
}

Virtual Data Model

DATA needs to be transposed before we can return slices of it from our dataListener() function, because it is row-oriented and regular-table expects column-oriented data.

function transpose(m) {
    return m.length === 0 ? [] : m[0].map((x, i) => m.map((x) => x[i]));
}

Otherwise, this dataListener() is very similar to 2d_array.md.

function dataListener(x0, y0, x1, y1) {
    return {
        num_rows: DATA.length,
        num_columns: DATA[0].row.length,
        row_headers: DATA.slice(y0, y1).map((z) => z.path.slice()),
        column_headers: COLUMNS.slice(x0, x1),
        data: transpose(DATA.slice(y0, y1).map(({ row }) => row.slice(x0, x1))),
    };
}

Custom Style

Directory and file icon styles applied as classes, using getMeta(), every td is mapped back to it's row in DATA.

function styleListener() {
    for (const td of window.regularTable.querySelectorAll("tbody th")) {
        const { y, value } = window.regularTable.getMeta(td);
        const { row, is_open } = DATA[y];
        const [, type] = row;
        td.classList.toggle("fb-directory", !!value && type === "directory");
        td.classList.toggle("fb-file", !!value && type === "file");
        td.classList.toggle("fb-open", !!value && is_open);
    }
}

UI

When directory rows are clicked, generate new directory contents at the td metadata's y coordinate in DATA and redraw.

// TODO `resetAutoSize()` is not documented - this is currently required to
// prevent the column size scroll memoize functionality from pinning the sizes
// of the 'blank' cells, as these columns may be re-purposed as the user expands
// or collapses the tree.  But auto-sizing is not well formalized feature yet
// and this API is just a stand-in.

function mousedownListener() {
    if (event.target.tagName === "TH") {
        const meta = regularTable.getMeta(event.target);
        if (DATA[meta.y].row[1] === "directory") {
            toggleDir(meta.y);
            regularTable._resetAutoSize();
            regularTable.draw();
        }
    }
}

Main

export function init() {
    regularTable.setDataListener(dataListener);
    regularTable.addStyleListener(styleListener);
    regularTable.addEventListener("mousedown", mousedownListener);
    regularTable.addEventListener("scroll", () => {
        regularTable._resetAutoSize();
    });
    regularTable.draw();
}
<script type="module">
    import { init } from "/dist/examples/file_browser.js";
    window.addEventListener("load", () => init());
</script>

CSS

Icons

tbody th.fb-directory:before {
    font-family: "Material Icons";
    content: "folder ";
}
tbody th.fb-directory.fb-open:before {
    content: "folder_open ";
}
tbody th.fb-file:before {
    font-family: "Material Icons";
    content: "text_snippet ";
}

Basic theme

table thead,
table tbody {
    user-select: none;
}
td:first-of-type,
head th {
    text-align: right;
}

Set dimensions of "tree" structure.

tbody th:empty {
    min-width: 20px;
    max-width: 20px;
}

Appendix (Utilities)

function numberFormat(x) {
    const formatter = new Intl.NumberFormat("en-us", {
        style: "decimal",
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
    });
    return formatter.format(x);
}

function dateFormat(x) {
    const formatter = new Intl.DateTimeFormat("en-us", {
        week: "numeric",
        year: "numeric",
        month: "numeric",
        day: "numeric",
        hour: "numeric",
        minute: "numeric",
        second: "numeric",
    });
    return formatter.format(x);
}

Appendix (Dependencies)

<script src="/dist/esm/regular-table.js"></script>
<link rel="stylesheet" href="/dist/css/material.css" />
license: apache-2.0