title="23 wallets"
- actions={
- {/* TODO: mr-2 with CSS */}
-
+ page={1}
+ pages={3}
+ actions={<>
+
Manage backups
-
+
Create wallet
-
}
+ >}
filters={<>
{/* Search filter textbox */}
-
-
+
+
{/* Category selection box */}
-
-
-
+
>}
+ headers={WALLET_HEADERS}
/>;
}
}
diff --git a/src/scss/_theme.scss b/src/scss/_theme.scss
index 8cc03ec..ad55ab4 100644
--- a/src/scss/_theme.scss
+++ b/src/scss/_theme.scss
@@ -54,6 +54,8 @@
$input-btn-focus-color: rgba($info, 0.5);
+$input-group-addon-bg: $secondary;
+
.was-validated .form-control:invalid, .form-control.is-invalid {
/* Make invalid form controls have a red glow */
&:not(:focus) {
@@ -85,3 +87,23 @@
/* Close button isn't vertically aligned in modal header by default. */
align-items: center;
}
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: $border-radius;
+}
+
+::-webkit-scrollbar-thumb {
+ background: $secondary;
+ border-radius: $border-radius;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: $slighter;
+}
+
diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss
index aa0bb0a..92d3d6a 100644
--- a/src/scss/_variables.scss
+++ b/src/scss/_variables.scss
@@ -4,7 +4,7 @@
@import
"~bootstrap/scss/functions",
"~bootstrap/scss/variables",
- "~bootstrap/scss/mixins/breakpoints";
+ "~bootstrap/scss/mixins";
/* -------------------------------------------------------------------------- */
/* NAVBAR */
diff --git a/src/shared-components/list-view/ColumnHeader.scss b/src/shared-components/list-view/ColumnHeader.scss
new file mode 100644
index 0000000..5e5ca58
--- /dev/null
+++ b/src/shared-components/list-view/ColumnHeader.scss
@@ -0,0 +1,18 @@
+@import "~scss/variables";
+
+.column-sort {
+ margin-left: 0.25rem;
+ cursor: pointer;
+
+ &-asc, &-desc {
+ color: $body-color;
+ }
+
+ &-none {
+ color: rgba($text-muted, 0.75);
+
+ &:hover {
+ color: $body-hover-color;
+ }
+ }
+}
diff --git a/src/shared-components/list-view/ColumnHeader.tsx b/src/shared-components/list-view/ColumnHeader.tsx
new file mode 100644
index 0000000..5b8deca
--- /dev/null
+++ b/src/shared-components/list-view/ColumnHeader.tsx
@@ -0,0 +1,51 @@
+import React, { ReactNode, Component } from "react";
+
+import { HeaderSpec, SortDirection } from ".";
+
+import "./ColumnHeader.scss";
+
+interface Props {
+ headerKey: Extract;
+ headerSpec: HeaderSpec;
+
+ sortDirection?: SortDirection;
+
+ onSort: (headerKey?: Extract, direction?: SortDirection) => void;
+}
+
+export class ColumnHeader extends Component> {
+ onClickSort(): void {
+ const { headerKey, sortDirection: oldDirection, onSort } = this.props;
+
+ if (oldDirection === undefined) // NONE -> ASC
+ onSort(headerKey, SortDirection.ASC);
+ else if (oldDirection === SortDirection.ASC) // ASC -> DESC
+ onSort(headerKey, SortDirection.DESC);
+ else if (oldDirection === SortDirection.DESC) // DESC -> NONE
+ onSort(undefined, undefined);
+ }
+
+ render(): ReactNode {
+ const { headerSpec, sortDirection } = this.props;
+
+ // If 'sortable' is undefined, then assume it is sortable
+ const sortable = headerSpec.sortable === undefined ? true : headerSpec.sortable;
+
+ // Decide which sort button to render
+ const sortButton = () => {
+ switch (sortDirection) {
+ case SortDirection.ASC:
+ return this.onClickSort()} className="column-sort column-sort-asc icon-up-open" />;
+ case SortDirection.DESC:
+ return this.onClickSort()} className="column-sort column-sort-desc icon-down-open" />;
+ default: // no sort
+ return this.onClickSort()} className="column-sort column-sort-none icon-down-open" />;
+ }
+ };
+
+ return
+ {headerSpec.name}
+ {sortable && sortButton()}
+ | ;
+ }
+}
diff --git a/src/shared-components/list-view/FilterSelect.scss b/src/shared-components/list-view/FilterSelect.scss
new file mode 100644
index 0000000..f263afa
--- /dev/null
+++ b/src/shared-components/list-view/FilterSelect.scss
@@ -0,0 +1,3 @@
+.list-view .list-view-filter-select {
+ max-width: 360px;
+}
diff --git a/src/shared-components/list-view/FilterSelect.tsx b/src/shared-components/list-view/FilterSelect.tsx
index 4d5c2ae..cda33dc 100644
--- a/src/shared-components/list-view/FilterSelect.tsx
+++ b/src/shared-components/list-view/FilterSelect.tsx
@@ -2,6 +2,8 @@
import Form from "react-bootstrap/Form";
+import "./FilterSelect.scss";
+
interface Props {
/** `options` is a map representing the form value and the human-readable
* text to display it. This map is ordered by insertion order. */
@@ -9,11 +11,11 @@
}
export const FilterSelect: React.FC = (props: Props) => (
-
+
{/* Insert each select option as an )}
+ )}
);
diff --git a/src/shared-components/list-view/ListPagination.scss b/src/shared-components/list-view/ListPagination.scss
new file mode 100644
index 0000000..2e5cc00
--- /dev/null
+++ b/src/shared-components/list-view/ListPagination.scss
@@ -0,0 +1,40 @@
+@import "~scss/variables";
+
+.list-view .list-view-pagination {
+ // Reset some bootstrap input group properties
+ display: inline-flex;
+ flex-wrap: nowrap;
+
+ width: auto;
+ margin-left: auto;
+
+ .btn {
+ border: none;
+ }
+
+ .list-view-pagination-inner {
+ position: relative;
+ }
+
+ .form-control {
+ display: block;
+ min-width: 96px;
+
+ text-align: center;
+
+ border-radius: 0;
+
+ float: left;
+ }
+
+ .list-view-pagination-grower {
+ white-space: nowrap;
+ word-wrap: break-all;
+
+ height: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+
+ float: right;
+ }
+}
diff --git a/src/shared-components/list-view/ListPagination.tsx b/src/shared-components/list-view/ListPagination.tsx
new file mode 100644
index 0000000..71a3955
--- /dev/null
+++ b/src/shared-components/list-view/ListPagination.tsx
@@ -0,0 +1,106 @@
+import React, { ReactNode, Component, createRef, ChangeEvent } from "react";
+
+import InputGroup from "react-bootstrap/InputGroup";
+import Button from "react-bootstrap/Button";
+import FormControl from "react-bootstrap/FormControl";
+
+import "./ListPagination.scss";
+
+interface Props {
+ defaultPage: number;
+ pages: number;
+}
+
+interface State {
+ focus: boolean;
+ value: string;
+}
+
+export class ListPagination extends Component {
+ private textInput = createRef();
+ private selected = false;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ focus: false,
+ value: this.props.defaultPage.toString()
+ };
+ }
+
+ private handleFocus() {
+ // First of all, assign the focus (triggering re-render)
+ this.setState({ focus: true });
+ }
+
+ private handleUnfocus() {
+ // First of all, unassign the focus (triggering re-render)
+ this.setState({ focus: false });
+ this.selected = false;
+ }
+
+ private handleChange(event: ChangeEvent) {
+ if (!this.state.focus) return; // Don't care if not focused (it was us)
+
+ const attemptedValue = event.target.value;
+ if (!attemptedValue.match(/^[0-9]+$/)) return; // Ensure it's a number
+
+ const value = Math.min(Math.max(parseInt(attemptedValue), 1), this.props.pages).toString();
+
+ // Let the user actually type in the input
+ this.setState({ value });
+ }
+
+ componentDidUpdate(): void {
+ // Highlight the text in the input if we just focused it
+ const input = this.textInput.current;
+ if (this.state.focus && input && !this.selected) {
+ this.selected = true;
+ input.select();
+ input.setSelectionRange(0, input.value.length);
+ }
+ }
+
+ private getFriendlyText() {
+ const pageNumber = parseInt(this.state.value);
+ return `Page ${pageNumber.toLocaleString()} of ${this.props.pages}`;
+ }
+
+ render(): ReactNode {
+ return (
+
+ {/* Group the prev/next buttons with the page text/input */}
+ {/* Prev button */}
+
+
+
+
+ {/* Used to contain the real input and the fake input */}
+
+ {/* Dummy text used to auto-grow the input */}
+
+ {this.getFriendlyText()}
+
+
+ {/* Page number input */}
+
+
+
+ {/* Next button */}
+
+
+
+
+ );
+ }
+};
diff --git a/src/shared-components/list-view/ListTable.scss b/src/shared-components/list-view/ListTable.scss
new file mode 100644
index 0000000..a54ba37
--- /dev/null
+++ b/src/shared-components/list-view/ListTable.scss
@@ -0,0 +1,7 @@
+@import "~scss/variables";
+
+.list-view table {
+ thead {
+ border-bottom: 1px solid $border-color;
+ }
+}
diff --git a/src/shared-components/list-view/ListTable.tsx b/src/shared-components/list-view/ListTable.tsx
new file mode 100644
index 0000000..e646944
--- /dev/null
+++ b/src/shared-components/list-view/ListTable.tsx
@@ -0,0 +1,86 @@
+import React, { Component, ReactNode } from "react";
+import { HeaderSpec, SortDirection } from ".";
+
+import Table from "react-bootstrap/Table";
+
+import { ColumnHeader } from "./ColumnHeader";
+import { KristValue } from "@components/krist-value";
+
+import "./ListTable.scss";
+
+interface State {
+ orderBy?: Extract;
+ order?: SortDirection;
+}
+
+interface Props {
+ headers?: Map, HeaderSpec>;
+}
+
+export class ListTable extends Component, State> {
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ orderBy: undefined,
+ order: undefined
+ };
+ }
+
+ setSort(orderBy?: Extract, order?: SortDirection): void {
+ this.setState({
+ orderBy, order
+ });
+ }
+
+ render(): ReactNode {
+ const { headers } = this.props;
+ const { orderBy, order } = this.state;
+
+ return
+ {/* Table headers, defined by the header map in the props */}
+ {headers &&
+
+ {Array.from(headers, ([headerKey, headerSpec]) =>
+ ())}
+
+ }
+
+ {/* Table rows */}
+
+
+ Shop Wallet |
+ kreichdyes |
+ |
+ 12 |
+ Shops |
+ 2020/09/11 08:08 |
+
+
+
+ Main Wallet |
+ khugepoopy |
+ |
+ 3 |
+ |
+ 2016/02/14 00:00 |
+
+
+
+ Old Wallet |
+ kre3w0i79j |
+ |
+ 0 |
+ |
+ 2015/02/14 00:00 |
+
+
+
;
+ }
+}
diff --git a/src/shared-components/list-view/SearchTextbox.scss b/src/shared-components/list-view/SearchTextbox.scss
new file mode 100644
index 0000000..38a7b69
--- /dev/null
+++ b/src/shared-components/list-view/SearchTextbox.scss
@@ -0,0 +1,3 @@
+.list-view .list-view-search-textbox {
+ max-width: 360px;
+}
diff --git a/src/shared-components/list-view/SearchTextbox.tsx b/src/shared-components/list-view/SearchTextbox.tsx
index b6a0474..cdb5c0f 100644
--- a/src/shared-components/list-view/SearchTextbox.tsx
+++ b/src/shared-components/list-view/SearchTextbox.tsx
@@ -2,10 +2,12 @@
import Form from "react-bootstrap/Form";
+import "./SearchTextbox.scss";
+
interface Props {
placeholder: string;
}
-export const SearchTextbox: React.FC = (props: Props) => {
- return ;
-};
+export const SearchTextbox: React.FC = (props: Props) => (
+
+);
diff --git a/src/shared-components/list-view/index.scss b/src/shared-components/list-view/index.scss
new file mode 100644
index 0000000..4660662
--- /dev/null
+++ b/src/shared-components/list-view/index.scss
@@ -0,0 +1,15 @@
+.list-view {
+ .list-view-actions, .list-view-filters {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+
+ .btn, .form-control, .custom-select {
+ margin-right: 0.5rem;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+}
diff --git a/src/shared-components/list-view/index.tsx b/src/shared-components/list-view/index.tsx
index faf85cb..2031bbd 100644
--- a/src/shared-components/list-view/index.tsx
+++ b/src/shared-components/list-view/index.tsx
@@ -1,34 +1,71 @@
-import React, { ReactNode } from "react";
+import React, { Component, ReactNode } from "react";
import Container from "react-bootstrap/Container";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
-interface Props {
+import { ListPagination } from "./ListPagination";
+
+import "./index.scss";
+import { ListTable } from "./ListTable";
+
+export enum SortDirection {
+ ASC, DESC
+}
+
+export interface HeaderSpec {
+ name: string;
+ sortable?: boolean;
+}
+
+interface Props {
title?: string;
actions?: ReactNode;
filters?: ReactNode;
-};
-export const ListView: React.FC = (props: Props) => {
- return
- {/* Main header row - wallet count and action buttons */}
-
-
- {/* List title */}
- {props.title && {props.title}
}
+ page?: number;
+ pages?: number;
- {/* Optional action button row */}
- {props.actions}
-
-
+ headers?: Map, HeaderSpec>;
+}
- {/* Search, filter and pagination row */}
-
- {/* List filters (e.g. search, category dropdown) */}
- {props.filters}
+export class ListView extends Component> {
+ render(): ReactNode {
+ const {
+ title, actions, filters,
+ page, pages,
+ headers
+ } = this.props;
- {/* Pagination */}
-
- ;
-};
+ return
+ {/* Main header row - wallet count and action buttons */}
+
+
+ {/* List title */}
+ {title && {title}
}
+
+ {/* Optional action button row */}
+ {actions && {actions}
}
+
+
+
+ {/* Search, filter and pagination row */}
+
+ {/* List filters (e.g. search, category dropdown) */}
+ {filters &&
+
+ {filters}
+ }
+
+ {/* Pagination */}
+ {page && pages &&
+
+
+ }
+
+
+ {/* Main table */}
+
+ ;
+ }
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 5559f9e..bf6dc37 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -11,3 +11,13 @@
// eslint-disable-next-line @typescript-eslint/no-empty-function
export const noop = (): void => {};
+
+export function selectContents(element: Element): void {
+ const range = document.createRange();
+ range.selectNodeContents(element);
+
+ const selection = window.getSelection();
+ if (!selection) throw new Error("Couldn't get window selection");
+ selection.removeAllRanges();
+ selection.addRange(range);
+}
diff --git a/tsconfig.json b/tsconfig.json
index 78c2195..1efefcc 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,6 +18,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
+ "noImplicitAny": true,
"jsx": "react"
},
"extends": "./tsconfig.extend.json",