This library implements live tiles with drag-n-drop, including group transfer and shifting of conflicting tiles.
Note: The internal layout algorithm is subject to improvement.
For creating live tiles, you need some additional code due to the drag-n-drop behavior; it is optional however. You can refer to the API documentation for more information.
// register Metro design fonts
import "com.sweaxizone.metro/fonts";
// third-party
import { createRoot } from "react-dom/client";
import * as React from "react";
import {
Root,
Group,
HGroup,
VGroup,
CheckBox,
Button,
Label,
Icon,
Tiles,
TileGroup,
Tile,
TilePage,
TileDND,
} from "com.sweaxizone.metro/components";
import {
type BulkChange,
type TileSize,
type CoreDirection,
} from "com.sweaxizone.metro/liveTiles";
import { RTLProvider } from "com.sweaxizone.metro/layout";
import {
Primary,
ThemePresets,
ThemeProvider,
type Theme,
} from "com.sweaxizone.metro/theme";
import {
randomHex,
} from "com.sweaxizone.metro/utils";
/**
* The test.
*/
function App() {
// short defer for reordering groups
const group_reorder_timeout = React.useRef(-1);
// drag-n-drop related
const [tile_dragging, set_tile_dragging] = React.useState<null | { id: string, tile: MyTile }>(null);
// live tiles direction
const [tiles_direction, set_tiles_direction] = React.useState<CoreDirection>(window.matchMedia("(orientation: portrait)").matches ? "vertical" : "horizontal");
// our groups
const [groups, set_groups] = React.useState<MyGroup[]>([
{
id: "group1",
label: "Group 1",
tiles: new Map([
["terminal", { size: "medium", x: -1, y: -1 }],
["camera", { size: "medium", x: -1, y: -1 }],
["bing", { size: "small", x: -1, y: -1 }],
]),
},
{
id: "group2",
label: "Group 2",
tiles: new Map([
["internetExplorer", { size: "medium", x: -1, y: -1 }],
]),
},
{
id: "group3",
label: "Group 3",
tiles: new Map([
["settings", { size: "medium", x: -1, y: -1 }],
]),
},
]);
const groups_sync = React.useRef(groups);
// initialization
React.useEffect(() => {
// observe orientation
const portrait_media_query = window.matchMedia("(orientation: portrait)");
portrait_media_query.addEventListener("change", adapt);
function adapt(e: MediaQueryListEvent): void {
set_tiles_direction(e.matches ? "vertical" : "horizontal");
}
// cleanup
return () => {
portrait_media_query.removeEventListener("change", adapt);
};
}, []);
// sync groups into a ref
React.useEffect(() => {
groups_sync.current = groups;
}, [groups]);
// render groups.
function render_groups(): React.ReactNode[] {
let group_nodes: React.ReactNode[] = [];
for (let [i, group] of groups.entries()) {
const tile_nodes: React.ReactNode[] = [];
for (const [id, tile] of group.tiles) {
const node = render_tile(id, tile);
if (node) {
tile_nodes.push(node);
}
}
group_nodes.push(
<TileGroup key={group.id} id={group.id} index={i} label={group.label}>
{tile_nodes}
</TileGroup>
);
}
return group_nodes;
}
// render a tile.
function render_tile(id: string, tile: MyTile): undefined | React.ReactNode {
switch (id) {
case "terminal": {
return (
<Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#04bed6" foreground="white">
<TilePage variant="iconLabel" size="small">
<Group><Icon variant="terminal"/></Group>
<Label>Terminal</Label>
</TilePage>
<TilePage size="medium">
<VGroup padding={10} gap={10}>
<Icon variant="terminal" size={27}/>
<Label variant="heading" style={{fontSize: "1rem", margin: "0"}}>Run some Bash</Label>
</VGroup>
</TilePage>
<TilePage size=">= wide">
<VGroup padding={10} gap={10}>
<Icon variant="terminal" size={27}/>
<Label variant="subsubsubheading" style={{margin: "0"}}>Run some Bash</Label>
</VGroup>
</TilePage>
</Tile>
);
}
case "camera": {
return (
<Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#937" foreground="white">
<TilePage variant="labelIcon">
<Label>Camera</Label>
<Group><Icon variant="camera"/></Group>
</TilePage>
<TilePage size=">= wide">
<Group paddingLeft={10}>
<Label variant="subsubheading">Shoot!</Label>
</Group>
</TilePage>
</Tile>
);
}
case "bing": {
return (
<Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#f9c000" foreground="white">
<TilePage variant="iconLabel">
<Group><Icon variant="bing"/></Group>
<Label>Bing</Label>
</TilePage>
</Tile>
);
}
case "internetExplorer": {
return (
<Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#04bed6" foreground="white">
<TilePage variant="iconLabel">
<Group><Icon variant="internetExplorer"/></Group>
<Label>Internet Explorer</Label>
</TilePage>
</Tile>
);
}
case "settings": {
return (
<Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#04bed6" foreground="white">
<TilePage variant="iconLabel">
<Group><Icon variant="settings"/></Group>
<Label>Settings</Label>
</TilePage>
</Tile>
);
}
default: {
return undefined;
}
}
}
// handle bulk change in live tiles
function bulk_change(e: BulkChange): void {
const new_groups = structuredClone(groups);
for (const m of e.movedTiles) {
const g = new_groups.find(g => g.tiles.has(m.id));
if (g) {
const t = g.tiles.get(m.id)!;
t.x = m.x;
t.y = m.y;
}
}
for (const transfer of e.groupTransfers) {
const old_group = new_groups.find(g => g.tiles.has(transfer.id))!;
const new_group = new_groups.find(g => g.id == transfer.group)!;
const t = old_group.tiles.get(transfer.id)!;
t.x = transfer.x;
t.y = transfer.y;
old_group.tiles.delete(transfer.id);
new_group.tiles.set(transfer.id, t);
}
for (const { id: group_id } of e.groupRemovals) {
const i = new_groups.findIndex(g => g.id == group_id);
if (i !== -1) {
new_groups.splice(i, 1);
}
}
if (e.groupCreation) {
const tile_id = e.groupCreation!.tile;
const old_group = new_groups.find(g => g.tiles.has(tile_id))!;
const new_group: MyGroup = {
id: "__" + randomHex(true),
label: "",
tiles: new Map(),
};
new_groups.push(new_group);
const t = old_group.tiles.get(tile_id)!;
t.x = -1;
t.y = -1;
old_group.tiles.delete(tile_id);
new_group.tiles.set(tile_id, t);
}
set_groups(new_groups);
}
// re-order groups
//
// here, keep order sequential and contiguous.
function reorder_groups(e: Map<number, string>): void {
if (group_reorder_timeout.current != -1) {
window.clearTimeout(group_reorder_timeout.current);
group_reorder_timeout.current = -1;
}
group_reorder_timeout.current = window.setTimeout(() => {
let new_groups = structuredClone(groups);
let seq_1 = Array.from(e.entries());
seq_1.sort(([a], [b]) => a - b);
let seq_2: string[] = [];
for (let [, group_id] of seq_1) {
seq_2.push(group_id);
}
new_groups = seq_2.map(group_id => new_groups.find(g => g.id == group_id)!);
set_groups(new_groups);
}, 3);
}
//
function rename_group(e: { id: string, label: string }): void {
let new_groups = structuredClone(groups);
const group = new_groups.find(g => g.id == e.id);
if (group) {
group!.label = e.label;
}
set_groups(new_groups);
}
//
function drag_start(e: { id: string, dnd: HTMLElement }): void {
const group = groups.find(g => g.tiles.has(e.id))!;
const tile = group.tiles.get(e.id)!;
set_tile_dragging({ id: e.id, tile: structuredClone(tile) });
}
//
function drag_end(e: { id: string, dnd: HTMLElement }): void {
set_tile_dragging(null);
}
return (
<ThemeProvider theme={ThemePresets.get("cyan")}>
<RTLProvider rtl={false}>
<Primary prefer={false}>
<Root
full
solid
selection={false}
style={{
overflowX: "auto",
overflowY: "auto",
}}
padding={tiles_direction == "horizontal" ? 10 : 1 }
wheelHorizontal={tiles_direction == "horizontal"}
wheelVertical={tiles_direction == "vertical"}>
<Tiles
direction={tiles_direction}
dragEnabled
checkEnabled
renamingGroupsEnabled
bulkChange={bulk_change}
reorderGroups={reorder_groups}
renameGroup={rename_group}
dragStart={drag_start}
dragEnd={drag_end}>
{render_groups()}
<TileDND>
{tile_dragging ? render_tile(tile_dragging!.id, tile_dragging!.tile) : undefined}
</TileDND>
</Tiles>
</Root>
</Primary>
</RTLProvider>
</ThemeProvider>
);
}
// a tile group
export type MyGroup = {
id: string,
label: string,
tiles: Map<string, MyTile>,
};
// a tile
export type MyTile = {
x: number,
y: number,
size: TileSize,
};
// Render App
const root = createRoot(document.getElementById("root")!);
root.render(<App />);