Skip to content

Commit 4c7ee38

Browse files
authored
Start implementing cockpit design from Figma (#42)
* Implement UI header according to the Figma design * Update datatable to be closer to the Figma design * Only show refresh button when a refresh fn is supplied * Listeners page: use refresh button * Make eslint happy * Add search box to listener table * Embed loading bar into datatable * Update website title in listener page * Use table refresh/loading indicators for stacklet list * Always reserve space for the DT loading bar * "Add stacklet" button * Fix eslint warnings * Replace material symbols with feather icons * Delete colour definitions that Uno already provides * Rename stackable brand colours * Fix non-reactive icon * Ignore innerhtml false positive
1 parent d2eda6b commit 4c7ee38

File tree

14 files changed

+284
-69
lines changed

14 files changed

+284
-69
lines changed

pnpm-lock.yaml

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"dev": "vite"
1616
},
1717
"devDependencies": {
18+
"@types/feather-icons": "^4.29.1",
1819
"@typescript-eslint/eslint-plugin": "^5.59.5",
1920
"@typescript-eslint/parser": "^5.59.5",
2021
"@vitest/coverage-c8": "^0.31.1",
@@ -34,6 +35,7 @@
3435
"dependencies": {
3536
"@solidjs/router": "^0.8.2",
3637
"@unocss/reset": "^0.51.12",
38+
"feather-icons": "^4.29.0",
3739
"openapi-fetch": "^0.2.0",
3840
"solid-js": "^1.7.4"
3941
}

web/src/components/button.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { A } from '@solidjs/router';
2+
import { JSX } from 'solid-js';
3+
4+
/// Special types of buttons that need specific callouts
5+
export type ButtonRole = 'primary';
6+
7+
export interface VisualButtonProps {
8+
children: JSX.Element;
9+
role?: ButtonRole;
10+
}
11+
12+
const buttonProps = (props: VisualButtonProps) => {
13+
const roleClasses =
14+
// buttonProps is only called within a reactive scope
15+
// eslint-disable-next-line solid/reactivity
16+
props.role === 'primary'
17+
? 'bg-stackable-blue-light hover-bg-stackable-blue-dark active-stackable-blue-dark border-stackable-blue-dark'
18+
: 'bg-gray-700 hover-bg-gray-600 active-bg-gray-500 border-gray-600';
19+
20+
return {
21+
class: `p-2 text-size-4 c-white rounded border-1 border-solid cursor-pointer decoration-none ${roleClasses}`,
22+
};
23+
};
24+
25+
export interface ButtonProps extends VisualButtonProps {
26+
onClick: () => void;
27+
}
28+
29+
export const Button = (props: ButtonProps) => (
30+
<button {...buttonProps(props)} onClick={() => props.onClick()}>
31+
{props.children}
32+
</button>
33+
);
34+
35+
export interface ButtonLinkProps extends VisualButtonProps {
36+
href: string;
37+
}
38+
39+
export const ButtonLink = (props: ButtonLinkProps) => (
40+
<A {...buttonProps(props)} href={props.href}>
41+
{props.children}
42+
</A>
43+
);

web/src/components/datatable.tsx

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { For, JSX, Show, createMemo, createSignal } from 'solid-js';
2+
import { Button } from './button';
3+
import { SearchInput } from './form/search';
4+
import { LoadingBar } from './loading';
25

36
export interface DataTableColumn<T> {
47
label: string;
@@ -9,6 +12,14 @@ export interface DataTableColumn<T> {
912
export interface DataTableProps<T> {
1013
columns: DataTableColumn<T>[];
1114
items: T[];
15+
16+
searchQuery?: string;
17+
setSearchQuery?: (query: string) => void;
18+
19+
extraButtons?: JSX.Element;
20+
21+
refresh?: () => void;
22+
isLoading?: boolean;
1223
}
1324

1425
export function DataTable<T>(props: DataTableProps<T>): JSX.Element {
@@ -35,7 +46,20 @@ export function DataTable<T>(props: DataTableProps<T>): JSX.Element {
3546
};
3647

3748
return (
38-
<>
49+
<div class='bg-gray-800 rounded-2 overflow-clip'>
50+
<div class='p-4 flex flex-gap-4'>
51+
<Show when={props.searchQuery !== undefined}>
52+
<SearchInput
53+
query={props.searchQuery || ''}
54+
setQuery={(q) => props.setSearchQuery?.(q)}
55+
/>
56+
</Show>
57+
<div class='flex-grow' />
58+
{props.extraButtons}
59+
<Show when={props.refresh}>
60+
<Button onClick={() => props.refresh?.()}>Refresh</Button>
61+
</Show>
62+
</div>
3963
<table class='font-sans border-collapse text-left w-full'>
4064
<thead class='text-xs uppercase text-gray-400 bg-gray-700'>
4165
<tr>
@@ -55,14 +79,21 @@ export function DataTable<T>(props: DataTableProps<T>): JSX.Element {
5579
)}
5680
</For>
5781
</tr>
82+
<tr>
83+
<th class='line-height-0 m-0 p-0' colspan={props.columns.length}>
84+
<div classList={{ invisible: !props.isLoading }}>
85+
<LoadingBar />
86+
</div>
87+
</th>
88+
</tr>
5889
</thead>
5990
<tbody>
6091
<For each={sortedItems()}>
6192
{(item) => (
62-
<tr class='bg-gray-800 border-b border-b-style-solid border-gray-700'>
93+
<tr class='border-t border-t-style-solid border-gray-700'>
6394
<For each={props.columns}>
6495
{(col) => (
65-
<td class='px-4 py-3 font-medium text-gray-400'>
96+
<td class='px-4 py-3 font-medium text-white'>
6697
{col.get(item)}
6798
</td>
6899
)}
@@ -72,7 +103,7 @@ export function DataTable<T>(props: DataTableProps<T>): JSX.Element {
72103
</For>
73104
</tbody>
74105
</table>
75-
</>
106+
</div>
76107
);
77108
}
78109

web/src/components/form/search.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { SearchSymbol } from '../symbols';
2+
3+
interface SearchInputProps {
4+
query: string;
5+
setQuery: (query: string) => void;
6+
}
7+
export const SearchInput = (props: SearchInputProps) => {
8+
return (
9+
<label class='bg-gray-600 rounded flex flex-items-center c-gray-200'>
10+
<div class='p-1'>
11+
<SearchSymbol />
12+
</div>
13+
<input
14+
class='inline flex-grow h-full b-none bg-transparent c-gray-200'
15+
placeholder='Search'
16+
value={props.query}
17+
onInput={(event) => props.setQuery(event.currentTarget.value)}
18+
/>
19+
</label>
20+
);
21+
};

web/src/components/header.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { A } from '@solidjs/router';
2+
import { ParentProps } from 'solid-js';
3+
import logo from '../resources/logo.png';
4+
5+
interface NavItemProps {
6+
href: string;
7+
}
8+
9+
const NavItem = (props: ParentProps<NavItemProps>) => (
10+
<li class='block h-auto ml-4'>
11+
<A
12+
href={props.href}
13+
class='p-4 c-white flex flex-items-center h-full decoration-none bg-gray-900'
14+
inactiveClass='bg-opacity-30 hover:bg-opacity-50'
15+
>
16+
{props.children}
17+
</A>
18+
</li>
19+
);
20+
21+
export const Header = () => {
22+
return (
23+
<nav class='flex bg-gray-600 h-16 px-4'>
24+
<h1 class='m-0 c-white'>
25+
<A class='flex flex-items-center h-full' href='/'>
26+
<img
27+
src={logo}
28+
elementtiming='logo'
29+
fetchpriority='auto'
30+
alt='Stackable'
31+
/>
32+
</A>
33+
</h1>
34+
<ul class='flex-auto m-0 p-0 flex'>
35+
<NavItem href='/stacklets'>Stacklets</NavItem>
36+
<NavItem href='/listeners'>Listeners</NavItem>
37+
<NavItem href='/stacks'>Stacks</NavItem>
38+
</ul>
39+
</nav>
40+
);
41+
};

web/src/components/loading.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const LoadingBar = () => (
2+
<svg class='h-1 w-full' viewBox='0 0 10 1' preserveAspectRatio='none'>
3+
<title>Loading...</title>
4+
<rect width='10' height='1' fill='#9CA3AF' />
5+
<rect width='1' height='3' y='-1' fill='#111827'>
6+
<animateMotion repeatCount='indefinite' dur='2s' path='M-1,0 L10,0' />
7+
</rect>
8+
</svg>
9+
);

web/src/components/symbols.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { FeatherIconNames, icons as featherIcons } from 'feather-icons';
2+
3+
interface FeatherSymbolProps {
4+
icon: FeatherIconNames;
5+
}
6+
const FeatherSymbol = (props: FeatherSymbolProps) => {
7+
const icon = () => featherIcons[props.icon];
8+
// Icon contents are provided statically by feather, and not influenced by user input
9+
// eslint-disable-next-line solid/no-innerhtml
10+
return <svg {...icon().attrs} innerHTML={icon().contents} />;
11+
};
12+
13+
export const SearchSymbol = () => <FeatherSymbol icon='search' />;
14+
export const AddSymbol = () => <FeatherSymbol icon='plus' />;

web/src/components/title.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { createEffect, onCleanup } from 'solid-js';
2+
import { createMutable } from 'solid-js/store';
3+
4+
const originalTitle = document.title;
5+
const titleStack = createMutable([() => originalTitle]);
6+
createEffect(() => {
7+
/* console.debug('Updating title stack', titleStack); */
8+
document.title = titleStack.map((x) => x()).join(' :: ');
9+
});
10+
11+
interface TitleProps {
12+
children: string;
13+
}
14+
export const Title = (props: TitleProps) => {
15+
/* console.debug('Adding to title stack', props.children); */
16+
const getTitle = () => props.children;
17+
// titleStack is consumed in a reactive context, so this is valid
18+
// eslint-disable-next-line solid/reactivity
19+
titleStack.push(getTitle);
20+
onCleanup(() => {
21+
/* console.debug(`Removing from title stack`, props.children); */
22+
const stackEntryIndex = titleStack.indexOf(getTitle);
23+
if (stackEntryIndex === -1) {
24+
throw new Error(
25+
`Title stack entry for ${props.children} could not be found`,
26+
);
27+
}
28+
titleStack.splice(stackEntryIndex, 1);
29+
});
30+
return <></>;
31+
};

0 commit comments

Comments
 (0)