diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/HeaderBar/index.tsx | 139 | ||||
| -rw-r--r-- | src/components/HeaderBar/logo.svg | 3 | ||||
| -rw-r--r-- | src/components/InputTypes/Checkbox.tsx | 65 | ||||
| -rw-r--r-- | src/components/InputTypes/Code.tsx | 11 | ||||
| -rw-r--r-- | src/components/InputTypes/Radio.tsx | 39 | ||||
| -rw-r--r-- | src/components/InputTypes/Range.tsx | 117 | ||||
| -rw-r--r-- | src/components/InputTypes/Select.tsx | 204 | ||||
| -rw-r--r-- | src/components/InputTypes/ShortText.tsx | 12 | ||||
| -rw-r--r-- | src/components/InputTypes/TextArea.tsx | 21 | ||||
| -rw-r--r-- | src/components/InputTypes/index.tsx | 71 | ||||
| -rw-r--r-- | src/components/Question.tsx | 166 | ||||
| -rw-r--r-- | src/components/ScrollToTop.tsx | 93 | 
12 files changed, 901 insertions, 40 deletions
| diff --git a/src/components/HeaderBar/index.tsx b/src/components/HeaderBar/index.tsx index dfe3957..5c536ef 100644 --- a/src/components/HeaderBar/index.tsx +++ b/src/components/HeaderBar/index.tsx @@ -1,59 +1,118 @@  /** @jsx jsx */ -import { css, jsx } from "@emotion/react"; +import { jsx, css } from "@emotion/react";  import Header1 from "./header_1.svg";  import Header2 from "./header_2.svg"; +import Logo from "./logo.svg"; + +import { Link } from "react-router-dom";  interface HeaderBarProps { -  title?: string +    title?: string +    description?: string  }  const headerImageStyles = css` -z-index: -1; -top: 0; -position: absolute; -width: 100%; -transition: height 1s; +  * { +    z-index: -1; +    top: 0; +    position: absolute; +    width: 100%; +    transition: height 1s; +  } +`; + +const headerTextStyles = css` +  transition: margin 1s; +  font-family: "Uni Sans", "Hind", "Arial", sans-serif; + +  margin: 0 2rem 10rem 2rem; + +  .title { +    font-size: 3vmax; +    margin-bottom: 0; +  } + +  .full_size { +    line-height: 200%; +  } + +  .description { +    font-size: 1.5vmax; +  } + +  .title, .description { +    transition: font-size 1s; +  } + +  @media (max-width: 480px) { +    margin-top: 7rem; +    text-align: center; + +    .title { +      font-size: 5vmax; +    } + +    .full_size { +      line-height: 100%; +    } + +    .description { +      font-size: 2vmax; +    } +  } +`; + +const homeButtonStyles = css` +  svg { +    transform: scale(0.25); +    transition: top 300ms, transform 300ms; + +    @media (max-width: 480px) { +      transform: scale(0.15); +    } +  } + +  * { +    position: absolute; +    top: -10rem; +    right: 1rem; + +    z-index: 0; +    transform-origin: right; + +    @media (max-width: 700px) { +      top: -11.5rem; +    } + +    @media (max-width: 480px) { +      top: -12.5rem; +    } +  }  `; -function HeaderBar({ title }: HeaderBarProps): JSX.Element { +function HeaderBar({ title, description }: HeaderBarProps): JSX.Element {      if (!title) {          title = "Python Discord Forms";      } -   -    return <div> + +    return (          <div> -            <Header1 css={headerImageStyles}/> -            <Header2 css={headerImageStyles}/> +            <div css={headerImageStyles}> +                <Header1/> +                <Header2/> +            </div> + +            <div css={css`${headerTextStyles}; margin-bottom: 12.5%;`}> +                <h1 className={description ? "title" : "title full_size"}>{title}</h1> +                {description ? <h1 className="description">{description}</h1> : null} +            </div> + +            <Link to="/" css={homeButtonStyles}> +                <Logo/> +            </Link>          </div> -        <h1 css={css` -      font-size: 4vw; -      margin: 0; -      margin-top: 30px; -      margin-left: 30px; -      margin-bottom: 200px; -      transition-property: font-size, margin-bottom; -      transition-duration: 1s; -      font-family: "Uni Sans", "Hind", "Arial", sans-serif; - -      @media (max-width: 1000px) { -        margin-top: 15px; -        font-size: 8vw; -      } - -      @media (max-width: 770px) { -        margin-top: 15px; -        font-size: 6vw; -        margin-bottom: 100px; -      } -      @media (max-width: 450px) { -        text-align: center; -        margin-left: 0; -      } -    `}> -            {title} -        </h1> -    </div>; +    );  }  export default HeaderBar; diff --git a/src/components/HeaderBar/logo.svg b/src/components/HeaderBar/logo.svg new file mode 100644 index 0000000..e7f43fc --- /dev/null +++ b/src/components/HeaderBar/logo.svg @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="463.86" height="463.86" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata> +	<path d="m296.09 80.929-11.506 3.3086c-12.223-1.8051-24.757-2.597-36.895-2.5078-13.5 0.1-26.498 1.1992-37.898 3.1992-3.6998 0.65161-7.0474 1.386-10.107 2.1992h-35.893v18.801h4.5v7.6992h2.7832c-0.63936 3.7142-0.88476 7.7997-0.88476 12.301v4h-15.398l-2.2012 11 17.6 15.014v0.0859h79.201v10h-79.201-29.699c-23 0-43.2 13.8-49.5 40-7.3 30-7.6 48.801 0 80.201 0.49734 2.0782 1.0605 4.0985 1.6836 6.0625l-1.1836 10.438 13.346 11.549c7.032 7.5103 16.371 11.951 28.254 11.951h27.201v-36c0-26 22.6-49 49.5-49h79.199c22 0 39.6-18.102 39.6-40.102v-75.199c0-12.9-6.5819-23.831-16.516-31.273zm76.801 77.6-14.301 7.4004h-20.1v0.0996 10.301 24.699c0 27.2-23.1 50-49.5 50h-79.1c-21.7 0-39.6 18.5-39.6 40.1v75.102c0 21.4 18.7 34 39.6 40.1 25.1 7.3 49.1 8.7 79.1 0 19.9-5.7 39.6-17.3 39.6-40.1v-27.9-2.0996-0.10156h-0.11914l-11.721-10h11.84 39.6c23 0 31.602-16 39.602-40 8.3-24.7 7.9-48.499 0-80.199-3.6226-14.491-9.3525-26.71-18.947-33.699zm-123.4 167.6h57.5v10h-57.5z" fill-opacity=".20554"/><path class="st2" d="m229.99 66.629c-13.5 0.1-26.5 1.2-37.9 3.2-33.5 5.9-39.6 18.2-39.6 41v30.1h79.2v10h-79.2-29.7c-23 0-43.2 13.8-49.5 40-7.3 30-7.6 48.8 0 80.2 5.6 23.4 19.1 40 42.1 40h27.2v-36c0-26 22.6-49 49.5-49h79.1c22 0 39.6-18.1 39.6-40.1v-75.2c0-21.4-18.1-37.4-39.6-41-13.5-2.3-27.6-3.3-41.2-3.2zm-42.8 24.2c8.2 0 14.9 6.8 14.9 15.1 0 8.3-6.7 15-14.9 15s-14.9-6.7-14.9-15c0-8.4 6.7-15.1 14.9-15.1z" fill="#cbd6ff"/><path class="st3" d="m320.79 150.93v35c0 27.2-23.1 50-49.5 50h-79.1c-21.7 0-39.6 18.5-39.6 40.1v75.1c0 21.4 18.7 34 39.6 40.1 25.1 7.3 49.1 8.7 79.1 0 19.9-5.7 39.6-17.3 39.6-40.1v-30.1h-79.1v-10h79.1 39.6c23 0 31.6-16 39.6-40 8.3-24.7 7.9-48.5 0-80.2-5.7-22.8-16.6-40-39.6-40h-29.7zm-44.5 190.2c8.2 0 14.9 6.7 14.9 15s-6.7 15.1-14.9 15.1-14.9-6.8-14.9-15.1 6.7-15 14.9-15z" fill="#fff"/></svg> diff --git a/src/components/InputTypes/Checkbox.tsx b/src/components/InputTypes/Checkbox.tsx new file mode 100644 index 0000000..3093caf --- /dev/null +++ b/src/components/InputTypes/Checkbox.tsx @@ -0,0 +1,65 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import colors from "../../colors"; +import { multiSelectInput, hiddenInput } from "../../commonStyles"; + +interface CheckboxProps { +    index: number, +    option: string, +    handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +const generalStyles = css` +  cursor: pointer; +         +  label { +    width: 1em; +    height: 1em; +    top: 0.3rem; + +    border-radius: 25%; +    cursor: pointer; +  } + +  .unselected { +    background-color: white; +  } + +  .unselected:focus-within, :hover .unselected { +    background-color: lightgray; +  } + +  .checkmark { +    position: absolute; +  } +`; + +const activeStyles = css` +  .selected { +    background-color: ${colors.blurple}; +  } + +  .selected .checkmark { +    width: 0.30rem; +    height: 0.60rem; +    left: 0.25em; + +    border: solid white; +    border-width: 0 0.2rem 0.2rem 0; + +    transform: rotate(45deg); +  } +`; + +export default function Checkbox(props: CheckboxProps): JSX.Element { +    return ( +        <label css={[generalStyles, activeStyles]}> +            <label className="unselected" css={multiSelectInput}> +                <input type="checkbox" value={props.option} css={hiddenInput} name={`${("000" + props.index).slice(-4)}. ${props.option}`} onChange={props.handler}/> +                <span className="checkmark"/> +            </label> +            {props.option}<br/> +        </label> +    ); +} diff --git a/src/components/InputTypes/Code.tsx b/src/components/InputTypes/Code.tsx new file mode 100644 index 0000000..51ca98d --- /dev/null +++ b/src/components/InputTypes/Code.tsx @@ -0,0 +1,11 @@ +/** @jsx jsx */ +import { jsx } from "@emotion/react"; +import React, { ChangeEvent } from "react"; + +interface CodeProps { +    handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +export default function Code(props: CodeProps): JSX.Element { +    return <input type="text" className="text" onChange={props.handler}/>; +} diff --git a/src/components/InputTypes/Radio.tsx b/src/components/InputTypes/Radio.tsx new file mode 100644 index 0000000..3bf13ed --- /dev/null +++ b/src/components/InputTypes/Radio.tsx @@ -0,0 +1,39 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import colors from "../../colors"; +import { multiSelectInput, hiddenInput } from "../../commonStyles"; + +interface RadioProps { +    option: string, +    question_id: string, +    handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +const styles = css` +  div { +    width: 0.7em; +    height: 0.75em; +    top: 0.18rem; + +    border-radius: 50%; +  } + +  :hover div, :focus-within div { +    background-color: lightgray; +  } + +  input:checked+div { +    background-color: ${colors.blurple}; +  } +`; + +export default function Radio(props: RadioProps): JSX.Element { +    return ( +        <label css={styles}> +            <input type="radio" name={props.question_id} onChange={props.handler} css={hiddenInput}/> +            <div css={multiSelectInput}/> +            {props.option}<br/> +        </label> +    ); +} diff --git a/src/components/InputTypes/Range.tsx b/src/components/InputTypes/Range.tsx new file mode 100644 index 0000000..e2f89f4 --- /dev/null +++ b/src/components/InputTypes/Range.tsx @@ -0,0 +1,117 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import colors from "../../colors"; +import { hiddenInput, multiSelectInput } from "../../commonStyles"; + +interface RangeProps { +    question_id: string, +    options: Array<string>, +    handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +const containerStyles = css` +  display: flex; +  justify-content: space-between; +  position: relative; +  width: 100%; + +  @media (max-width: 800px) { +    width: 20%; +    display: block; +    margin: 0 auto; +     +    label span { +      margin-left: 0; +      transform: translateY(1.6rem) translateX(2rem); +    } +  } +`; + +const optionStyles = css` +  display: inline-block; +  transform: translateX(-50%); +  margin: 0 50%; + +  white-space: nowrap; + +  transition: transform 300ms; +`; + +const selectorStyles = css` +  cursor: pointer; + +  div { +    width: 1rem; +    height: 1rem; + +    background-color: whitesmoke; + +    border-radius: 50%; +    margin: 0 100% 0 0; +  } + +  :hover div, :focus-within div { +    background-color: lightgray; +  } + +  input:checked+div { +    background-color: ${colors.blurple}; +  } +`; + +const sliderContainerStyles = css` +  display: flex; +  justify-content: center; +  width: 100%; + +  position: absolute; +  z-index: -1; + +  top: 2.1rem; + +  transition: all 300ms; +   +  @media (max-width: 800px) { +    width: 0.5rem; +    height: 80%; + +    left: 0.4rem; + +    background: whitesmoke; +  } +`; + +const sliderStyles = css` +  width: 98%; /* Needs to be slightly smaller than container to work on all devices */ +  height: 0.5rem; +  background-color: whitesmoke; + +  transition: transform 300ms; +   +  @media (max-width: 800px) { +    display: none; +  } +`; + +export default function Range(props: RangeProps): JSX.Element { +    const range = props.options.map((option, index) => { +        return ( +            <label css={[selectorStyles, css`width: 1rem`]} key={index}> +                <span css={optionStyles}>{option}</span> +                <input type="radio" name={props.question_id} css={hiddenInput} onChange={props.handler}/> +                <div css={multiSelectInput}/> +            </label> +        ); +    }); + +    return ( +        <div css={containerStyles}> +            { range } + +            <div css={sliderContainerStyles}> +                <div css={sliderStyles}/> +            </div> +        </div> +    ); +} diff --git a/src/components/InputTypes/Select.tsx b/src/components/InputTypes/Select.tsx new file mode 100644 index 0000000..e753357 --- /dev/null +++ b/src/components/InputTypes/Select.tsx @@ -0,0 +1,204 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React from "react"; +import { hiddenInput } from "../../commonStyles"; + +interface SelectProps { +    options: Array<string>, +    state_dict: Map<string, string | boolean | null> +} + +const containerStyles = css` +  position: relative; +  width: min(20rem, 90%); + +  color: black; +  cursor: pointer; + +  :focus-within .selected_container { +    border-bottom-left-radius: 0; +    border-bottom-right-radius: 0; + +    border-bottom-color: transparent; +  } +`; + +const mainWindowStyles = css` +  display: inline-block; +  position: relative; +  background: whitesmoke; + +  width: 100%; +  height: 100%; +  min-height: 2.5rem; + +  margin-bottom: 0; + +  overflow: hidden; +  z-index: 1; + +  :hover, :focus-within { +    background-color: lightgray; +  } + +  .selected_option { +    position: absolute; +    height: 100%; +    width: 100%; + +    outline: none; +    padding-left: 0.75rem; +    line-height: 250%; +  } + +  border: 0.1rem solid black; +  border-radius: 8px; + +  transition: border-radius 200ms; +`; + +const arrowStyles = css` +  .arrow { +    display: inline-block; +    height: 0.5rem; +    width: 0.5rem; + +    position: relative; +    float: right; +    right: 1em; +    top: 0.7rem; + +    border: solid black; +    border-width: 0 0.2rem 0.2rem 0; + +    transform: rotate(45deg); +    transition: transform 200ms; +  } + +  :focus-within .arrow { +    transform: translateY(40%) rotate(225deg); +  } +`; + +const optionContainerStyles = css` +  .option_container { +    position: absolute; +    width: 100%; +    height: 0; + +    top: 2.3rem; +    padding-top: 0.2rem; + +    visibility: hidden; +    opacity: 0; + +    overflow: hidden; +    background: whitesmoke; + +    border: 0.1rem solid black; +    border-radius: 0 0 8px 8px; +    border-top: none; + +    transition: opacity 200ms, visibility 200ms; + +    * { +      cursor: pointer; +    } +  } + +  :focus-within .option_container { +    height: auto; +    visibility: visible; +    opacity: 1; +  } + +  .option_container .hidden { +    display: none; +  } +`; + +const inputStyles = css` +  position: absolute; +  width: 100%; +  height: 100%; + +  z-index: 2; + +  margin: 0; +  border: none; +  outline: none; +`; + +const optionStyles = css` +  position: relative; + +  :hover, :focus-within { +    background-color: lightgray; +  } + +  div { +    padding: 0.75rem; +  } +`; + +class Select extends React.Component<SelectProps> { +    handler(selected_option: React.RefObject<HTMLDivElement>, event: React.ChangeEvent<HTMLInputElement>): void { +        const option_container = event.target.parentElement; +        if (!option_container || !option_container.parentElement || !selected_option.current) { +            return; +        } + +        // Update stored value +        this.props.state_dict.set("value", option_container.textContent); + +        // Close the menu +        selected_option.current.focus(); +        selected_option.current.blur(); +        selected_option.current.textContent = option_container.textContent; +    } + +    handle_click( +        container: React.RefObject<HTMLDivElement>, +        selected_option: React.RefObject<HTMLDivElement>, +        event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement> +    ): void { +        if (!container.current || !selected_option.current || (event.type === "keydown" && (event as React.KeyboardEvent).code !== "Space")) { +            return; +        } + +        // Check if menu is open +        if (container.current.contains(document.activeElement)) { +            // Close menu +            selected_option.current.focus(); +            selected_option.current.blur(); +            event.preventDefault(); +        } +    } + +    render(): JSX.Element { +        const container_ref: React.RefObject<HTMLDivElement> = React.createRef(); +        const selected_option_ref: React.RefObject<HTMLDivElement> = React.createRef(); + +        const handle_click = (event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => this.handle_click(container_ref, selected_option_ref, event); + +        return ( +            <div css={[containerStyles, arrowStyles, optionContainerStyles]} ref={container_ref}> +                <div className="selected_container" css={mainWindowStyles}> +                    <span className="arrow"/> +                    <div tabIndex={0} className="selected_option" ref={selected_option_ref} onMouseDown={handle_click} onKeyDown={handle_click}>...</div> +                </div> + +                <div className="option_container" tabIndex={-1} css={css`outline: none;`}> +                    { this.props.options.map((option, index) => ( +                        <div key={index} css={optionStyles}> +                            <input type="checkbox" css={[hiddenInput, inputStyles]} onChange={event => this.handler.call(this, selected_option_ref, event)}/> +                            <div>{option}</div> +                        </div> +                    )) } +                </div> +            </div> +        ); +    } +} + +export default Select; diff --git a/src/components/InputTypes/ShortText.tsx b/src/components/InputTypes/ShortText.tsx new file mode 100644 index 0000000..1e38bcd --- /dev/null +++ b/src/components/InputTypes/ShortText.tsx @@ -0,0 +1,12 @@ +/** @jsx jsx */ +import { jsx } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import { textInputs } from "../../commonStyles"; + +interface ShortTextProps { +    handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +export default function ShortText(props: ShortTextProps): JSX.Element { +    return <input type="text" css={textInputs} placeholder="Enter Text..." onChange={props.handler}/>; +} diff --git a/src/components/InputTypes/TextArea.tsx b/src/components/InputTypes/TextArea.tsx new file mode 100644 index 0000000..6e46c27 --- /dev/null +++ b/src/components/InputTypes/TextArea.tsx @@ -0,0 +1,21 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import { textInputs } from "../../commonStyles"; + +interface TextAreaProps { +    handler: (event: ChangeEvent<HTMLTextAreaElement>) => void +} + +const styles = css` +  min-height: 20rem; +  width: 100%; +  box-sizing: border-box; +  resize: vertical; + +  padding: 1rem; +`; + +export default function TextArea(props: TextAreaProps): JSX.Element { +    return <textarea css={[textInputs, styles]} placeholder="Enter Text..." onChange={props.handler}/>; +} diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx new file mode 100644 index 0000000..f1e0b30 --- /dev/null +++ b/src/components/InputTypes/index.tsx @@ -0,0 +1,71 @@ +import Checkbox from "./Checkbox"; +import Code from "./Code"; +import Radio from "./Radio"; +import Range from "./Range"; +import Select from "./Select"; +import ShortText from "./ShortText"; +import TextArea from "./TextArea"; + +import React, { ChangeEvent } from "react"; + +import { QuestionType } from "../../api/question"; +import { QuestionProp } from "../Question"; + +const require_options: Array<QuestionType> = [ +    QuestionType.Radio, +    QuestionType.Checkbox, +    QuestionType.Select, +    QuestionType.Range +]; + +export default function create_input({ question, public_state }: QuestionProp, handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void): JSX.Element | JSX.Element[] { +    let result: JSX.Element | JSX.Element[]; + +    // eslint-disable-next-line +    // @ts-ignore +    let options: string[] = question.data["options"]; + +    // Catch input types that require options but don't have any +    if ((options === undefined || typeof options !== "object") && require_options.includes(question.type)) { +        // TODO: Implement some sort of warning here +        options = []; +    } + +    /* eslint-disable react/react-in-jsx-scope */ +    switch (question.type) { +        case QuestionType.TextArea: +            result = <TextArea handler={handler}/>; +            break; + +        case QuestionType.Checkbox: +            result = options.map((option, index) => <Checkbox index={index} option={option} handler={handler} key={index}/>); +            break; + +        case QuestionType.Radio: +            result = options.map((option, index) => <Radio option={option} question_id={question.id} handler={handler} key={index}/>); +            break; + +        case QuestionType.Select: +            result = <Select options={options} state_dict={public_state}/>; +            break; + +        case QuestionType.ShortText: +            result = <ShortText handler={handler}/>; +            break; + +        case QuestionType.Range: +            result = <Range question_id={question.id} options={options} handler={handler}/>; +            break; + +        case QuestionType.Code: +            // TODO: Implement +            result = <Code handler={handler}/>; +            break; + +        default: +            result = <TextArea handler={handler}/>; +    } +    /* eslint-enable react/react-in-jsx-scope */ + +    return result; +} diff --git a/src/components/Question.tsx b/src/components/Question.tsx new file mode 100644 index 0000000..735d69b --- /dev/null +++ b/src/components/Question.tsx @@ -0,0 +1,166 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; + +import { Question, QuestionType } from "../api/question"; +import { selectable } from "../commonStyles"; +import create_input from "./InputTypes"; + +const skip_normal_state: Array<QuestionType> = [ +    QuestionType.Radio, +    QuestionType.Checkbox, +    QuestionType.Select, +    QuestionType.Section, +    QuestionType.Range +]; + +export type QuestionProp = { +    question: Question, +    public_state: Map<string, string | boolean | null>, +} + +class RenderedQuestion extends React.Component<QuestionProp> { +    constructor(props: QuestionProp) { +        super(props); +        if (props.question.type === QuestionType.TextArea) { +            this.handler = this.text_area_handler.bind(this); +        } else { +            this.handler = this.normal_handler.bind(this); +        } + +        if (!skip_normal_state.includes(props.question.type)) { +            this.setPublicState("value", ""); +        } +    } + +    setPublicState(target: string, value: string | boolean | null, callback?:() => void): void { +        this.setState({[target]: value}, callback); +        this.props.public_state.set(target, value); +    } + +    // This is here to allow dynamic selection between the general handler, and the textarea handler. +    handler(_: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {} // eslint-disable-line + +    normal_handler(event: ChangeEvent<HTMLInputElement>): void { +        let target: string; +        let value: string | boolean; + +        switch (event.target.type) { +            case "checkbox": +                target = event.target.name; +                value = event.target.checked; +                break; + +            case "radio": +            // This handles radios and ranges, as they are both based on the same fundamental input type +                target = "value"; +                if (event.target.parentElement) { +                    value = event.target.parentElement.innerText.trimEnd(); +                } else { +                    value = event.target.value; +                } +                break; + +            default: +                target = "value"; +                value = event.target.value; +        } + +        this.setPublicState(target, value); + +        // Toggle checkbox class +        if (event.target.type == "checkbox" && event.target.parentElement !== null) { +            event.target.parentElement.classList.toggle("unselected"); +            event.target.parentElement.classList.toggle("selected"); +        } +    } + +    text_area_handler(event: ChangeEvent<HTMLTextAreaElement>): void { +        this.setPublicState("value", event.target.value); +    } + +    componentDidMount(): void { +        // Initialize defaults for complex and nested fields +        const options: string | string[] = this.props.question.data["options"]; + +        if (this.props.public_state.size === 0) { +            switch (this.props.question.type) { +                case QuestionType.Checkbox: +                    if (typeof options === "string") { +                        return; +                    } + +                    options.forEach((option, index) => { +                        this.setPublicState(`${("000" + index).slice(-4)}. ${option}`, false); +                    }); +                    break; + +                case QuestionType.Range: +                case QuestionType.Radio: +                case QuestionType.Select: +                    this.setPublicState("value", null); +                    break; +            } +        } +    } + +    render(): JSX.Element { +        const question = this.props.question; + +        if (question.type === QuestionType.Section) { +            const styles = css` +              h1 { +                margin-bottom: 0; +              } + +              h3 { +                margin-top: 0; +              } + +              h1, h3 { +                text-align: center; +                padding: 0 2rem; +              } + +              @media (max-width: 500px) { +                h1, h3 { +                  padding: 0; +                } +              } +            `; + +            return <div css={styles}> +                <h1 css={[selectable, css`line-height: 2.5rem;`]}>{question.name}</h1> +                { question.data["text"] ? <h3 css={selectable}>{question.data["text"]}</h3> : "" } +                <hr css={css`color: gray; margin: 3rem 0;`}/> +            </div>; + +        } else { +            const requiredStarStyles = css` +              span { +                display: none; +              } + +              .required { +                display: inline-block; +                position: relative; + +                color: red; + +                top: -0.2rem; +                margin-left: 0.2rem; +              } +            `; + +            return <div> +                <h2 css={[selectable, requiredStarStyles]}> +                    {question.name}<span className={question.required ? "required" : ""}>*</span> +                </h2> +                { create_input(this.props, this.handler) } +                <hr css={css`color: gray; margin: 3rem 0;`}/> +            </div>; +        } +    } +} + +export default RenderedQuestion; diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx new file mode 100644 index 0000000..231c9e8 --- /dev/null +++ b/src/components/ScrollToTop.tsx @@ -0,0 +1,93 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React from "react"; +import colors from "../colors"; + +import smoothscroll from "smoothscroll-polyfill"; +smoothscroll.polyfill(); + +const styles = css` +  width: 2.5rem; +  height: 2.5rem; + +  position: fixed; +  bottom: 3rem; +  right: 3rem; +   +  background-color: ${colors.blurple}; +  border-radius: 50%; + +  opacity: 0; +  transition: opacity 300ms; +   +  :after { +    display: inline-block; +    content: ""; + +    position: fixed; +    bottom: 3.5rem; +    right: 3.65rem; +     +    border: solid whitesmoke; +    border-width: 0.35rem 0.35rem 0 0; +    padding: 0.4rem; + +    transform: rotate(-45deg); +  } +   +  @media (max-width: 800px) { +    bottom: 1.5rem; +    right: 1.5rem; +     +    :after { +      bottom: 2rem; +      right: 2.15rem; +    } +  } +`; + +let last_ref: React.RefObject<HTMLDivElement>; + +class ScrollToTop extends React.Component { +    constructor(props: Record<string, never>) { +        super(props); +        last_ref = React.createRef(); +    } + +    handleScroll(): void { +        if (!last_ref.current) return; + +        if (window.pageYOffset > 250) { +            last_ref.current.style.opacity = "1"; +            last_ref.current.style.cursor = "pointer"; +        } else { +            last_ref.current.style.opacity = "0"; +            last_ref.current.style.cursor = "default"; +        } +    } + +    componentDidMount(): void { +        // Register event handler +        window.addEventListener("scroll", this.handleScroll, {passive: true}); +    } + +    componentDidUpdate(): void { +        // Hide previous iterations, and register handler for current one +        if (last_ref.current) { +            last_ref.current.style.opacity = "0"; +        } + +        window.addEventListener("scroll", this.handleScroll, {passive: true}); +    } + +    componentWillUnmount(): void { +        // Unregister handler +        window.removeEventListener("scroll", this.handleScroll); +    } + +    render(): JSX.Element { +        return <div css={styles} key={Date.now()} ref={last_ref} onClick={() => window.scrollTo({top: 0, behavior: "smooth"})}/>; +    } +} + +export default ScrollToTop; | 
