aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/console/components/Console.tsx48
-rw-r--r--src/console/components/Theme.ts53
-rw-r--r--src/console/components/console.scss141
-rw-r--r--src/console/components/console/Completion.tsx49
-rw-r--r--src/console/components/console/CompletionItem.tsx75
-rw-r--r--src/console/components/console/CompletionTitle.tsx17
-rw-r--r--src/console/components/console/Input.tsx27
-rw-r--r--src/console/components/console/Message.tsx31
-rw-r--r--src/console/index.css30
-rw-r--r--src/console/index.tsx1
-rw-r--r--src/content/presenters/FocusPresenter.ts2
-rw-r--r--src/content/site-style.ts4
-rw-r--r--src/settings/components/form/BlacklistForm.scss9
-rw-r--r--src/settings/components/form/BlacklistForm.tsx83
-rw-r--r--src/settings/components/form/KeymapsForm.scss11
-rw-r--r--src/settings/components/form/KeymapsForm.tsx27
-rw-r--r--src/settings/components/form/PartialBlacklistForm.scss28
-rw-r--r--src/settings/components/form/PartialBlacklistForm.tsx126
-rw-r--r--src/settings/components/form/PropertiesForm.scss12
-rw-r--r--src/settings/components/form/PropertiesForm.tsx33
-rw-r--r--src/settings/components/form/SearchForm.scss28
-rw-r--r--src/settings/components/form/SearchForm.tsx137
-rw-r--r--src/settings/components/index.tsx104
-rw-r--r--src/settings/components/site.scss27
-rw-r--r--src/settings/components/ui/AddButton.scss13
-rw-r--r--src/settings/components/ui/AddButton.tsx31
-rw-r--r--src/settings/components/ui/DeleteButton.scss13
-rw-r--r--src/settings/components/ui/DeleteButton.tsx30
-rw-r--r--src/settings/components/ui/Input.scss29
-rw-r--r--src/settings/components/ui/Input.tsx89
-rw-r--r--src/settings/components/ui/Radio.tsx33
-rw-r--r--src/settings/components/ui/Text.tsx54
-rw-r--r--src/settings/components/ui/TextArea.tsx56
33 files changed, 771 insertions, 680 deletions
diff --git a/src/console/components/Console.tsx b/src/console/components/Console.tsx
index a0e22e4..1c673fa 100644
--- a/src/console/components/Console.tsx
+++ b/src/console/components/Console.tsx
@@ -1,4 +1,3 @@
-import "./console.scss";
import { connect } from "react-redux";
import React from "react";
import Input from "./console/Input";
@@ -11,6 +10,13 @@ import CommandLineParser, {
} from "../commandline/CommandLineParser";
import { Command } from "../../shared/Command";
import ColorScheme from "../../shared/ColorScheme";
+import { LightTheme, DarkTheme } from "./Theme";
+import styled from "./Theme";
+import { ThemeProvider } from "styled-components";
+
+const ConsoleWrapper = styled.div`
+ border-top: 1px solid gray;
+`;
const COMPLETION_MAX_ITEMS = 33;
@@ -143,28 +149,34 @@ class Console extends React.Component<Props> {
case "command":
case "find":
return (
- <div data-theme={theme} className="vimvixen-console-command-wrapper">
- <Completion
- size={COMPLETION_MAX_ITEMS}
- completions={this.props.completions}
- select={this.props.select}
- />
- <Input
- ref={this.input}
- mode={this.props.mode}
- onBlur={this.onBlur.bind(this)}
- onKeyDown={this.onKeyDown.bind(this)}
- onChange={this.onChange.bind(this)}
- value={this.props.consoleText}
- />
- </div>
+ <ThemeProvider
+ theme={theme === ColorScheme.Dark ? DarkTheme : LightTheme}
+ >
+ <ConsoleWrapper>
+ <Completion
+ size={COMPLETION_MAX_ITEMS}
+ completions={this.props.completions}
+ select={this.props.select}
+ />
+ <Input
+ ref={this.input}
+ mode={this.props.mode}
+ onBlur={this.onBlur.bind(this)}
+ onKeyDown={this.onKeyDown.bind(this)}
+ onChange={this.onChange.bind(this)}
+ value={this.props.consoleText}
+ />
+ </ConsoleWrapper>
+ </ThemeProvider>
);
case "info":
case "error":
return (
- <div data-theme={theme}>
+ <ThemeProvider
+ theme={theme === ColorScheme.Dark ? DarkTheme : LightTheme}
+ >
<Message mode={this.props.mode}>{this.props.messageText}</Message>
- </div>
+ </ThemeProvider>
);
default:
return null;
diff --git a/src/console/components/Theme.ts b/src/console/components/Theme.ts
new file mode 100644
index 0000000..dd7baa5
--- /dev/null
+++ b/src/console/components/Theme.ts
@@ -0,0 +1,53 @@
+import baseStyled, { ThemedStyledInterface } from "styled-components";
+
+type Theme = {
+ completionTitleBackground: string;
+ completionTitleForeground: string;
+ completionItemBackground: string;
+ completionItemForeground: string;
+ completionItemDescriptionForeground: string;
+ completionSelectedBackground: string;
+ completionSelectedForeground: string;
+ commandBackground: string;
+ commandForeground: string;
+ consoleErrorBackground: string;
+ consoleErrorForeground: string;
+ consoleInfoBackground: string;
+ consoleInfoForeground: string;
+};
+
+export const LightTheme: Theme = {
+ completionTitleBackground: "lightgray",
+ completionTitleForeground: "#000000",
+ completionItemBackground: "#ffffff",
+ completionItemForeground: "#000000",
+ completionItemDescriptionForeground: "#008000",
+ completionSelectedBackground: "#ffff00",
+ completionSelectedForeground: "#000000",
+ commandBackground: "#ffffff",
+ commandForeground: "#000000",
+ consoleErrorBackground: "#ff0000",
+ consoleErrorForeground: "#ffffff",
+ consoleInfoBackground: "#ffffff",
+ consoleInfoForeground: "#018786",
+};
+
+export const DarkTheme: Theme = {
+ completionTitleBackground: "#052027",
+ completionTitleForeground: "white",
+ completionItemBackground: "#2f474f",
+ completionItemForeground: "white",
+ completionItemDescriptionForeground: "#86fab0",
+ completionSelectedBackground: "#eeff41",
+ completionSelectedForeground: "#000000",
+ commandBackground: "#052027",
+ commandForeground: "white",
+ consoleErrorBackground: "red",
+ consoleErrorForeground: "white",
+ consoleInfoBackground: "#052027",
+ consoleInfoForeground: "#ffffff",
+};
+
+const styled = baseStyled as ThemedStyledInterface<Theme>;
+
+export default styled;
diff --git a/src/console/components/console.scss b/src/console/components/console.scss
deleted file mode 100644
index ccb769b..0000000
--- a/src/console/components/console.scss
+++ /dev/null
@@ -1,141 +0,0 @@
-[data-theme="light"] {
- --completion-title-background: lightgray;
- --completion-title-foreground: #000000;
- --completion-item-background: #ffffff;
- --completion-item-foreground: #000000;
- --completion-item-description-foreground: #008000;
- --completion-selected-background: #ffff00;
- --completion-selected-foreground: #000000;
- --command-background: #ffffff;
- --command-foreground: #000000;
- --console-error-background: #ff0000;
- --console-error-foreground: #ffffff;
- --console-info-background: #ffffff;
- --console-info-foreground: #018786;
-}
-
-[data-theme="dark"] {
- --completion-title-background: #052027;
- --completion-title-foreground: white;
- --completion-item-background: #2f474f;
- --completion-item-foreground: white;
- --completion-item-description-foreground: #86fab0;
- --completion-selected-background: #eeff41;
- --completion-selected-foreground: #000000;
- --command-background: #052027;
- --command-foreground: white;
- --console-error-background: red;
- --console-error-foreground: white;
- --console-info-background: #052027;
- --console-info-foreground: #ffffff;
-}
-
-html, body, * {
- margin: 0;
- padding: 0;
-}
-
-body {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- overflow: hidden;
-}
-
-.vimvixen-console {
- bottom: 0;
- margin: 0;
- padding: 0;
-
- @mixin console-font {
- font-style: normal;
- font-family: monospace;
- font-size: 12px;
- line-height: 16px;
- }
-
- &-command-wrapper {
- border-top: 1px solid gray;
- }
-
- &-completion {
- @include console-font;
-
- &-title {
- background-color: var(--completion-title-background);
- color: var(--completion-title-foreground);
- font-weight: bold;
- margin: 0;
- padding: 0;
- }
-
- &-item {
- background-color: var(--completion-item-background);
- color: var(--completion-item-foreground);
-
- padding-left: 1.5rem;
- background-position: 0 center;
- background-size: contain;
- background-repeat: no-repeat;
- white-space: pre;
-
- &.vimvixen-completion-selected {
- background-color: var(--completion-selected-background);
- color: var(--completion-selected-foreground);
- }
-
- &-caption {
- display: inline-block;
- width: 40%;
- text-overflow: ellipsis;
- overflow: hidden;
- }
-
- &-url {
- display: inline-block;
- color: var(--completion-item-description-foreground);
- width: 60%;
- text-overflow: ellipsis;
- overflow: hidden;
- }
- }
- }
-
- &-message {
- @include console-font;
-
- border-top: 1px solid gray;
- }
-
- &-error {
- background-color: var(--console-error-background);
- color: var(--console-error-foreground);
- font-weight: bold;
- }
-
- &-info {
- background-color: var(--console-info-background);
- color: var(--console-info-foreground);
- font-weight: normal;
- }
-
- &-command {
- background-color: var(--command-background);
- color: var(--command-foreground);
- display: flex;
-
- &-prompt {
- @include console-font;
- }
-
- &-input {
- border: none;
- flex-grow: 1;
- background-color: var(--command-background);
- color: var(--command-foreground);
-
- @include console-font;
- }
- }
-}
diff --git a/src/console/components/console/Completion.tsx b/src/console/components/console/Completion.tsx
index 9b4cf15..09ae278 100644
--- a/src/console/components/console/Completion.tsx
+++ b/src/console/components/console/Completion.tsx
@@ -63,29 +63,52 @@ class Completion extends React.Component<Props, State> {
}
render() {
- let eles = [];
- let index = 0;
+ let itemIndex = 0;
+ let viewIndex = 0;
+ const groups: Array<JSX.Element> = [];
+ const viewOffset = this.state.viewOffset;
+ const viewSize = this.props.size;
- for (const group of this.props.completions) {
- eles.push(<CompletionTitle key={`group-${index}`} title={group.name} />);
+ this.props.completions.forEach((group, groupIndex) => {
+ const items = [];
+ const title = (
+ <CompletionTitle
+ id={`title-${groupIndex}`}
+ key={`group-${groupIndex}`}
+ shown={viewOffset <= viewIndex && viewIndex < viewOffset + viewSize}
+ title={group.name}
+ />
+ );
+ ++viewIndex;
for (const item of group.items) {
- eles.push(
+ items.push(
<CompletionItem
- key={`item-${index}`}
+ shown={viewOffset <= viewIndex && viewIndex < viewOffset + viewSize}
+ key={`item-${itemIndex}`}
icon={item.icon}
caption={item.caption}
url={item.url}
- highlight={index === this.props.select}
+ highlight={itemIndex === this.props.select}
+ aria-selected={itemIndex === this.props.select}
+ role="menuitem"
/>
);
- ++index;
+ ++viewIndex;
+ ++itemIndex;
}
- }
-
- const viewOffset = this.state.viewOffset;
- eles = eles.slice(viewOffset, viewOffset + this.props.size);
+ groups.push(
+ <div
+ key={`group-${groupIndex}`}
+ role="group"
+ aria-describedby={`title-${groupIndex}`}
+ >
+ {title}
+ <ul>{items}</ul>
+ </div>
+ );
+ });
- return <ul className="vimvixen-console-completion">{eles}</ul>;
+ return <div role="menu">{groups}</div>;
}
}
diff --git a/src/console/components/console/CompletionItem.tsx b/src/console/components/console/CompletionItem.tsx
index 657f360..5f2f9f6 100644
--- a/src/console/components/console/CompletionItem.tsx
+++ b/src/console/components/console/CompletionItem.tsx
@@ -1,35 +1,62 @@
import React from "react";
-import PropTypes from "prop-types";
+import styled from "../Theme";
+
+const Container = styled.li<{
+ shown: boolean;
+ icon: string;
+ highlight: boolean;
+}>`
+ background-image: ${({ icon }) => "url(" + icon + ")"};
+ background-color: ${({ highlight, theme }) =>
+ highlight
+ ? theme.completionSelectedBackground
+ : theme.completionItemBackground};
+ color: ${({ highlight, theme }) =>
+ highlight
+ ? theme.completionSelectedForeground
+ : theme.completionItemForeground};
+ display: ${({ shown }) => (shown ? "display" : "none")};
+ padding-left: 1.8rem;
+ background-position: 0 center;
+ background-size: contain;
+ background-repeat: no-repeat;
+ white-space: pre;
+`;
+
+const Caption = styled.span`
+ display: inline-block;
+ width: 40%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+`;
+
+const Description = styled.span`
+ display: inline-block;
+ color: ${({ theme }) => theme.completionItemDescriptionForeground};
+ width: 60%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+`;
interface Props {
+ shown: boolean;
highlight: boolean;
caption?: string;
url?: string;
icon?: string;
}
-const CompletionItem = (props: Props) => {
- let className = "vimvixen-console-completion-item";
- if (props.highlight) {
- className += " vimvixen-completion-selected";
- }
- return (
- <li
- className={className}
- style={{ backgroundImage: "url(" + props.icon + ")" }}
- >
- <span className="vimvixen-console-completion-item-caption">
- {props.caption}
- </span>
- <span className="vimvixen-console-completion-item-url">{props.url}</span>
- </li>
- );
-};
-
-CompletionItem.propTypes = {
- highlight: PropTypes.bool,
- caption: PropTypes.string,
- url: PropTypes.string,
-};
+const CompletionItem: React.FC<React.HTMLAttributes<HTMLElement> & Props> = (
+ props
+) => (
+ <Container
+ icon={props.icon || ""}
+ aria-labelledby={`completion-item-${props.caption}`}
+ {...props}
+ >
+ <Caption id={`completion-item-${props.caption}`}>{props.caption}</Caption>
+ <Description>{props.url}</Description>
+ </Container>
+);
export default CompletionItem;
diff --git a/src/console/components/console/CompletionTitle.tsx b/src/console/components/console/CompletionTitle.tsx
index 7257006..ec2fc8b 100644
--- a/src/console/components/console/CompletionTitle.tsx
+++ b/src/console/components/console/CompletionTitle.tsx
@@ -1,11 +1,22 @@
import React from "react";
+import styled from "../Theme";
+
+const Li = styled.li<{ shown: boolean }>`
+ display: ${({ shown }) => (shown ? "display" : "none")};
+ background-color: ${({ theme }) => theme.completionTitleBackground};
+ color: ${({ theme }) => theme.completionTitleForeground};
+ font-weight: bold;
+ margin: 0;
+ padding: 0;
+`;
interface Props {
+ shown: boolean;
title: string;
}
-const CompletionTitle = (props: Props) => {
- return <li className="vimvixen-console-completion-title">{props.title}</li>;
-};
+const CompletionTitle: React.FC<React.HTMLAttributes<HTMLElement> & Props> = (
+ props
+) => <Li {...props}>{props.title}</Li>;
export default CompletionTitle;
diff --git a/src/console/components/console/Input.tsx b/src/console/components/console/Input.tsx
index e412a0c..448b096 100644
--- a/src/console/components/console/Input.tsx
+++ b/src/console/components/console/Input.tsx
@@ -1,4 +1,22 @@
import React from "react";
+import styled from "../Theme";
+
+const Container = styled.div`
+ background-color: ${({ theme }) => theme.commandBackground};
+ color: ${({ theme }) => theme.commandForeground};
+ display: flex;
+`;
+
+const Prompt = styled.i`
+ font-style: normal;
+`;
+
+const InputInner = styled.input`
+ border: none;
+ flex-grow: 1;
+ background-color: ${({ theme }) => theme.commandBackground};
+ color: ${({ theme }) => theme.commandForeground};
+`;
interface Props {
mode: string;
@@ -32,17 +50,16 @@ class Input extends React.Component<Props> {
}
return (
- <div className="vimvixen-console-command">
- <i className="vimvixen-console-command-prompt">{prompt}</i>
- <input
- className="vimvixen-console-command-input"
+ <Container>
+ <Prompt>{prompt}</Prompt>
+ <InputInner
ref={this.input}
onBlur={this.props.onBlur}
onKeyDown={this.props.onKeyDown}
onChange={this.props.onChange}
value={this.props.value}
/>
- </div>
+ </Container>
);
}
}
diff --git a/src/console/components/console/Message.tsx b/src/console/components/console/Message.tsx
index fd1c9d7..73498fd 100644
--- a/src/console/components/console/Message.tsx
+++ b/src/console/components/console/Message.tsx
@@ -1,24 +1,31 @@
import React from "react";
+import styled from "../Theme";
+
+const Error = styled.p`
+ border-top: 1px solid gray;
+ background-color: ${({ theme }) => theme.consoleErrorBackground};
+ color: ${({ theme }) => theme.consoleErrorForeground};
+ font-weight: bold;
+`;
+
+const Info = styled.p`
+ border-top: 1px solid gray;
+ background-color: ${({ theme }) => theme.consoleInfoBackground};
+ color: ${({ theme }) => theme.consoleInfoForeground};
+ font-weight: normal;
+`;
interface Props {
mode: string;
children: string;
}
-const Message = (props: Props) => {
- switch (props.mode) {
+const Message: React.FC<Props> = ({ mode, children }) => {
+ switch (mode) {
case "error":
- return (
- <p className="vimvixen-console-message vimvixen-console-error">
- {props.children}
- </p>
- );
+ return <Error role="alert">{children}</Error>;
case "info":
- return (
- <p className="vimvixen-console-message vimvixen-console-info">
- {props.children}
- </p>
- );
+ return <Info role="status">{children}</Info>;
}
return null;
};
diff --git a/src/console/index.css b/src/console/index.css
new file mode 100644
index 0000000..2d548df
--- /dev/null
+++ b/src/console/index.css
@@ -0,0 +1,30 @@
+html, body, * {
+ margin: 0;
+ padding: 0;
+
+ font-style: normal;
+ font-family: monospace;
+ font-size: 12px;
+ line-height: 16px;
+}
+
+input {
+ font-style: normal;
+ font-family: monospace;
+ font-size: 12px;
+ line-height: 16px;
+}
+
+body {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ overflow: hidden;
+}
+
+.vimvixen-console {
+ bottom: 0;
+ margin: 0;
+ padding: 0;
+}
diff --git a/src/console/index.tsx b/src/console/index.tsx
index e1a9dd3..228625e 100644
--- a/src/console/index.tsx
+++ b/src/console/index.tsx
@@ -5,6 +5,7 @@ import promise from "redux-promise";
import * as consoleActions from "./actions/console";
import { Provider } from "react-redux";
import Console from "./components/Console";
+import "./index.css";
import React from "react";
import ReactDOM from "react-dom";
diff --git a/src/content/presenters/FocusPresenter.ts b/src/content/presenters/FocusPresenter.ts
index 4d70a6e..3591039 100644
--- a/src/content/presenters/FocusPresenter.ts
+++ b/src/content/presenters/FocusPresenter.ts
@@ -11,7 +11,7 @@ export class FocusPresenterImpl implements FocusPresenter {
.map((type) => `input[type=${type}]`)
.join(",");
const targets = window.document.querySelectorAll(
- inputSelector + ",textarea"
+ inputSelector + ",input:not([type]),textarea"
);
const target = Array.from(targets).find(doms.isVisible);
if (target instanceof HTMLInputElement) {
diff --git a/src/content/site-style.ts b/src/content/site-style.ts
index 0c335fc..3748c6b 100644
--- a/src/content/site-style.ts
+++ b/src/content/site-style.ts
@@ -8,8 +8,8 @@ export default `
height: 100%;
position: fixed;
z-index: 2147483647;
- border: none;
- background-color: unset;
+ border: none !important;
+ background-color: unset !important;
pointer-events:none;
}
diff --git a/src/settings/components/form/BlacklistForm.scss b/src/settings/components/form/BlacklistForm.scss
deleted file mode 100644
index a230d0d..0000000
--- a/src/settings/components/form/BlacklistForm.scss
+++ /dev/null
@@ -1,9 +0,0 @@
-.form-blacklist-form {
- &-row {
- display: flex;
-
- .column-url {
- flex: 1;
- }
- }
-}
diff --git a/src/settings/components/form/BlacklistForm.tsx b/src/settings/components/form/BlacklistForm.tsx
index 859cb9f..6fb9eca 100644
--- a/src/settings/components/form/BlacklistForm.tsx
+++ b/src/settings/components/form/BlacklistForm.tsx
@@ -1,9 +1,29 @@
-import "./BlacklistForm.scss";
+import styled from "styled-components";
import AddButton from "../ui/AddButton";
import DeleteButton from "../ui/DeleteButton";
import React from "react";
import Blacklist, { BlacklistItem } from "../../../shared/settings/Blacklist";
+const Grid = styled.div``;
+
+const GridRow = styled.div`
+ display: flex;
+`;
+
+const GridCell = styled.div<{ grow?: number }>`
+ &:nth-child(1) {
+ flex-grow: 1;
+ }
+ &:nth-child(2) {
+ flex-shrink: 1;
+ }
+`;
+
+const Input = styled.input`
+ width: 100%;
+ box-sizing: border-box;
+`;
+
interface Props {
value: Blacklist;
onChange: (value: Blacklist) => void;
@@ -19,37 +39,46 @@ class BlacklistForm extends React.Component<Props> {
render() {
return (
- <div className="form-blacklist-form">
- {this.props.value.items.map((item, index) => {
- if (item.partial) {
- return null;
- }
- return (
- <div key={index} className="form-blacklist-form-row">
- <input
- data-index={index}
- type="text"
- name="url"
- className="column-url"
- value={item.pattern}
- onChange={this.bindValue.bind(this)}
- onBlur={this.props.onBlur}
- />
- <DeleteButton
- data-index={index}
- name="delete"
- onClick={this.bindValue.bind(this)}
- onBlur={this.props.onBlur}
- />
- </div>
- );
- })}
+ <>
+ <Grid role="list">
+ {this.props.value.items.map((item, index) => {
+ if (item.partial) {
+ return null;
+ }
+ return (
+ <GridRow role="listitem" key={index}>
+ <GridCell>
+ <Input
+ data-index={index}
+ type="text"
+ name="url"
+ aria-label="URL"
+ value={item.pattern}
+ placeholder="example.com/mail/*"
+ onChange={this.bindValue.bind(this)}
+ onBlur={this.props.onBlur}
+ />
+ </GridCell>
+ <GridCell>
+ <DeleteButton
+ data-index={index}
+ name="delete"
+ onClick={this.bindValue.bind(this)}
+ onBlur={this.props.onBlur}
+ aria-label="Delete"
+ />
+ </GridCell>
+ </GridRow>
+ );
+ })}
+ </Grid>
<AddButton
name="add"
+ aria-label="Add"
style={{ float: "right" }}
onClick={this.bindValue.bind(this)}
/>
- </div>
+ </>
);
}
diff --git a/src/settings/components/form/KeymapsForm.scss b/src/settings/components/form/KeymapsForm.scss
deleted file mode 100644
index 1a4e5cd..0000000
--- a/src/settings/components/form/KeymapsForm.scss
+++ /dev/null
@@ -1,11 +0,0 @@
-.form-keymaps-form {
- column-count: 3;
-
- &-field-group {
- margin-top: 24px;
- }
-
- &-field-group:first-of-type {
- margin-top: 24px;
- }
-}
diff --git a/src/settings/components/form/KeymapsForm.tsx b/src/settings/components/form/KeymapsForm.tsx
index b9af0df..6582529 100644
--- a/src/settings/components/form/KeymapsForm.tsx
+++ b/src/settings/components/form/KeymapsForm.tsx
@@ -1,9 +1,21 @@
-import "./KeymapsForm.scss";
import React from "react";
-import Input from "../ui/Input";
+import styled from "styled-components";
+import Text from "../ui/Text";
import keymaps from "../../keymaps";
import { FormKeymaps } from "../../../shared/SettingData";
+const Grid = styled.div`
+ column-count: 3;
+`;
+
+const FieldGroup = styled.div`
+ margin-top: 24px;
+
+ &:first-of-type {
+ margin-top: 24px;
+ }
+`;
+
interface Props {
value: FormKeymaps;
onChange: (e: FormKeymaps) => void;
@@ -20,15 +32,14 @@ class KeymapsForm extends React.Component<Props> {
render() {
const values = this.props.value.toJSON();
return (
- <div className="form-keymaps-form">
+ <Grid>
{keymaps.fields.map((group, index) => {
return (
- <div key={index} className="form-keymaps-form-field-group">
+ <FieldGroup key={index}>
{group.map(([name, label]) => {
const value = values[name] || "";
return (
- <Input
- type="text"
+ <Text
id={name}
name={name}
key={name}
@@ -39,10 +50,10 @@ class KeymapsForm extends React.Component<Props> {
/>
);
})}
- </div>
+ </FieldGroup>
);
})}
- </div>
+ </Grid>
);
}
diff --git a/src/settings/components/form/PartialBlacklistForm.scss b/src/settings/components/form/PartialBlacklistForm.scss
deleted file mode 100644
index caf6f93..0000000
--- a/src/settings/components/form/PartialBlacklistForm.scss
+++ /dev/null
@@ -1,28 +0,0 @@
-.form-partial-blacklist-form {
- @mixin row-base {
- display: flex;
-
- .column-url {
- flex: 5;
- min-width: 0;
- }
- .column-keys {
- flex: 1;
- min-width: 0;
- }
- .column-delete {
- flex: 1;
- min-width: 0;
- }
- }
-
- &-header {
- @include row-base;
-
- font-weight: bold;
- }
-
- &-row {
- @include row-base;
- }
-}
diff --git a/src/settings/components/form/PartialBlacklistForm.tsx b/src/settings/components/form/PartialBlacklistForm.tsx
index 95beee8..b2da2bb 100644
--- a/src/settings/components/form/PartialBlacklistForm.tsx
+++ b/src/settings/components/form/PartialBlacklistForm.tsx
@@ -1,9 +1,41 @@
-import "./PartialBlacklistForm.scss";
+import React from "react";
+import styled from "styled-components";
import AddButton from "../ui/AddButton";
import DeleteButton from "../ui/DeleteButton";
-import React from "react";
import Blacklist, { BlacklistItem } from "../../../shared/settings/Blacklist";
+const Grid = styled.div``;
+
+const GridHeader = styled.div`
+ display: flex;
+ font-weight: bold;
+`;
+
+const GridRow = styled.div`
+ display: flex;
+`;
+
+const GridCell = styled.div<{ grow?: number }>`
+ &:nth-child(1) {
+ flex-grow: 5;
+ }
+
+ &:nth-child(2) {
+ flex-shrink: 1;
+ min-width: 20%;
+ max-width: 20%;
+ }
+
+ &:nth-child(3) {
+ flex-shrink: 1;
+ }
+`;
+
+const Input = styled.input`
+ width: 100%;
+ box-sizing: border-box;
+`;
+
interface Props {
value: Blacklist;
onChange: (value: Blacklist) => void;
@@ -19,50 +51,62 @@ class PartialBlacklistForm extends React.Component<Props> {
render() {
return (
- <div className="form-partial-blacklist-form">
- <div className="form-partial-blacklist-form-header">
- <div className="column-url">URL</div>
- <div className="column-keys">Keys</div>
- </div>
- {this.props.value.items.map((item, index) => {
- if (!item.partial) {
- return null;
- }
- return (
- <div key={index} className="form-partial-blacklist-form-row">
- <input
- data-index={index}
- type="text"
- name="url"
- className="column-url"
- value={item.pattern}
- onChange={this.bindValue.bind(this)}
- onBlur={this.props.onBlur}
- />
- <input
- data-index={index}
- type="text"
- name="keys"
- className="column-keys"
- value={item.keys.join(",")}
- onChange={this.bindValue.bind(this)}
- onBlur={this.props.onBlur}
- />
- <DeleteButton
- data-index={index}
- name="delete"
- onClick={this.bindValue.bind(this)}
- onBlur={this.props.onBlur}
- />
- </div>
- );
- })}
+ <>
+ <Grid role="list">
+ <GridHeader>
+ <GridCell>URL</GridCell>
+ <GridCell>Keys</GridCell>
+ </GridHeader>
+ {this.props.value.items.map((item, index) => {
+ if (!item.partial) {
+ return null;
+ }
+ return (
+ <GridRow key={index} role="listitem">
+ <GridCell>
+ <Input
+ data-index={index}
+ type="text"
+ name="url"
+ aria-label="URL"
+ value={item.pattern}
+ placeholder="example.com/mail/*"
+ onChange={this.bindValue.bind(this)}
+ onBlur={this.props.onBlur}
+ />
+ </GridCell>
+ <GridCell>
+ <Input
+ data-index={index}
+ type="text"
+ name="keys"
+ aria-label="Keys"
+ value={item.keys.join(",")}
+ placeholder="j,k,<C-d>,<C-u>"
+ onChange={this.bindValue.bind(this)}
+ onBlur={this.props.onBlur}
+ />
+ </GridCell>
+ <GridCell>
+ <DeleteButton
+ data-index={index}
+ name="delete"
+ aria-label="Delete"
+ onClick={this.bindValue.bind(this)}
+ onBlur={this.props.onBlur}
+ />
+ </GridCell>
+ </GridRow>
+ );
+ })}
+ </Grid>
<AddButton
name="add"
+ aria-label="Add"
style={{ float: "right" }}
onClick={this.bindValue.bind(this)}
/>
- </div>
+ </>
);
}
diff --git a/src/settings/components/form/PropertiesForm.scss b/src/settings/components/form/PropertiesForm.scss
deleted file mode 100644
index 7c9e167..0000000
--- a/src/settings/components/form/PropertiesForm.scss
+++ /dev/null
@@ -1,12 +0,0 @@
-.form-properties-form {
- &-row {
- .column-name {
- display: inline-block;
- min-width: 5rem;
- font-weight: bold;
- }
- .column-input {
- line-height: 2.2rem;
- }
- }
-}
diff --git a/src/settings/components/form/PropertiesForm.tsx b/src/settings/components/form/PropertiesForm.tsx
index aebd9b1..53ebf03 100644
--- a/src/settings/components/form/PropertiesForm.tsx
+++ b/src/settings/components/form/PropertiesForm.tsx
@@ -1,6 +1,20 @@
-import "./PropertiesForm.scss";
+import styled from "styled-components";
import React from "react";
+const Form = styled.div``;
+
+const Row = styled.div``;
+
+const Label = styled.label`
+ display: inline-block;
+ min-width: 5rem;
+ font-weight: bold;
+`;
+
+const Input = styled.input`
+ line-height: 2.2rem;
+`;
+
interface Props {
types: { [key: string]: string };
value: { [key: string]: any };
@@ -21,7 +35,7 @@ class PropertiesForm extends React.Component<Props> {
const values = this.props.value;
return (
- <div className="form-properties-form">
+ <Form>
{Object.keys(types).map((name) => {
const type = types[name];
let inputType = "";
@@ -42,23 +56,22 @@ class PropertiesForm extends React.Component<Props> {
return null;
}
return (
- <div key={name} className="form-properties-form-row">
- <label>
- <span className="column-name">{name}</span>
- <input
+ <Row key={name}>
+ <Label>
+ <span>{name}</span>
+ <Input
type={inputType}
name={name}
- className="column-input"
value={values[name] ? values[name] : ""}
onChange={onChange}
onBlur={this.props.onBlur}
checked={values[name]}
/>
- </label>
- </div>
+ </Label>
+ </Row>
);
})}
- </div>
+ </Form>
);
}
diff --git a/src/settings/components/form/SearchForm.scss b/src/settings/components/form/SearchForm.scss
deleted file mode 100644
index 26b2f44..0000000
--- a/src/settings/components/form/SearchForm.scss
+++ /dev/null
@@ -1,28 +0,0 @@
-.form-search-form {
- @mixin row-base {
- display: flex;
-
- .column-name {
- flex: 1;
- min-width: 0;
- }
- .column-url {
- flex: 5;
- min-width: 0;
- }
- .column-option {
- text-align: right;
- flex-basis: 5rem;
- }
- }
-
- &-header {
- @include row-base;
-
- font-weight: bold;
- }
-
- &-row {
- @include row-base;
- }
-}
diff --git a/src/settings/components/form/SearchForm.tsx b/src/settings/components/form/SearchForm.tsx
index a4d923d..4bf0e02 100644
--- a/src/settings/components/form/SearchForm.tsx
+++ b/src/settings/components/form/SearchForm.tsx
@@ -1,9 +1,42 @@
-import "./SearchForm.scss";
import React from "react";
+import styled from "styled-components";
import AddButton from "../ui/AddButton";
import DeleteButton from "../ui/DeleteButton";
import { FormSearch } from "../../../shared/SettingData";
+const Grid = styled.div``;
+
+const GridHeader = styled.div`
+ display: flex;
+ font-weight: bold;
+`;
+
+const GridRow = styled.div`
+ display: flex;
+`;
+
+const GridCell = styled.div<{ grow?: number }>`
+ &:nth-child(1) {
+ flex-grow: 0;
+ min-width: 10%;
+ max-width: 10%;
+ }
+
+ &:nth-child(2) {
+ flex-grow: 2;
+ }
+
+ &:nth-child(3) {
+ flex-grow: 0;
+ flex-shrink: 1;
+ }
+`;
+
+const Input = styled.input`
+ width: 100%;
+ box-sizing: border-box;
+`;
+
interface Props {
value: FormSearch;
onChange: (value: FormSearch) => void;
@@ -20,57 +53,67 @@ class SearchForm extends React.Component<Props> {
render() {
const value = this.props.value.toJSON();
return (
- <div className="form-search-form">
- <div className="form-search-form-header">
- <div className="column-name">Name</div>
- <div className="column-url">URL</div>
- <div className="column-option">Default</div>
- </div>
- {value.engines.map((engine, index) => {
- return (
- <div key={index} className="form-search-form-row">
- <input
- data-index={index}
- type="text"
- name="name"
- className="column-name"
- value={engine[0]}
- onChange={this.bindValue.bind(this)}
- onBlur={this.props.onBlur}
- />
- <input
- data-index={index}
- type="text"
- name="url"
- placeholder="http://example.com/?q={}"
- className="column-url"
- value={engine[1]}
- onChange={this.bindValue.bind(this)}
- onBlur={this.props.onBlur}
- />
- <div className="column-option">
- <input
- data-index={index}
- type="radio"
- name="default"
- checked={value.default === engine[0]}
- onChange={this.bindValue.bind(this)}
- />
- <DeleteButton
- data-index={index}
- name="delete"
- onClick={this.bindValue.bind(this)}
- />
- </div>
- </div>
- );
- })}
+ <>
+ <Grid role="list">
+ <GridHeader>
+ <GridCell>Name</GridCell>
+ <GridCell>URL</GridCell>
+ <GridCell>Default</GridCell>
+ </GridHeader>
+ {value.engines.map((engine, index) => {
+ return (
+ <GridRow key={index} role="listitem">
+ <GridCell>
+ <Input
+ data-index={index}
+ type="text"
+ name="name"
+ aria-label="Name"
+ value={engine[0]}
+ onChange={this.bindValue.bind(this)}
+ onBlur={this.props.onBlur}
+ />
+ </GridCell>
+ <GridCell>
+ <Input
+ data-index={index}
+ type="text"
+ name="url"
+ aria-label="URL"
+ placeholder="http://example.com/?q={}"
+ value={engine[1]}
+ onChange={this.bindValue.bind(this)}
+ onBlur={this.props.onBlur}
+ />
+ </GridCell>
+ <GridCell>
+ <input
+ data-index={index}
+ type="radio"
+ name="default"
+ aria-label="Default"
+ checked={value.default === engine[0]}
+ onChange={this.bindValue.bind(this)}
+ />
+ a
+ <DeleteButton
+ data-index={index}
+ aria-label="Delete"
+ name="delete"
+ onClick={this.bindValue.bind(this)}
+ />
+ </GridCell>
+ </GridRow>
+ );
+ })}
+ </Grid>
<AddButton
name="add"
+ aria-label="Add"
style={{ float: "right" }}
onClick={this.bindValue.bind(this)}
/>
- </div>
+ </>
);
}
diff --git a/src/settings/components/index.tsx b/src/settings/components/index.tsx
index 9d71cac..2e2ff52 100644
--- a/src/settings/components/index.tsx
+++ b/src/settings/components/index.tsx
@@ -1,7 +1,8 @@
-import "./site.scss";
import React from "react";
import { connect } from "react-redux";
-import Input from "./ui/Input";
+import styled from "styled-components";
+import TextArea from "./ui/TextArea";
+import Radio from "./ui/Radio";
import SearchForm from "./form/SearchForm";
import KeymapsForm from "./form/KeymapsForm";
import BlacklistForm from "./form/BlacklistForm";
@@ -18,6 +19,28 @@ import { State as AppState } from "../reducers/setting";
import Properties from "../../shared/settings/Properties";
import Blacklist from "../../shared/settings/Blacklist";
+const Container = styled.form`
+ padding: 2px;
+ font-family: system-ui;
+`;
+
+const Fieldset = styled.fieldset`
+ margin: 0;
+ padding: 0;
+ border: none;
+ margin-top: 1rem;
+
+ &:first-of-type {
+ margin-top: 1rem;
+ }
+`;
+
+const Legend = styled.legend`
+ font-size: 1.5rem;
+ padding: 0.5rem 0;
+ font-weight: bold;
+`;
+
const DO_YOU_WANT_TO_CONTINUE =
"Some settings in JSON can be lost when migrating. " +
"Do you want to continue?";
@@ -40,47 +63,47 @@ class SettingsComponent extends React.Component<Props> {
renderFormFields(form: FormSettings) {
return (
<div>
- <fieldset>
- <legend>Keybindings</legend>
+ <Fieldset>
+ <Legend>Keybindings</Legend>
<KeymapsForm
value={form.keymaps}
onChange={this.bindKeymapsForm.bind(this)}
onBlur={this.save.bind(this)}
/>
- </fieldset>
- <fieldset>
- <legend>Search Engines</legend>
+ </Fieldset>
+ <Fieldset>
+ <Legend>Search Engines</Legend>
<SearchForm
value={form.search}
onChange={this.bindSearchForm.bind(this)}
onBlur={this.save.bind(this)}
/>
- </fieldset>
- <fieldset>
- <legend>Blacklist</legend>
+ </Fieldset>
+ <Fieldset>
+ <Legend>Blacklist</Legend>
<BlacklistForm
value={form.blacklist}
onChange={this.bindBlacklistForm.bind(this)}
onBlur={this.save.bind(this)}
/>
- </fieldset>
- <fieldset>
- <legend>Partial blacklist</legend>
+ </Fieldset>
+ <Fieldset>
+ <Legend>Partial blacklist</Legend>
<PartialBlacklistForm
value={form.blacklist}
onChange={this.bindBlacklistForm.bind(this)}
onBlur={this.save.bind(this)}
/>
- </fieldset>
- <fieldset>
- <legend>Properties</legend>
+ </Fieldset>
+ <Fieldset>
+ <Legend>Properties</Legend>
<PropertiesForm
types={Properties.types()}
value={form.properties.toJSON()}
onChange={this.bindPropertiesForm.bind(this)}
onBlur={this.save.bind(this)}
/>
- </fieldset>
+ </Fieldset>
</div>
);
}
@@ -88,8 +111,7 @@ class SettingsComponent extends React.Component<Props> {
renderJsonFields(json: JSONTextSettings, error: string) {
return (
<div>
- <Input
- type="textarea"
+ <TextArea
name="json"
label="Plain JSON"
spellCheck={false}
@@ -111,32 +133,28 @@ class SettingsComponent extends React.Component<Props> {
fields = this.renderJsonFields(this.props.json!, this.props.error);
}
return (
- <div>
+ <Container>
<h1>Configure Vim-Vixen</h1>
- <form className="vimvixen-settings-form">
- <Input
- type="radio"
- id="setting-source-form"
- name="source"
- label="Use form"
- checked={this.props.source === "form"}
- value="form"
- onValueChange={this.bindSource.bind(this)}
- disabled={disabled}
- />
+ <Radio
+ id="setting-source-form"
+ name="source"
+ label="Use form"
+ checked={this.props.source === "form"}
+ value="form"
+ onValueChange={this.bindSource.bind(this)}
+ disabled={disabled}
+ />
- <Input
- type="radio"
- name="source"
- label="Use plain JSON"
- checked={this.props.source === "json"}
- value="json"
- onValueChange={this.bindSource.bind(this)}
- disabled={disabled}
- />
- {fields}
- </form>
- </div>
+ <Radio
+ name="source"
+ label="Use plain JSON"
+ checked={this.props.source === "json"}
+ value="json"
+ onValueChange={this.bindSource.bind(this)}
+ disabled={disabled}
+ />
+ {fields}
+ </Container>
);
}
diff --git a/src/settings/components/site.scss b/src/settings/components/site.scss
deleted file mode 100644
index c0c4f9e..0000000
--- a/src/settings/components/site.scss
+++ /dev/null
@@ -1,27 +0,0 @@
-.vimvixen-settings-form {
- padding: 2px;
-
- textarea[name=json] {
- font-family: monospace;
- width: 100%;
- min-height: 64ex;
- resize: vertical;
- }
-
- fieldset {
- margin: 0;
- padding: 0;
- border: none;
- margin-top: 1rem;
-
- fieldset:first-of-type {
- margin-top: 1rem;
- }
-
- legend {
- font-size: 1.5rem;
- padding: .5rem 0;
- font-weight: bold;
- }
- }
-}
diff --git a/src/settings/components/ui/AddButton.scss b/src/settings/components/ui/AddButton.scss
deleted file mode 100644
index beb5688..0000000
--- a/src/settings/components/ui/AddButton.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-.ui-add-button {
- border: none;
- padding: 4;
- display: inline;
- background: none;
- font-weight: bold;
- color: green;
- cursor: pointer;
-
- &:hover {
- color: darkgreen;
- }
-}
diff --git a/src/settings/components/ui/AddButton.tsx b/src/settings/components/ui/AddButton.tsx
index c15a732..8cf4300 100644
--- a/src/settings/components/ui/AddButton.tsx
+++ b/src/settings/components/ui/AddButton.tsx
@@ -1,19 +1,24 @@
-import "./AddButton.scss";
import React from "react";
+import styled from "styled-components";
-type Props = React.AllHTMLAttributes<HTMLInputElement>;
+const Button = styled.input`
+ border: none;
+ padding: 4;
+ display: inline;
+ background: none;
+ font-weight: bold;
+ color: green;
+ cursor: pointer;
-class AddButton extends React.Component<Props> {
- render() {
- return (
- <input
- className="ui-add-button"
- type="button"
- value="&#x271a;"
- {...this.props}
- />
- );
+ &:hover {
+ color: darkgreen;
}
-}
+`;
+
+type Props = React.InputHTMLAttributes<HTMLInputElement>;
+
+const AddButton: React.FC<Props> = (props) => (
+ <Button type="button" value="&#x271a;" {...props} />
+);
export default AddButton;
diff --git a/src/settings/components/ui/DeleteButton.scss b/src/settings/components/ui/DeleteButton.scss
deleted file mode 100644
index 5932a72..0000000
--- a/src/settings/components/ui/DeleteButton.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-
-.ui-delete-button {
- border: none;
- padding: 4;
- display: inline;
- background: none;
- color: red;
- cursor: pointer;
-
- &:hover {
- color: darkred;
- }
-}
diff --git a/src/settings/components/ui/DeleteButton.tsx b/src/settings/components/ui/DeleteButton.tsx
index df8976e..ce0183b 100644
--- a/src/settings/components/ui/DeleteButton.tsx
+++ b/src/settings/components/ui/DeleteButton.tsx
@@ -1,19 +1,23 @@
-import "./DeleteButton.scss";
import React from "react";
+import styled from "styled-components";
-type Props = React.AllHTMLAttributes<HTMLInputElement>;
+const Button = styled.input`
+ border: none;
+ padding: 4;
+ display: inline;
+ background: none;
+ color: red;
+ cursor: pointer;
-class DeleteButton extends React.Component<Props> {
- render() {
- return (
- <input
- className="ui-delete-button"
- type="button"
- value="&#x2716;"
- {...this.props}
- />
- );
+ &:hover {
+ color: darkred;
}
-}
+`;
+
+type Props = React.InputHTMLAttributes<HTMLInputElement>;
+
+const DeleteButton: React.FC<Props> = (props) => (
+ <Button type="button" value="&#x2716;" {...props} />
+);
export default DeleteButton;
diff --git a/src/settings/components/ui/Input.scss b/src/settings/components/ui/Input.scss
deleted file mode 100644
index ad4daf8..0000000
--- a/src/settings/components/ui/Input.scss
+++ /dev/null
@@ -1,29 +0,0 @@
-.settings-ui-input {
- page-break-inside: avoid;
-
- * {
- page-break-inside: avoid;
- }
-
- label {
- font-weight: bold;
- min-width: 14rem;
- display: inline-block;
- }
-
- input[type='text'] {
- padding: 4px;
- width: 8rem;
- }
-
- input.input-crror,
- textarea.input-error {
- box-shadow: 0 0 2px red;
- }
-
- &-error {
- font-weight: bold;
- color: red;
- min-height: 1.5em;
- }
-}
diff --git a/src/settings/components/ui/Input.tsx b/src/settings/components/ui/Input.tsx
deleted file mode 100644
index 0e24277..0000000
--- a/src/settings/components/ui/Input.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import React from "react";
-import "./Input.scss";
-
-interface Props extends React.AllHTMLAttributes<HTMLElement> {
- name: string;
- type: string;
- error?: string;
- label: string;
- value: string;
- onValueChange?: (name: string, value: string) => void;
- onBlur?: (e: React.FocusEvent<Element>) => void;
-}
-
-class Input extends React.Component<Props> {
- renderText(props: Props) {
- const inputClassName = props.error ? "input-error" : "";
- const pp = { ...props };
- delete pp.onValueChange;
- return (
- <div className="settings-ui-input">
- <label htmlFor={props.id}>{props.label}</label>
- <input
- className={inputClassName}
- onChange={this.bindOnChange.bind(this)}
- {...pp}
- />
- </div>
- );
- }
-
- renderRadio(props: Props) {
- const inputClassName = props.error ? "input-error" : "";
- const pp = { ...props };
- delete pp.onValueChange;
- return (
- <div className="settings-ui-input">
- <label>
- <input
- className={inputClassName}
- onChange={this.bindOnChange.bind(this)}
- {...pp}
- />
- {props.label}
- </label>
- </div>
- );
- }
-
- renderTextArea(props: Props) {
- const inputClassName = props.error ? "input-error" : "";
- const pp = { ...props };
- delete pp.onValueChange;
- return (
- <div className="settings-ui-input">
- <label htmlFor={props.id}>{props.label}</label>
- <textarea
- className={inputClassName}
- onChange={this.bindOnChange.bind(this)}
- {...pp}
- />
- <p className="settings-ui-input-error">{this.props.error}</p>
- </div>
- );
- }
-
- render() {
- const { type } = this.props;
-
- switch (this.props.type) {
- case "text":
- return this.renderText(this.props);
- case "radio":
- return this.renderRadio(this.props);
- case "textarea":
- return this.renderTextArea(this.props);
- default:
- console.warn(`Unsupported input type ${type}`);
- }
- return null;
- }
-
- bindOnChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
- if (this.props.onValueChange) {
- this.props.onValueChange(e.target.name, e.target.value);
- }
- }
-}
-
-export default Input;
diff --git a/src/settings/components/ui/Radio.tsx b/src/settings/components/ui/Radio.tsx
new file mode 100644
index 0000000..c0d4dd9
--- /dev/null
+++ b/src/settings/components/ui/Radio.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+import styled from "styled-components";
+
+const Container = styled.div`
+ font-family: system-ui;
+`;
+
+interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
+ label: string;
+ onValueChange?: (name: string, value: string) => void;
+}
+
+const Radio: React.FC<Props> = (props) => {
+ const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (props.onValueChange) {
+ props.onValueChange(e.target.name, e.target.value);
+ }
+ };
+
+ const pp = { ...props };
+ delete pp.onValueChange;
+
+ return (
+ <Container>
+ <label htmlFor={props.id}>
+ <input type="radio" onChange={onChange} {...pp} />
+ {props.label}
+ </label>
+ </Container>
+ );
+};
+
+export default Radio;
diff --git a/src/settings/components/ui/Text.tsx b/src/settings/components/ui/Text.tsx
new file mode 100644
index 0000000..700b08a
--- /dev/null
+++ b/src/settings/components/ui/Text.tsx
@@ -0,0 +1,54 @@
+import React from "react";
+import styled from "styled-components";
+
+const Container = styled.div`
+ page-break-inside: avoid;
+`;
+
+const Input = styled.input<{ hasError: boolean }>`
+ padding: 4px;
+ width: 8rem;
+ box-shadow: ${({ hasError }) => (hasError ? "0 0 2px red" : "none")};
+`;
+
+const Label = styled.label`
+ font-weight: bold;
+ min-width: 14rem;
+ display: inline-block;
+`;
+
+interface Props extends React.HTMLAttributes<HTMLElement> {
+ name: string;
+ error?: string;
+ label: string;
+ value: string;
+ onValueChange?: (name: string, value: string) => void;
+}
+
+const Text: React.FC<Props> = (props) => {
+ const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (props.onValueChange) {
+ props.onValueChange(e.target.name, e.target.value);
+ }
+ };
+
+ const pp = { ...props };
+ delete pp.onValueChange;
+
+ return (
+ <Container>
+ <Label>
+ {props.label}
+ <br />
+ <Input
+ type="text"
+ hasError={props.error !== undefined}
+ onChange={onChange}
+ {...pp}
+ />
+ </Label>
+ </Container>
+ );
+};
+
+export default Text;
diff --git a/src/settings/components/ui/TextArea.tsx b/src/settings/components/ui/TextArea.tsx
new file mode 100644
index 0000000..9dcd5be
--- /dev/null
+++ b/src/settings/components/ui/TextArea.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import styled from "styled-components";
+
+const Container = styled.div`
+ page-break-inside: avoid;
+`;
+
+const Label = styled.label`
+ font-weight: bold;
+ min-width: 14rem;
+ display: inline-block;
+`;
+
+const ErrorableTextArea = styled.textarea<{ hasError: boolean }>`
+ box-shadow: ${({ hasError }) => (hasError ? "0 0 2px red" : "none")};
+ font-family: monospace;
+ font-family: monospace;
+ width: 100%;
+ min-height: 64ex;
+ resize: vertical;
+`;
+
+const ErrorMessage = styled.p`
+ font-weight: bold;
+ color: red;
+ min-height: 1.5em;
+`;
+
+interface Props extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
+ error?: string;
+ label: string;
+ onValueChange?: (name: string, value: string) => void;
+}
+
+const TextArea: React.FC<Props> = (props) => {
+ const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+ if (props.onValueChange) {
+ props.onValueChange(e.target.name, e.target.value);
+ }
+ };
+
+ const hasError = typeof props.error !== "undefined" && props.error !== "";
+ const pp = { ...props };
+ delete pp.onValueChange;
+ return (
+ <Container>
+ <Label htmlFor={props.id}>{props.label}</Label>
+ <ErrorableTextArea hasError={hasError} onChange={onChange} {...pp} />
+ {hasError ? (
+ <ErrorMessage role="alert">{props.error}</ErrorMessage>
+ ) : null}
+ </Container>
+ );
+};
+
+export default TextArea;