├── src
├── Helpers
│ ├── Function.re
│ ├── GenericHelpers.re
│ └── StyleHelpers.re
├── Spacer.re
├── Empty.re
├── Label.re
├── UiTypes.re
├── Checkbox.re
├── Button.re
├── Alert.re
├── Lib.re
├── Notifications.re
├── Loader.re
├── Input.re
├── Textarea.re
├── Config.re
├── CheckboxStyles.re
├── Card.re
├── CardStyles.re
├── Segment.re
├── Dnd.re
├── InputStyles.re
├── AlertStyles.re
├── TabStyles.re
├── TextareaStyles.re
├── ButtonStyles.re
├── ActionDropdown.re
├── Notification.re
├── Slider.re
├── Dropdown.re
├── Tooltip.re
└── Tabs.re
├── .prettierrc
├── postcss.config.js
├── jest.config.js
├── babel.config.js
├── .github
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── ui-components.yml
├── .gitignore
├── .storybook
├── preview-head.html
└── main.js
├── scripts
└── generate-react-icon-bindings.js
├── bsconfig.json
├── stories
├── loader.stories.js
├── typography.stories.js
├── alert.stories.js
├── tooltip.stories.js
├── checkbox.stories.js
├── icon.stories.js
├── segment.stories.js
├── input.stories.js
├── slider.stories.js
├── textarea.stories.js
├── button.stories.js
├── dropdown.stories.js
├── notifications.stories.js
├── card.stories.js
└── tabs.stories.js
├── COPYING
├── __tests__
├── GenericHelpers_test.re
└── StyleHelpers_test.re
├── CHANGELOG.md
├── package.json
└── README.md
/src/Helpers/Function.re:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "print-width": 80,
3 | "trailingComma": "es5",
4 | "tabWidth": 4,
5 | "semi": false,
6 | "singleQuote": true
7 | }
8 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('postcss-flexbugs-fixes'),
4 | require('autoprefixer')({
5 | flexbox: 'no-2009',
6 | }),
7 | ],
8 | }
9 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transformIgnorePatterns: [
3 | 'node_modules/(?!(' +
4 | '|bs-css' +
5 | '|@glennsl/bs-jest' +
6 | '|bs-css-emotion' +
7 | '|bs-platform' +
8 | ')/)',
9 | ],
10 | }
11 |
--------------------------------------------------------------------------------
/src/Spacer.re:
--------------------------------------------------------------------------------
1 | module Vertical = {
2 | [@react.component]
3 | let make = (~amount: Css.Types.Length.t) => {
4 |
;
5 | };
6 | };
7 | module Horizontal = {
8 | [@react.component]
9 | let make = (~amount: Css.Types.Length.t) => {
10 |
;
11 | };
12 | };
13 |
--------------------------------------------------------------------------------
/src/Empty.re:
--------------------------------------------------------------------------------
1 | /*
2 | In some cases when pattern matching, we would want to show either a component, or nothing at all.
3 | The easiest way around this is to return an empty string as that won't render to anything. That
4 | isn't particularly declarative though, so we use this 'empty' component instead.
5 | */
6 |
7 | [@react.component]
8 | let make = () => React.string("");
9 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | ['@babel/plugin-proposal-private-property-in-object', { loose: true }],
4 | ],
5 | presets: [
6 | [
7 | '@babel/preset-env',
8 | {
9 | targets: {
10 | node: 'current',
11 | },
12 | },
13 | ],
14 | ],
15 | }
16 |
--------------------------------------------------------------------------------
/src/Label.re:
--------------------------------------------------------------------------------
1 | open UiTypes;
2 | open InputStyles;
3 |
4 | [@react.component]
5 | let make =
6 | (
7 | ~label=Unlabeled,
8 | ~identifier,
9 | ~variant=Normal,
10 | ~theme=Config.defaultTheme,
11 | ) =>
12 | switch (label) {
13 | | Labeled(labelString) =>
14 |
15 | {React.string(labelString)}
16 |
17 | | Unlabeled =>
18 | };
19 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ### :notebook_with_decorative_cover: Description
2 |
3 |
4 |
5 | ### :memo: Checklist
6 |
7 | - [ ] All user-facing changes have changelog entries.
8 | - [ ] The PR description contains instructions for the reviewer, if necessary.
9 |
10 | ### :dart: Review Instructions
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # -- general -------------------------------------------------------------------
2 | .DS_Store
3 | .*sw?
4 | .cache
5 |
6 | # -- bucklescript --------------------------------------------------------------
7 | .merlin
8 | .bsb.lock
9 | *.bs.js
10 | /lib/
11 |
12 | # -- dependencies --------------------------------------------------------------
13 | /node_modules/
14 | /src/node_modules/
15 |
16 | # -- generated -----------------------------------------------------------------
17 | /storybook-static/
18 | /bundleOutput/
19 | /build/
20 | /.out/
21 |
22 | # -- logs ----------------------------------------------------------------------
23 | npm-debug.log
24 | yarn-error.log
25 |
--------------------------------------------------------------------------------
/.storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/UiTypes.re:
--------------------------------------------------------------------------------
1 | [@bs.deriving accessors]
2 | type size =
3 | | Small
4 | | Medium
5 | | Large;
6 |
7 | [@bs.deriving accessors]
8 | type theme =
9 | | Light
10 | | Dark
11 | | TenzirBlue;
12 |
13 | [@bs.deriving accessors]
14 | type fontStyle =
15 | | Normal
16 | | Mono;
17 |
18 | type componentState =
19 | | Base
20 | | Active
21 | | Focus
22 | | Hovering;
23 |
24 | type percentOperations =
25 | | Add
26 | | Subtract;
27 |
28 | type changeTypes =
29 | | Lighten
30 | | Darken;
31 |
32 | [@bs.deriving accessors]
33 | type labels =
34 | | Labeled(string)
35 | | Unlabeled;
36 |
37 | [@bs.deriving accessors]
38 | type validation =
39 | | Valid
40 | | Invalid;
41 |
--------------------------------------------------------------------------------
/scripts/generate-react-icon-bindings.js:
--------------------------------------------------------------------------------
1 | const icons = require("react-icons/fi");
2 | const fs = require("fs");
3 | const os = require("os");
4 |
5 | const mappedIcons = Object.keys(icons).map(icon => {
6 | const header = "Icon;\n";
7 | // Naively slice off two letters, just for removing 'Fi';
8 | let nameForReason = icon.slice(2, icon.length);
9 | return `module ${nameForReason} = {
10 | [@bs.module "react-icons/fi"] [@react.component]
11 | external make: (
12 | ~className: string=?,
13 | ~color: string=?,
14 | ~size: int=?,
15 | ~style: ReactDOMRe.Style.t=?
16 | ) => React.element = "${icon}";
17 | };
18 | `;
19 | });
20 |
21 | return fs.writeFileSync(`./src/Icons.re`, mappedIcons.join(""));
22 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | const { themes } = require('@storybook/theming')
2 | const { addParameters } = require('@storybook/react')
3 |
4 | module.exports = {
5 | addons: [
6 | '@storybook/addon-actions/register',
7 | '@storybook/addon-postcss',
8 | '@storybook/addon-links/register',
9 | 'storybook-dark-mode/register',
10 | ],
11 | stories: ['../stories/*.stories.js'],
12 | options: {
13 | isFullscreen: false,
14 | showNav: true,
15 | showPanel: true,
16 | panelPosition: 'bottom',
17 | sidebarAnimations: true,
18 | enableShortcuts: false,
19 | isToolshown: true,
20 | storySort: undefined,
21 | configureJSX: true,
22 | },
23 | }
24 |
25 | addParameters({
26 | darkMode: {
27 | dark: { ...themes.dark, appBg: 'rgb(34, 38, 38)' },
28 | light: themes.light,
29 | },
30 | })
31 |
--------------------------------------------------------------------------------
/src/Checkbox.re:
--------------------------------------------------------------------------------
1 | open CheckboxStyles;
2 | open UiTypes;
3 |
4 | [@react.component]
5 | let make =
6 | (
7 | ~defaultValue=false,
8 | ~disabled=false,
9 | ~label,
10 | ~id=?,
11 | ~width=[@reason.preserve_braces] 100.0,
12 | ~theme=Config.defaultTheme,
13 | ~placeholder="",
14 | ~validity=Valid,
15 | ~onChange=?,
16 | ) => {
17 | let identifier =
18 | switch (id) {
19 | | Some(idString) => idString
20 | | None => label
21 | };
22 |
23 |
24 |
25 |
34 | label->React.string
35 |
36 |
;
37 | };
38 |
--------------------------------------------------------------------------------
/bsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tenzir-ui-component-library",
3 | "reason": {
4 | "react-jsx": 3
5 | },
6 | "sources": [
7 | {
8 | "dir": "src",
9 | "subdirs": true
10 | },
11 | {
12 | "dir": "__tests__",
13 | "type": "dev"
14 | }
15 | ],
16 | "bsc-flags": ["-bs-super-errors", "-bs-no-version-header"],
17 | "warnings": {
18 | "number": "+A-4-41-48-102-107",
19 | "error": "+A-3-44-102-103"
20 | },
21 | "package-specs": [
22 | {
23 | "module": "es6-global",
24 | "in-source": true
25 | }
26 | ],
27 | "suffix": ".bs.js",
28 | "namespace": true,
29 | "bs-dependencies": [
30 | "bs-flexboxgrid",
31 | "bs-webapi",
32 | "reason-react",
33 | "bs-css",
34 | "bs-css-emotion"
35 | ],
36 | "bs-dev-dependencies": ["@glennsl/bs-jest"],
37 | "refmt": 3
38 | }
39 |
--------------------------------------------------------------------------------
/src/Button.re:
--------------------------------------------------------------------------------
1 | [@react.component]
2 | let make =
3 | (
4 | ~disabled=false,
5 | ~type_="button",
6 | ~variant=ButtonStyles.Primary,
7 | ~size=UiTypes.Medium,
8 | ~theme=Config.defaultTheme,
9 | ~icon=?,
10 | ~iconPosition=ButtonStyles.Left,
11 | ~onClick=?,
12 | ~className="",
13 | ~children,
14 | ) =>
15 |
24 | {switch (icon) {
25 | | Some(icon) =>
26 | icon
27 | | None =>
28 | }}
29 | children
30 | ;
31 |
32 | module Group = {
33 | [@react.component]
34 | let make = (~children, ~className="") =>
35 |
36 | children
37 |
;
38 | };
39 |
--------------------------------------------------------------------------------
/src/Alert.re:
--------------------------------------------------------------------------------
1 | open AlertStyles;
2 |
3 | [@react.component]
4 | let make =
5 | (
6 | ~children=?,
7 | ~variant=Primary,
8 | ~theme=Config.defaultTheme,
9 | ~size=UiTypes.Medium,
10 | ~className="",
11 | ~message=?,
12 | ) =>
13 |
15 | <>
16 |
17 | {switch (variant) {
18 | | Primary
19 | | Secondary =>
20 | | Success =>
21 | | Warning =>
22 | | Danger =>
23 | }}
24 |
25 | {switch (message, children) {
26 | | (Some(message), Some(children)) =>
27 | <> message->React.string children >
28 | | (Some(message), None) => message->React.string
29 | | (None, Some(children)) => children
30 | | (None, None) =>
31 | }}
32 | >
33 | ;
34 |
--------------------------------------------------------------------------------
/src/Lib.re:
--------------------------------------------------------------------------------
1 | module Function = {
2 | let id = x => x;
3 | let apply = (x, fn) => fn(x);
4 | let applyF = (fn, x) => fn(x);
5 | let const = (x, _) => x;
6 | let compose = (f, g, x) => f(g(x));
7 | let pipe = (g, f, x) => f(g(x));
8 |
9 | module Infix = {
10 | let (>>) = pipe;
11 | let (<<) = compose;
12 | };
13 | };
14 |
15 | module Jsx = {
16 | open Webapi;
17 | open Function.Infix;
18 | let focus = (ref: ReactDOM.Ref.currentDomRef) =>
19 | ref.current
20 | ->Js.Nullable.toOption
21 | ->Belt.Option.map(
22 | Dom.Element.unsafeAsHtmlElement >> Dom.HtmlElement.focus,
23 | )
24 | ->ignore;
25 |
26 | module Infix = {
27 | let (<&&>) = (a, b) => a ? b : React.null;
28 | };
29 | };
30 |
31 | module Event = {
32 | let getValueFromEvent = event => ReactEvent.Synthetic.target(event)##value;
33 | module Keyboard = {
34 | external toKeyboardEvent: 'a => Webapi.Dom.KeyboardEvent.t = "%identity";
35 |
36 | let keyWas = (inputKey, e) =>
37 | e
38 | |> toKeyboardEvent
39 | |> Webapi.Dom.KeyboardEvent.key
40 | |> (eventKey => eventKey === inputKey);
41 | };
42 | };
43 |
--------------------------------------------------------------------------------
/src/Notifications.re:
--------------------------------------------------------------------------------
1 | open BsFlexboxgrid;
2 |
3 | module Styles = {
4 | open Css;
5 | let container =
6 | style([
7 | position(`fixed),
8 | width(100.0->vw),
9 | height(100.0->vh),
10 | left(`zero),
11 | top(`zero),
12 | pointerEvents(`none),
13 | ]);
14 | };
15 |
16 | [@react.component]
17 | let make =
18 | (
19 | ~theme,
20 | ~notifications: array(Notification.t),
21 | ~handleDismissal=_ => (),
22 | ~defaultAnimationTime=400,
23 | ~maxAmount=5,
24 | ) => {
25 |
26 |
27 |
28 | {notifications
29 | ->Belt.Array.reverse
30 | ->Belt.Array.slice(~offset=0, ~len=maxAmount)
31 | ->Belt.Array.reverse
32 | ->Belt.Array.map(notification => {
33 |
40 | })
41 | ->React.array}
42 |
43 |
44 |
;
45 | };
46 |
--------------------------------------------------------------------------------
/src/Loader.re:
--------------------------------------------------------------------------------
1 | open Css;
2 | let pulse =
3 | keyframes([
4 | (0, [opacity(0.1)]),
5 | (60, [opacity(0.5)]),
6 | (100, [opacity(0.1)]),
7 | ]);
8 |
9 | let loaderStyles =
10 | style([
11 | display(`flex),
12 | alignSelf(`center),
13 | justifyContent(`center),
14 | height(100.0->pct),
15 | width(100.0->pct),
16 | selector(
17 | "& circle",
18 | [
19 | opacity(0.1),
20 | animationName(pulse),
21 | animationDuration(1200),
22 | animationTimingFunction(`easeInOut),
23 | animationIterationCount(`infinite),
24 | ],
25 | ),
26 | selector("& circle:nth-of-type(1)", [animationDelay(100)]),
27 | selector("& circle:nth-of-type(2)", [animationDelay(200)]),
28 | ]);
29 |
30 | type background =
31 | | Dark
32 | | Light;
33 |
34 | [@react.component]
35 | let make = (~theme=Config.defaultTheme, ~className="") => {
36 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
37 |
38 |
39 | Css_AtomicTypes.Color.toString}
41 | size=40
42 | />
43 |
;
44 | };
45 |
46 | let default = make;
47 |
--------------------------------------------------------------------------------
/stories/loader.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
3 | import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism";
4 | import { useDarkMode } from "storybook-dark-mode";
5 | import { make as Loader } from "../src/Loader.bs.js";
6 | import { make as Card } from "../src/Card.bs.js";
7 | import { light, dark } from "../src/UiTypes.bs";
8 | export default {
9 | title: "Loader",
10 | };
11 |
12 | const margin = {
13 | margin: "1rem",
14 | };
15 | export const Loaders = () => {
16 | const theme = useDarkMode() ? dark : light;
17 | return (
18 | <>
19 |
20 |
21 | Loader
22 | Interface
23 |
24 | {`type Loader: (
25 | ~className: option(string), /* Defaults to empty string */
26 | ~theme: option(UiTypes.theme)
27 | ) => React.element;
28 | `}
29 |
30 |
31 | Preview
32 |
33 |
34 |
35 |
36 |
37 | >
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/Helpers/GenericHelpers.re:
--------------------------------------------------------------------------------
1 | open UiTypes;
2 |
3 | /*
4 | * Bound a value within its upper and lower bounds. Works for int / float
5 | */
6 | let bound = (lowerBound, upperBound, value) =>
7 | switch (value) {
8 | | value when value > upperBound => upperBound
9 | | value when value < lowerBound => lowerBound
10 | | _ => value
11 | };
12 |
13 | /*
14 | * This function is used to add or subtract a percentate from any color
15 | * value. We take the percentage relative to 255 (max rgb value for any
16 | * given channel) and either add or subtract it.
17 | */
18 | let operatePercent = (percentage, operation, value) => {
19 | let amount = 255 * percentage / 100;
20 | switch (operation) {
21 | | Add => value + amount
22 | | Subtract => value - amount
23 | };
24 | };
25 |
26 | /*
27 | * When not providing an id for inputs with labels, we would still need to generate one.
28 | * We do this by re-using the labelstring in this case. There could be duplicates but this
29 | * will be caught by React and give a console output error. However, in 90% of the cases,
30 | * falling back to the label string will be just fine.
31 | */
32 | let genIdentifier = (id, label) =>
33 | switch (id, label) {
34 | | (Some(idString), _) => idString
35 | | (None, Labeled(labelString)) => labelString
36 | | (None, Unlabeled) => ""
37 | };
38 |
39 | /*
40 | * To aid the creation of random id's, we use UUID's v4 fn.
41 | */
42 | [@bs.module "uuid"] [@bs.val] external v4: unit => string = "v4";
43 |
--------------------------------------------------------------------------------
/src/Input.re:
--------------------------------------------------------------------------------
1 | open UiTypes;
2 | open InputStyles;
3 |
4 | [@react.component]
5 | let make =
6 | (
7 | ~_type="text",
8 | ~defaultValue="",
9 | ~className="",
10 | ~value=?,
11 | ~disabled=false,
12 | ~label=Unlabeled,
13 | ~id=?,
14 | ~validity=Valid,
15 | ~variant=Normal,
16 | ~width=[@reason.preserve_braces] 100.0,
17 | ~theme=Config.defaultTheme,
18 | ~placeholder="",
19 | ~onChange=?,
20 | ~onKeyDown=?,
21 | ~onBlur=?,
22 | ) => {
23 | let identifier = GenericHelpers.genIdentifier(id, label);
24 |
25 |
26 |
27 | {switch (value) {
28 | | Some(value) =>
29 |
42 | | None =>
43 |
56 | }}
57 |
;
58 | };
59 |
--------------------------------------------------------------------------------
/stories/typography.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
3 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
4 | import { useDarkMode } from 'storybook-dark-mode'
5 | import { make as Card } from '../src/Card.bs.js'
6 | import { light, dark } from '../src/UiTypes.bs'
7 |
8 | export default {
9 | title: 'Typography',
10 | }
11 |
12 | const margin = {
13 | margin: '1rem',
14 | }
15 |
16 | export const typograph = () => {
17 | const theme = useDarkMode() ? dark : light
18 | return (
19 | <>
20 |
21 |
22 | Typography
23 | This is an h1 title
24 | This is an h2 title
25 | This is an h3 title
26 | This is an h4 title
27 | This is an h5 title
28 | This is an h6 title
29 | Here is the paragraph text
30 |
31 | I'm in an unordered list
32 | I'm in an unordered list
33 |
34 |
35 | I'm in an ordered list
36 | I'm in an ordered list
37 |
38 |
39 |
40 | >
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/COPYING:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020, Tenzir GmbH
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright
8 | notice, this list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in the
12 | documentation and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of the copyright holder nor the names of its
15 | contributors may be used to endorse or promote products derived from
16 | this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
22 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28 | POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/src/Textarea.re:
--------------------------------------------------------------------------------
1 | open UiTypes;
2 | open TextareaStyles;
3 |
4 | [@react.component]
5 | let make =
6 | (
7 | ~defaultValue="",
8 | ~value=?,
9 | ~label=Unlabeled,
10 | ~disabled=false,
11 | ~id=?,
12 | ~variant=Normal,
13 | ~validity=Valid,
14 | ~width=[@reason.preserve_braces] 100.0,
15 | ~resize=NoResize,
16 | ~theme=Config.defaultTheme,
17 | ~placeholder="",
18 | ~onChange=?,
19 | ~onBlur=?,
20 | ~styles=[],
21 | ~rows=4,
22 | ~cols=50,
23 | ) => {
24 | let identifier = GenericHelpers.genIdentifier(id, label);
25 |
26 |
27 |
28 | {switch (value) {
29 | | Some(value) =>
30 |
48 | | None =>
49 |
67 | }}
68 |
;
69 | };
70 |
--------------------------------------------------------------------------------
/.github/workflows/ui-components.yml:
--------------------------------------------------------------------------------
1 | name: "UI Components"
2 | on:
3 | push:
4 | branches:
5 | - master
6 | pull_request:
7 | types: [opened, synchronize]
8 | release:
9 | types: published
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 | steps:
15 |
16 | - uses: actions/checkout@v2
17 | with:
18 | ref: ${{ github.ref }}
19 |
20 | - name: Install Dependencies
21 | run: yarn install --frozen-lockfile
22 |
23 | - name: Run Build
24 | run: yarn build
25 |
26 | - name: Run Tests
27 | run: yarn test
28 |
29 | - name: Build Storybook for GitHub Pages
30 | run: yarn deploy-storybook -- --dry-run --out=.out
31 |
32 | - name: Push Storybook Folder
33 | if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
34 | uses: JamesIves/github-pages-deploy-action@releases/v3
35 | with:
36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37 | ACCESS_TOKEN: ${{ secrets.REPO_SCOPE_ACCESS_TOKEN }}
38 | BRANCH: gh-pages
39 | FOLDER: .out
40 |
41 | npm-release:
42 | name: NPM Release
43 | runs-on: ubuntu-latest
44 | if: github.event.action == 'published'
45 | needs: build
46 | steps:
47 |
48 | - uses: actions/checkout@v2
49 | with:
50 | ref: ${{ github.ref }}
51 |
52 | - uses: actions/setup-node@v1
53 | with:
54 | always-auth: true
55 | registry-url: "https://registry.npmjs.org"
56 |
57 | - env:
58 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
59 | run: |
60 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
61 | yarn publish
62 |
--------------------------------------------------------------------------------
/__tests__/GenericHelpers_test.re:
--------------------------------------------------------------------------------
1 | open Jest;
2 | open Expect;
3 |
4 | describe("bound", () => {
5 | let lowerBound = -10;
6 | let upperBound = 10;
7 | let bounded = GenericHelpers.bound(lowerBound, upperBound);
8 |
9 | test("LowerBound", () =>
10 | expect(bounded(-100)) |> toBe(lowerBound))
11 | test("UpperBound", () =>
12 | expect(bounded(100)) |> toBe(upperBound))
13 | test("Within Bounds", () =>
14 | expect(bounded(0)) |> toBe(0))
15 |
16 | let lowerBoundFlt = -10.0;
17 | let upperBoundFlt = 10.0;
18 | let bounded = GenericHelpers.bound(lowerBoundFlt, upperBoundFlt);
19 |
20 | test("LowerBound Float", () =>
21 | expect(bounded(-100.0)) |> toBe(lowerBoundFlt))
22 | test("UpperBound Float", () =>
23 | expect(bounded(100.0)) |> toBe(upperBoundFlt))
24 | test("Within Bounds Float", () =>
25 | expect(bounded(0.0)) |> toBe(0.0))
26 | });
27 |
28 | describe("operatePercent", () => {
29 | let percentage = 10;
30 | let baseValue = 50;
31 |
32 | test("Add", () =>
33 | expect(GenericHelpers.operatePercent(percentage, UiTypes.Add, baseValue)) |> toBe(75))
34 |
35 | test("Subtract", () =>
36 | expect(GenericHelpers.operatePercent(percentage, UiTypes.Subtract, baseValue)) |> toBe(25))
37 | });
38 |
39 | describe("genIdentifier", () => {
40 | open UiTypes;
41 |
42 | test("Opt id over label", () =>
43 | expect(GenericHelpers.genIdentifier(Some("id"), Labeled("label"))) |> toBe("id"))
44 |
45 | test("Fall back to label if no id", () =>
46 | expect(GenericHelpers.genIdentifier(None, Labeled("label"))) |> toBe("label"))
47 |
48 | test("Fall back to empty string if no id and no label", () =>
49 | expect(GenericHelpers.genIdentifier(None, Unlabeled)) |> toBe(""))
50 | });
51 |
--------------------------------------------------------------------------------
/src/Config.re:
--------------------------------------------------------------------------------
1 | open Css_AtomicTypes;
2 | open UiTypes;
3 |
4 | let defaultTheme = UiTypes.Light;
5 |
6 | type colors = {
7 | background: Color.t,
8 | darkBlueBg: Color.t,
9 | primary: Color.t,
10 | secondary: Color.t,
11 | success: Color.t,
12 | warning: Color.t,
13 | danger: Color.t,
14 | input: Color.t,
15 | font: Color.t,
16 | black: Color.t,
17 | white: Color.t,
18 | };
19 |
20 | module Colors = {
21 | let hoverChangeType = Lighten;
22 | let hoverChangePercent = 8;
23 | let activeChangeType = Darken;
24 | let activeChangePercent = 5;
25 | let light = {
26 | background: `rgb((254, 255, 255)),
27 | darkBlueBg: `rgba((0, 51, 77, `num(1.0))),
28 | primary: `rgb((0, 164, 241)),
29 | secondary: `rgb((189, 207, 219)),
30 | success: `rgb((154, 248, 0)),
31 | warning: `rgb((248, 237, 0)),
32 | danger: `rgb((248, 65, 10)),
33 | input: `rgb((34, 34, 34)),
34 | font: `rgb((34, 34, 34)),
35 | black: `rgb((34, 34, 34)),
36 | white: `rgb((238, 238, 238)),
37 | };
38 |
39 | let dark = {
40 | ...light,
41 | background: `rgb((34, 38, 38)),
42 | font: `rgb((238, 238, 238)),
43 | };
44 |
45 | let tenzirBlue = {
46 | ...light,
47 | background: `rgba((0, 51, 77, `num(1.0))),
48 | font: `rgb((238, 238, 238)),
49 | };
50 | };
51 |
52 | module Typography = {
53 | open Length;
54 | let family_default = "'Source Sans Pro', sans-serif;";
55 | let family_mono = "'Source Code Pro', monospace;";
56 | let size = 15->px;
57 | let size_label = 14->px;
58 | let weight_default = `normal;
59 | let weight_button = `semiBold;
60 | let weight_mono = `semiBold;
61 | };
62 |
63 | module Misc = {
64 | open Css;
65 | let baseXPadding = 1.0->rem;
66 | let baseYPadding = 0.5->rem;
67 | let borderRadius = 0.2->rem;
68 | let cardDarkeningPct = 4;
69 | let baseTransitions = [
70 | transitionProperty("all"),
71 | transitionDuration(200),
72 | transitionTimingFunction(`easeInOut),
73 | ];
74 | };
75 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | This changelog documents all notable user-facing changes of the Tenzir
4 | Component Library.
5 |
6 | Every entry has a category for which we use the following visual abbreviations:
7 |
8 | - 🎁 Feature
9 | - 🧬 Experimental Feature
10 | - ⚠️ Change
11 | - ⚡️ Breaking Change
12 | - 🐞 Bugfix
13 |
14 | ## [0.12.0]
15 | - 🎁 Tabs [#81](https://github.com/tenzir/ui-component-library/pull/81)
16 | We now have a tabs, and tabbed card component. They have built-in support for
17 | drag'n'drop replacing, updating, adding, duplicating and removing. Depending
18 | on which callback functions are supplied they're activated.
19 |
20 | ## [0.10.0]
21 | - 🎁 Sizing [#71](https://github.com/tenzir/ui-component-library/pull/71)
22 | We now support sizes (`small` / `medium` / `large`) for buttons and alerts
23 |
24 | ## [0.9.0]
25 | - 🎁 Generalized Segments [#67](https://github.com/tenzir/ui-component-library/pull/67)
26 | Instead of using strings as identifiers, the segments now use a generic type.
27 | Making them type-safe using variants is now possible.
28 |
29 | - ⚠️ Better font scaling [#67](https://github.com/tenzir/ui-component-library/pull/67)
30 | Font-scaling was previously done using css transforms. But that broke when using
31 | it in button-groups. By using paddings, we can still use different sizes,
32 | but they no longer break in groups.
33 |
34 | ## [0.8.1]
35 | - 🐞 Tooltip Hover [#60](https://github.com/tenzir/ui-component-library/pull/60)
36 | The tooltip was included in the hover's bounding box of the element. The
37 | `pointer-events` are now disabled for hover events until they become visible,
38 | meaning it can only be activated by hovering the element upon which the tooltip
39 | is introduced, not the tooltip itself.
40 |
41 | ## [0.8.0]
42 | - 🧬 Tooltips [#59](https://github.com/tenzir/ui-component-library/pull/59)
43 | We've used a CSS only approach for tooltips that is reasonably flexible in terms
44 | of positioning. We'll test this internally before releasing it publicly.
45 |
--------------------------------------------------------------------------------
/src/CheckboxStyles.re:
--------------------------------------------------------------------------------
1 | open Css;
2 | open Config;
3 | open UiTypes;
4 |
5 | let labelFontColor = (colors: colors, componentState, ~validity=Valid, ()) =>
6 | switch (componentState, validity) {
7 | | (Hovering, Invalid) => StyleHelpers.lighten(20, colors.danger)
8 | | (_, Invalid) => colors.danger
9 | | (Hovering, Valid) => StyleHelpers.lighten(20, colors.font)
10 | | (_, Valid) => colors.font
11 | };
12 |
13 | let inputFontColor = (colors: colors, componentState) =>
14 | switch (componentState) {
15 | | Hovering => StyleHelpers.lighten(20, colors.input)
16 | | _ => colors.input
17 | };
18 |
19 | let inputContainerStyles = (~pctWidth=100.0, ~disabled, ()) =>
20 | style([
21 | width(pctWidth->pct),
22 | paddingTop(0.7->rem),
23 | display(`inlineBlock),
24 | position(`relative),
25 | opacity(disabled ? 0.5 : 1.0),
26 | selector(
27 | "& input, & label",
28 | [cursor(disabled ? `notAllowed : `default)],
29 | ),
30 | hover([cursor(disabled ? `notAllowed : `default)]),
31 | ]);
32 |
33 | let checkboxStyles = (~theme, ()) => {
34 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
35 | style([
36 | display(`inline),
37 | borderStyle(`solid),
38 | borderWidth(2->px),
39 | borderColor(colors.danger),
40 | outlineStyle(`none),
41 | color(inputFontColor(colors, Base)),
42 | fontFamily(`custom(Typography.family_default)),
43 | fontWeight(Typography.weight_default),
44 | fontSize(Typography.size),
45 | borderRadius(Misc.borderRadius),
46 | paddingLeft(1.1->rem),
47 | paddingRight(1.1->rem),
48 | paddingTop(0.6->rem),
49 | paddingBottom(0.6->rem),
50 | marginRight(0.6->rem),
51 | ...Misc.baseTransitions,
52 | ]);
53 | };
54 |
55 | let labelStyles = (~theme, ~validity, ()) => {
56 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
57 | style([
58 | color(labelFontColor(colors, Base, ~validity, ())),
59 | fontFamily(`custom(Typography.family_default)),
60 | fontWeight(Typography.weight_default),
61 | fontSize(Typography.size_label),
62 | paddingLeft(0.6->rem),
63 | paddingRight(0.6->rem),
64 | paddingTop(0.6->rem),
65 | paddingBottom(0.6->rem),
66 | ...Misc.baseTransitions,
67 | ]);
68 | };
69 |
--------------------------------------------------------------------------------
/src/Card.re:
--------------------------------------------------------------------------------
1 | open CardStyles;
2 |
3 | [@react.component]
4 | let make =
5 | (
6 | ~onMouseOver=?,
7 | ~onMouseOut=?,
8 | ~spacing=Normal,
9 | ~theme=Config.defaultTheme,
10 | ~depth=1,
11 | ~className="",
12 | ~header=?,
13 | ~footer=?,
14 | ~children= ,
15 | ) => {
16 |
20 | {header->Belt.Option.mapWithDefault(
, header =>
21 |
header
22 | )}
23 |
Belt.Option.isSome,
26 | ~hasFooter=footer->Belt.Option.isSome,
27 | )}>
28 | children
29 |
30 | {footer->Belt.Option.mapWithDefault(
, footer =>
31 |
footer
32 | )}
33 |
;
34 | };
35 |
36 | module Tabbed = {
37 | [@react.component]
38 | let make =
39 | (
40 | ~onMouseOver=?,
41 | ~onMouseOut=?,
42 | ~spacing=Normal,
43 | ~theme=Config.defaultTheme,
44 | ~depth=1,
45 | ~className="",
46 | ~footer=?,
47 | ~children= ,
48 | ~activeTabId: string,
49 | ~tabs: array(Tabs.t),
50 | ~addButtonText,
51 | ~onAdd=?,
52 | ~onMove=?,
53 | ~onOpen=?,
54 | ~onClose=?,
55 | ~onDuplicate=?,
56 | ~onRename=?,
57 | ) => {
58 |
64 |
78 |
Belt.Option.isSome,
82 | )}>
83 | children
84 |
85 | {footer->Belt.Option.mapWithDefault(
, footer =>
86 |
footer
87 | )}
88 |
;
89 | };
90 | };
91 |
--------------------------------------------------------------------------------
/stories/alert.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
3 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
4 | import { useDarkMode } from 'storybook-dark-mode'
5 | import { make as Alert } from '../src/Alert.bs.js'
6 | import { FiActivity as Activity } from 'react-icons/fi'
7 | import { make as Card } from '../src/Card.bs.js'
8 | import { small, medium, large, light, dark } from '../src/UiTypes.bs'
9 | import {
10 | primary,
11 | secondary,
12 | success,
13 | warning,
14 | danger,
15 | } from '../src/AlertStyles.bs'
16 |
17 | export default {
18 | title: 'Alert',
19 | }
20 |
21 | const margin = {
22 | margin: '1rem',
23 | }
24 |
25 | export const Alerts = () => {
26 | const theme = useDarkMode() ? dark : light
27 | return (
28 |
29 |
30 | Alerts
31 | Interface
32 |
37 | {`type Alert: (
38 | ~message: option(string),
39 | ~children: option(React.Element), /* Note that both message and children can be passed in at once */
40 | ~variant: AlertStyles.variant,
41 | ~size: UiTypes.size,
42 | ~className: option(string),
43 | ~theme: option(UiTypes.theme)
44 | ) => React.element;
45 | `}
46 |
47 |
48 | Preview
49 |
50 |
54 |
55 |
56 |
57 |
58 | Sizes
59 |
60 |
61 |
62 |
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/src/CardStyles.re:
--------------------------------------------------------------------------------
1 | open Css;
2 | open Config;
3 |
4 | [@bs.deriving accessors]
5 | type spacing =
6 | | Tiny
7 | | Normal
8 | | Large
9 | | Huge;
10 |
11 | let deteriminePadding = spacing => {
12 | let baseBadding = 1.0;
13 | switch (spacing) {
14 | | Tiny =>
15 | padding2(~h=(baseBadding *. 0.8)->rem, ~v=(baseBadding *. 0.6)->rem)
16 | | Normal =>
17 | padding2(~h=(baseBadding *. 1.2)->rem, ~v=(baseBadding *. 1.1)->rem)
18 | | Large =>
19 | padding2(~h=(baseBadding *. 2.2)->rem, ~v=(baseBadding *. 1.6)->rem)
20 | | Huge =>
21 | padding2(~h=(baseBadding *. 3.2)->rem, ~v=(baseBadding *. 2.6)->rem)
22 | };
23 | };
24 |
25 | let card = (~theme, ~spacing=Normal, ~depth, ()) => {
26 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
27 | style([
28 | width(100.0->pct),
29 | height(100.0->pct),
30 | overflow(`hidden),
31 | backgroundColor(
32 | StyleHelpers.offsetBgColor(theme, depth, colors.background),
33 | ),
34 | color(colors.font),
35 | fontFamily(`custom(Typography.family_default)),
36 | fontWeight(Typography.weight_default),
37 | fontSize(Typography.size),
38 | borderRadius(Misc.borderRadius),
39 | deteriminePadding(spacing),
40 | position(`relative),
41 | ]);
42 | };
43 |
44 | let cardHeader = (~theme, ~depth, ()) => {
45 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
46 | style([
47 | height(3.5->rem),
48 | position(`absolute),
49 | left(`zero),
50 | top(`zero),
51 | display(`flex),
52 | alignItems(`center),
53 | padding2(~h=0.0->rem, ~v=0.0->rem),
54 | width(100.0->pct),
55 | backgroundColor(
56 | StyleHelpers.offsetBgColor(theme, depth + 1, colors.background)
57 | ->StyleHelpers.rgbWithAlpha(0.5),
58 | ),
59 | ]);
60 | };
61 |
62 | let cardContent = (~hasHeader, ~hasFooter) => {
63 | style([
64 | paddingTop(hasHeader ? 3.5->rem : 0.0->rem),
65 | paddingBottom(hasFooter ? 3.5->rem : 0.0->rem),
66 | width(100.0->pct),
67 | height(100.0->pct),
68 | ]);
69 | };
70 |
71 | let cardFooter = (~theme, ~depth, ()) => {
72 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
73 | style([
74 | height(3.5->rem),
75 | position(`absolute),
76 | left(`zero),
77 | bottom(`zero),
78 | display(`flex),
79 | alignItems(`center),
80 | padding2(~h=0.0->rem, ~v=0.0->rem),
81 | width(100.0->pct),
82 | backgroundColor(
83 | StyleHelpers.offsetBgColor(theme, depth + 1, colors.background)
84 | ->StyleHelpers.rgbWithAlpha(0.5),
85 | ),
86 | ]);
87 | };
88 |
--------------------------------------------------------------------------------
/src/Segment.re:
--------------------------------------------------------------------------------
1 | open BsFlexboxgrid;
2 |
3 | type t('a) = {
4 | disabled: bool,
5 | id: 'a,
6 | title: string,
7 | };
8 |
9 | module Styles = {
10 | open Css;
11 | let selectorContainer = theme => {
12 | let colors = theme->StyleHelpers.colorsFromThemeVariant;
13 | style([
14 | backgroundColor(
15 | StyleHelpers.offsetBgColor(
16 | theme,
17 | 2,
18 | StyleHelpers.rgbWithAlpha(colors.background, 0.8),
19 | ),
20 | ),
21 | padding2(~h=0.2->rem, ~v=0.25->rem),
22 | margin2(~h=`zero, ~v=0.6->rem),
23 | borderRadius(Config.Misc.borderRadius),
24 | ]);
25 | };
26 |
27 | let selectorButton = (theme, disabled, isActive) => {
28 | let colors = theme->StyleHelpers.colorsFromThemeVariant;
29 | style([
30 | backgroundColor(isActive ? colors.secondary : `transparent),
31 | color(
32 | isActive
33 | ? StyleHelpers.rgbWithAlpha(colors.black, 0.8)
34 | : StyleHelpers.rgbWithAlpha(colors.font, 0.8),
35 | ),
36 | textAlign(`center),
37 | padding2(~h=0.2->rem, ~v=0.25->rem),
38 | borderRadius(Config.Misc.borderRadius),
39 | cursor(disabled ? `notAllowed : `pointer),
40 | opacity(disabled ? 0.2 : 1.0),
41 | fontWeight(`semiBold),
42 | flexGrow(1.0),
43 | ...Config.Misc.baseTransitions,
44 | ]);
45 | };
46 | };
47 |
48 | [@react.component]
49 | let make =
50 | (
51 | ~segments: array(t('a)),
52 | ~onSegmentUpdate: 'a => unit,
53 | ~default=?,
54 | ~theme=Config.defaultTheme,
55 | ) => {
56 | let (active: option('a), setActive) =
57 | React.useState(_ =>
58 | switch (default) {
59 | | Some(id) => id
60 | | None =>
61 | segments
62 | ->Belt.Array.keep(x => !x.disabled)
63 | ->Belt.Array.get(0)
64 | ->Belt.Option.map(x => x.id)
65 | }
66 | );
67 |
68 | React.useEffect1(
69 | () => {
70 | active->Belt.Option.map(onSegmentUpdate)->ignore;
71 | None;
72 | },
73 | [|active|],
74 | );
75 | <>
76 |
77 | {segments
78 | ->Belt.Array.map(segment => {
79 | let isActive =
80 | active
81 | ->Belt.Option.map(x => x === segment.id)
82 | ->Belt.Option.getWithDefault(false);
83 |
84 |
92 | segment.disabled ? () : setActive(_ => Some(segment.id))
93 | }>
94 | segment.title->React.string
95 | ;
96 | })
97 | ->React.array}
98 |
99 | >;
100 | };
101 |
--------------------------------------------------------------------------------
/stories/tooltip.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Examples } from '../src/Tooltip.bs'
3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
4 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
5 | import { useDarkMode } from 'storybook-dark-mode'
6 | import { FiActivity as Activity } from 'react-icons/fi'
7 | import { make as Card } from '../src/Card.bs.js'
8 | import { light, dark } from '../src/UiTypes.bs'
9 | import { top, right, bottom, left } from '../src/Tooltip.bs'
10 |
11 | const TooltipExamples = Examples.make
12 |
13 | export default {
14 | title: 'Tooltip',
15 | }
16 |
17 | const margin = {
18 | margin: '1rem',
19 | }
20 |
21 | export const Tooltips = () => {
22 | const theme = useDarkMode() ? dark : light
23 | return (
24 | <>
25 |
26 |
27 | Tooltip
28 | Interface
29 |
34 | {`type tooltip: (
35 | ~theme: option(UiTypes.theme)), // Defaults to UiTypes.light
36 | ~position: option(Tooltip.tPosition)), // Defaults to Top(Center)
37 | value: string
38 | )=> string;`}
39 |
40 |
41 |
42 | As opposed to most elements, this is not a component,
43 | but a css class generator. Based on the options above,
44 | we generate a tooltip that is placed on the "after"
45 | attribute of the element. This means we're not adding
46 | additional dom. The interface for positioning looks as
47 | follows:
48 |
49 |
50 |
55 | {`type horizontal =
56 | | Left
57 | | Center
58 | | Right;
59 |
60 | type vertical =
61 | | Top
62 | | Middle
63 | | Bottom;
64 |
65 | type tPosition =
66 | | Top(horizontal)
67 | | Bottom(horizontal)
68 | | Left(vertical)
69 | | Right(vertical);
70 |
71 | // For instance,
72 | // Adding a tooltip to the top left or bottom right of an element, would be:
73 |
84 |
85 | Previews
86 |
87 |
88 |
89 | >
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/src/Helpers/StyleHelpers.re:
--------------------------------------------------------------------------------
1 | open UiTypes;
2 | open Config;
3 | open GenericHelpers;
4 |
5 | /* Converts `rgb to `rgba */
6 | let rgbWithAlpha = (rgb, alpha) =>
7 | switch (rgb) {
8 | | `rgb(r, g, b) => `rgba((r, g, b, `num(alpha)))
9 | | x => x
10 | };
11 |
12 | /* Map over r,g,b, return tuple instead of List */
13 | let mapRGB = (fn, r, g, b) =>
14 | switch ([r, g, b]->Belt.List.map(fn)) {
15 | | [nR, nG, nB] => (nR, nG, nB)
16 | | _ => (r, g, b)
17 | };
18 |
19 | /* Add or subtract percentage of 255 from color */
20 | let applyPercentageToColor =
21 | (changeType, percentage, color: Css_AtomicTypes.Color.t) => {
22 | let operation =
23 | switch (changeType) {
24 | | Lighten => operatePercent(percentage, Add)
25 | | Darken => operatePercent(percentage, Subtract)
26 | };
27 |
28 | let change = value => operation(value) |> bound(0, 255);
29 |
30 | switch (color) {
31 | | `rgb(r, g, b) => `rgb(mapRGB(change, r, g, b))
32 | | `rgba(r, g, b, a) =>
33 | let (nR, nG, nB) = mapRGB(change, r, g, b);
34 | `rgba((nR, nG, nB, a));
35 | | _ => color
36 | };
37 | };
38 |
39 | /* Partially applied to specifically lighten or darken a color */
40 | let lighten = applyPercentageToColor(Lighten);
41 | let darken = applyPercentageToColor(Darken);
42 |
43 | let offsetBgColorFlt = (theme, depth: float, color) => {
44 | (
45 | switch (theme) {
46 | | UiTypes.Dark
47 | | UiTypes.TenzirBlue =>
48 | float_of_int(Misc.cardDarkeningPct) *. (depth *. (-1.) -. 1.)
49 | | _ => float_of_int(Misc.cardDarkeningPct) *. depth
50 | }
51 | )
52 | ->int_of_float
53 | ->darken(color);
54 | };
55 |
56 | let offsetBgColor = (theme, depth, color) => {
57 | (
58 | switch (theme) {
59 | | UiTypes.Dark
60 | | UiTypes.TenzirBlue => Misc.cardDarkeningPct * (depth * (-1) - 1)
61 | | _ => Misc.cardDarkeningPct * depth
62 | }
63 | )
64 | ->darken(color);
65 | };
66 |
67 | let hoverColorChange =
68 | switch (Colors.hoverChangeType) {
69 | | Lighten => lighten(Colors.hoverChangePercent)
70 | | Darken => darken(Colors.hoverChangePercent)
71 | };
72 |
73 | let activeColorChange =
74 | switch (Colors.activeChangeType) {
75 | | Lighten => lighten(Colors.activeChangePercent)
76 | | Darken => darken(Colors.activeChangePercent)
77 | };
78 |
79 | let colorsFromThemeVariant = (themeVariant: UiTypes.theme) =>
80 | switch (themeVariant) {
81 | | Light => Colors.light
82 | | Dark => Colors.dark
83 | | TenzirBlue => Colors.tenzirBlue
84 | };
85 |
86 | let adjustForSize = (size, value) =>
87 | switch (size) {
88 | | Small => 0.6 *. value
89 | | Medium => 1.0 *. value
90 | | Large => 1.2 *. value
91 | };
92 |
93 | let adjustForSizeP4 =
94 | Css.(
95 | (~size, ~left, ~right, ~top, ~bottom) => {
96 | padding4(
97 | ~left=adjustForSize(size, left)->rem,
98 | ~right=adjustForSize(size, right)->rem,
99 | ~top=adjustForSize(size, top)->rem,
100 | ~bottom=adjustForSize(size, bottom)->rem,
101 | );
102 | }
103 | );
104 |
--------------------------------------------------------------------------------
/stories/checkbox.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
3 | import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism";
4 | import { useDarkMode } from "storybook-dark-mode";
5 | import { make as Checkbox } from "../src/Checkbox.bs.js";
6 | import { make as Card } from "../src/Card.bs.js";
7 | import { light, dark, normal, mono, invalid } from "../src/UiTypes.bs";
8 | import { tiny, huge } from "../src/CardStyles.bs";
9 |
10 | export default {
11 | title: "Checkbox",
12 | };
13 |
14 | const margin = {
15 | margin: "1rem",
16 | };
17 |
18 | export const checkbox = () => {
19 | const theme = useDarkMode() ? dark : light;
20 | const [val, setVal] = React.useState({
21 | checkbox1: false,
22 | checkbox2: false,
23 | checkbox3: false,
24 | });
25 | return (
26 | <>
27 |
28 |
29 | Cards
30 | Interface
31 |
32 | {`type Card: (
33 | ~defaultValue: option(bool), /* Defaults to false */
34 | ~disabled: option(bool), /* Defaults to false */
35 | ~label: string,
36 | ~id: option(string), /* Id's have a fallback to the label, but possible collisions may occur if no id is presented */
37 | ~width: option(float), /* as a percentage, defaults to 100.0 */
38 | ~theme: option(UiTypes.theme),
39 | ~placeholder: option(string), /* defaults to empty string */
40 | ~validity: UiTypes.validation, /* defaults to UiTypes.Valid */
41 | ~onChange=option(React.SyntheticEvent.t->unit),
42 | ) => React.element;`}
43 |
44 |
45 | Preview
46 |
47 | setVal({ ...val, checkbox1: !val.checkbox1 })}
54 | />
55 | setVal({ ...val, checkbox2: !val.checkbox2 })}
63 | />
64 | setVal({ ...val, checkbox3: !val.checkbox3 })}
71 | />
72 |
79 |
80 |
81 |
82 | >
83 | );
84 | };
85 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tenzir-ui-component-library",
3 | "author": "Tenzir",
4 | "version": "0.12.1",
5 | "scripts": {
6 | "build:reason": "bsb -clean-world && bsb -make-world",
7 | "build:storybook": "build-storybook",
8 | "build": "yarn build:reason && yarn build:storybook",
9 | "clean": "bsb -clean-world",
10 | "start": "bsb -make-world -w",
11 | "server": "start-storybook -p 6006",
12 | "deploy-storybook": "yarn build:reason && storybook-to-ghpages",
13 | "test": "yarn clean && yarn build:reason && jest",
14 | "tdd": "jest --watchAll"
15 | },
16 | "lint-staged": {
17 | "*.js": ["prettier --print-width 80 --write"],
18 | "*.{re,rei}": ["bsrefmt --in-place"]
19 | },
20 | "keywords": [
21 | "UI-Library",
22 | "UI-Components",
23 | "ReasonML",
24 | "ReasonReact",
25 | "Reason-React"
26 | ],
27 | "license": "BSD-3-Clause",
28 | "peerDependencies": {
29 | "bs-platform": "7 | 8 | 9",
30 | "react": "17",
31 | "react-dom": "17",
32 | "reason-react": ">=0.9.1"
33 | },
34 | "dependencies": {
35 | "bs-css-emotion": "^3.0.0",
36 | "bs-flexboxgrid": "^3.0.0",
37 | "bs-webapi": "^0.19.1",
38 | "react-beautiful-dnd": "^13.1.0",
39 | "react-icons": "^4.2.0",
40 | "react-syntax-highlighter": "^15.4.4",
41 | "storybook-dark-mode": "^1.0.8"
42 | },
43 | "devDependencies": {
44 | "@babel/core": "^7.14.3",
45 | "@glennsl/bs-jest": "^0.7.0",
46 | "@storybook/addon-actions": "^6.3.6",
47 | "@storybook/addon-links": "^6.3.6",
48 | "@storybook/addon-postcss": "^2.0.0",
49 | "@storybook/addons": "^6.3.6",
50 | "@storybook/api": "^6.3.6",
51 | "@storybook/components": "^6.3.6",
52 | "@storybook/core": "^6.3.6",
53 | "@storybook/core-events": "^6.3.6",
54 | "@storybook/react": "^6.3.6",
55 | "@storybook/storybook-deployer": "^2.8.10",
56 | "@storybook/theming": "^6.3.6",
57 | "autoprefixer": "^10.3.1",
58 | "babel-loader": "^8.2.2",
59 | "bs-platform": "^9.0.2",
60 | "html-webpack-plugin": "^5.3.2",
61 | "husky": "^4.3.8",
62 | "lint-staged": "^10.4.2",
63 | "postcss-flexbugs-fixes": "^5.0.2",
64 | "prettier": "^2.3.2",
65 | "react": "17.0.2",
66 | "react-dom": "17.0.2",
67 | "react-scripts": "^4.0.3",
68 | "reason-react": ">=0.9.1",
69 | "regenerator-runtime": "^0.13.9"
70 | },
71 | "jest": {
72 | "transformIgnorePatterns": [
73 | "node_modules/(?!(@glennsl|bs-platform|bs-css)/)"
74 | ]
75 | },
76 | "resolutions": {
77 | "trim": ">=0.0.3",
78 | "tar": ">=6.1.11",
79 | "prismjs": ">=1.24.0",
80 | "glob-parent": ">=5.1.2",
81 | "postcss": ">=8.2.10",
82 | "browserslist": ">=4.16.5",
83 | "ws": ">=7.4.6",
84 | "path-parse": "1.0.7"
85 | },
86 | "husky": {
87 | "hooks": {
88 | "pre-commit": "lint-staged"
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/Dnd.re:
--------------------------------------------------------------------------------
1 | module OpaqueTypes = {
2 | type draggableProps;
3 | type dragHandleProps;
4 | type provided = {
5 | innerRef: ReactDOM.Ref.t,
6 | placeholder: React.element,
7 | draggableProps,
8 | dragHandleProps,
9 | };
10 | };
11 |
12 | module Context = {
13 | type dragItem = {
14 | droppableId: string,
15 | index: int,
16 | };
17 | type dragEndEvent = {
18 | destination: Js.Nullable.t(dragItem),
19 | source: dragItem,
20 | /*combine: Js.Nullable.t(combine),*/
21 | /*draggableId: string,*/
22 | /*mode: string,*/
23 | /*reason: string,*/
24 | /*type_: string*/
25 | };
26 | type dragStartEvent = {
27 | source: dragItem,
28 | /*combine: Js.Nullable.t(combine),*/
29 | /*draggableId: string,*/
30 | /*mode: string,*/
31 | /*reason: string,*/
32 | /*type_: string*/
33 | };
34 |
35 | [@bs.module "react-beautiful-dnd"] [@react.component]
36 | external make:
37 | (
38 | ~onDragStart: dragStartEvent => unit=?,
39 | ~onDragUpdate: ReactEvent.Synthetic.t => unit=?,
40 | ~onDragEnd: dragEndEvent => unit,
41 | ~children: React.element=?
42 | ) =>
43 | React.element =
44 | "DragDropContext";
45 | };
46 |
47 | module Provided = {
48 | let augmentChildren: (OpaqueTypes.provided, React.element) => React.element = [%bs.raw
49 | {|
50 | function (provided, children) {
51 | return {
52 | ...children,
53 | ref: provided.innerRef,
54 | props: {
55 | ...children.props,
56 | ...provided.draggableProps,
57 | ...provided.dragHandleProps,
58 | }
59 | };
60 | }
61 | |}
62 | ];
63 | };
64 |
65 | module Draggable = {
66 | module Wrapper = {
67 | [@bs.module "react-beautiful-dnd"] [@react.component]
68 | external make:
69 | (
70 | ~isDragDisabled: bool=?,
71 | ~draggableId: string,
72 | ~index: int,
73 | ~children: OpaqueTypes.provided => React.element=?
74 | ) =>
75 | React.element =
76 | "Draggable";
77 | };
78 |
79 | [@react.component]
80 | let make = (~draggableId, ~index, ~isDragDisabled, ~children) => {
81 |
82 | {(provided: OpaqueTypes.provided) => {
83 | <>
84 | {Provided.augmentChildren(provided, children)}
85 | {provided.placeholder}
86 | >;
87 | }}
88 | ;
89 | };
90 | };
91 |
92 | module Droppable = {
93 | type direction = [ | `vertical | `horizontal];
94 |
95 | module Wrapper = {
96 | [@bs.module "react-beautiful-dnd"] [@react.component]
97 | external make:
98 | (
99 | ~droppableId: string,
100 | ~direction: direction=?,
101 | ~children: OpaqueTypes.provided => React.element=?
102 | ) =>
103 | React.element =
104 | "Droppable";
105 | };
106 |
107 | [@react.component]
108 | let make = (~droppableId, ~direction, ~children) => {
109 |
110 | {(provided: OpaqueTypes.provided) => {
111 | <>
112 | {Provided.augmentChildren(provided, children)}
113 | {provided.placeholder}
114 | >;
115 | }}
116 | ;
117 | };
118 | };
119 |
--------------------------------------------------------------------------------
/src/InputStyles.re:
--------------------------------------------------------------------------------
1 | open Css;
2 | open Config;
3 | open UiTypes;
4 |
5 | let inputFontWeight = variant =>
6 | switch (variant) {
7 | | Normal => Typography.weight_default
8 | | Mono => Typography.weight_mono
9 | };
10 |
11 | let inputFontFamily = variant => {
12 | let font =
13 | switch (variant) {
14 | | Normal => Typography.family_default
15 | | Mono => Typography.family_mono
16 | };
17 | `custom(font);
18 | };
19 |
20 | let labelFontColor = (colors: colors, componentState) =>
21 | switch (componentState) {
22 | | Hovering => StyleHelpers.lighten(20, colors.font)
23 | | _ => colors.font
24 | };
25 |
26 | let inputFontColor = (colors: colors, componentState) =>
27 | switch (componentState) {
28 | | Hovering => StyleHelpers.lighten(20, colors.input)
29 | | _ => colors.input
30 | };
31 |
32 | let inputBordercolor = (colors: colors, componentState, ~validity=Valid, ()) =>
33 | switch (componentState, validity) {
34 | | (Base, Invalid) => colors.danger
35 | | (Hovering, Invalid) => StyleHelpers.lighten(25, colors.danger)
36 | | (Active, Invalid)
37 | | (Focus, Invalid) => StyleHelpers.lighten(8, colors.danger)
38 | | (Base, Valid) => StyleHelpers.lighten(80, colors.input)
39 | | (Hovering, Valid) => StyleHelpers.lighten(50, colors.input)
40 | | (Active, Valid)
41 | | (Focus, Valid) => StyleHelpers.lighten(40, colors.input)
42 | };
43 |
44 | let inputContainerStyles = (~pctWidth=100.0, ~label=Unlabeled, ()) =>
45 | style([
46 | width(pctWidth->pct),
47 | paddingTop(
48 | switch (label) {
49 | | Labeled(_) => 0.7->rem
50 | | Unlabeled => 0.0->rem
51 | },
52 | ),
53 | display(`inlineBlock),
54 | position(`relative),
55 | ]);
56 |
57 | let inputStyles = (~theme, ~validity, ~variant=Normal, ()) => {
58 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
59 | style([
60 | display(`inlineBlock),
61 | width(100.0->pct),
62 | borderStyle(`solid),
63 | borderWidth(2->px),
64 | borderColor(inputBordercolor(colors, Base, ~validity, ())),
65 | outlineStyle(`none),
66 | color(inputFontColor(colors, Base)),
67 | fontFamily(variant->inputFontFamily),
68 | fontWeight(variant->inputFontWeight),
69 | fontSize(Typography.size),
70 | borderRadius(Misc.borderRadius),
71 | paddingLeft(1.1->rem),
72 | paddingRight(1.1->rem),
73 | paddingTop(0.6->rem),
74 | paddingBottom(0.6->rem),
75 | disabled([
76 | cursor(`notAllowed),
77 | backgroundColor(StyleHelpers.rgbWithAlpha(colors.white, 0.8)),
78 | ]),
79 | hover([
80 | color(inputFontColor(colors, Hovering)),
81 | borderColor(inputBordercolor(colors, Hovering, ~validity, ())),
82 | ]),
83 | focus([
84 | color(inputFontColor(colors, Focus)),
85 | borderColor(inputBordercolor(colors, Focus, ~validity, ())),
86 | ]),
87 | ...Misc.baseTransitions,
88 | ]);
89 | };
90 |
91 | let labelStyles = (~theme, ~variant=Normal, ()) => {
92 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
93 | style([
94 | display(`block),
95 | color(labelFontColor(colors, Base)),
96 | fontFamily(variant->inputFontFamily),
97 | fontWeight(variant->inputFontWeight),
98 | fontSize(Typography.size_label),
99 | paddingLeft(0.6->rem),
100 | paddingRight(0.6->rem),
101 | paddingTop(0.6->rem),
102 | paddingBottom(0.6->rem),
103 | hover([
104 | color(labelFontColor(colors, Hovering)),
105 | borderColor(inputBordercolor(colors, Hovering, ())),
106 | ]),
107 | ...Misc.baseTransitions,
108 | ]);
109 | };
110 |
--------------------------------------------------------------------------------
/stories/icon.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { make as Button, Group } from '../src/Button.bs.js'
3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
4 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
5 | import { useDarkMode } from 'storybook-dark-mode'
6 | import { make as Card } from '../src/Card.bs.js'
7 | import { make as Input } from '../src/Input.bs.js'
8 | import { light, dark } from '../src/UiTypes.bs'
9 | import { tiny, huge } from '../src/CardStyles.bs'
10 | import icons from './icons.js'
11 |
12 | export default {
13 | title: 'Icons',
14 | }
15 |
16 | const margin = {
17 | margin: '1rem',
18 | }
19 |
20 | const inputContainer = {
21 | marginBottom: '2rem',
22 | }
23 |
24 | const icon = {
25 | container: {
26 | textAlign: 'center',
27 | width: '20%',
28 | display: 'inline-block',
29 | marginBottom: '2rem',
30 | },
31 | title: {
32 | display: 'block',
33 | margin: '0.5rem',
34 | },
35 | icon: {
36 | display: 'inline-block',
37 | },
38 | }
39 |
40 | const cardContainer = {
41 | width: 'calc(100% - 2rem)',
42 | margin: '1rem',
43 | display: 'block',
44 | }
45 |
46 | const lCase = (str) => str.toLowerCase()
47 |
48 | export const feather = () => {
49 | const [filteredIcons, setFilteredIcons] = React.useState(icons)
50 | const [filter, setFilter] = React.useState('')
51 | const theme = useDarkMode() ? dark : light
52 | React.useEffect(() => {
53 | const newSet = icons.filter(({ name }) =>
54 | lCase(name).includes(lCase(filter))
55 | )
56 | setFilteredIcons(newSet)
57 | }, [filter])
58 |
59 | return (
60 |
61 |
62 | Icons
63 | Interface
64 |
69 | {`type Icon: (
70 | ~className:option(string),
71 | ~color: option(string),
72 | ~size: option(int),
73 | ~style: option(ReactDomRe.Style.t),
74 | )=> React.element;`}
75 |
76 | Preview
77 |
78 | We built a script that takes some (or all) icons from the
79 | 'react-icons', and generates ReasonML type bindings for
80 | them. We are currently using all 'feather-icons' from the
81 | 'react-icons' library. The cool part is that even though the
82 | file with the bindings is huge, it will be removed at
83 | compile time since they're simply type bindings. Check out
84 | the icons below.
85 |
86 |
87 |
88 | setFilter(e.target.value)}
92 | placeholder="Search"
93 | />
94 |
95 |
96 | {filteredIcons.map(({ name, component }) => (
97 |
98 | {component}
99 |
{name}
100 |
101 | ))}
102 |
103 |
104 | )
105 | }
106 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | Tenzir UI Component Library
3 |
4 |
5 |
6 | A ReasonML-first UI component library
7 |
8 | [![Storybook][storybook-badge]][storybook-url]
9 | [![CI][ci-badge]][ci-url]
10 | [![NPM Version][npm-badge]][npm-url]
11 | [![Development Status][alpha-badge]][latest-release-url]
12 | [![License][license-badge]][license-url]
13 |
14 | [_Introduction_](#introduction) —
15 | [_Getting Started_](#getting-started) —
16 | [_Contributing Guidelines_][contributing-url] —
17 | [_License_](#license)
18 |
19 |
20 |
21 | ## Introduction
22 | Most UI-Libraries for use with ReasonML have bindings to existing UI-Libraries.
23 | Wanting to be fully type-safe without outdated component bindings, leveraging
24 | things like pattern-matching along the way, we decided to build our own
25 | UI-Library, ReasonML-first. We will continually build on this and add
26 | components as we need them. This means components may be missing, or lack
27 | support of all the web API's needed in your specific use-case. We encourage
28 | contribution ([Contributing Guidelines][contributing-url]) in these cases.
29 |
30 | ### Components
31 | The following components are currently built.
32 | - Button
33 | - Card (optionally with a tabbed header)
34 | - Checkbox
35 | - Icons - A script generates bindings for [React
36 | Icons](https://github.com/react-icons/react-icons). We currently generate
37 | bindings to a subset that includes just the [Feather
38 | Icons](https://feathericons.com/) (MIT licensed).
39 | - Input
40 | - Textarea
41 | - Loader
42 | - Alert
43 | - Dropdown
44 | - Dropdown with Action Button
45 | - Segment
46 | - Notifications
47 | - Slider
48 | - Tabs
49 |
50 | Preview here: [Tenzir-Ui-Component-Library][storybook-url]
51 |
52 | ## Getting Started
53 | ### Installation
54 | **Add dependency:**
55 | ```sh
56 | yarn add tenzir-ui-component-library
57 | ```
58 |
59 | **Add the library to the `bs-dependencies` in your `bsconfig.json`:**
60 | ```sh
61 | {
62 | ...
63 | "bs-dependencies": ["tenzir-ui-component-library"]
64 | }
65 | ```
66 |
67 | ### Contributing / Development
68 | We develop the components application agnostically with the help of
69 | [_Storybook_](https://storybook.js.org/). The ReasonML code is compiled first
70 | and is then imported as plain Javascript in the Storybook stories.
71 |
72 | **Install dependencies:**
73 | ```sh
74 | yarn
75 | ```
76 |
77 | **Start ReasonML compiler with file-watcher:**
78 | ```sh
79 | yarn start
80 | ```
81 |
82 | **Start Storybook server (should be ran simultaniously):**
83 | ```sh
84 | yarn server
85 | ```
86 |
87 | ## License
88 | Tenzir UI-Component Library comes with a [3-clause BSD license][license-url].
89 |
90 | [storybook-badge]: https://raw.githubusercontent.com/storybookjs/brand/master/badge/badge-storybook.svg
91 | [storybook-url]: https://tenzir.github.io/ui-component-library/
92 | [ci-url]: https://github.com/tenzir/ui-component-library/actions?query=branch%3Amaster+workflow%3A%22UI%20Components%22
93 | [ci-badge]: https://github.com/tenzir/ui-component-library/workflows/UI%20Components/badge.svg?branch=master
94 | [npm-badge]: https://img.shields.io/npm/v/tenzir-ui-component-library
95 | [npm-url]: https://www.npmjs.com/package/tenzir-ui-component-library
96 | [contributing-url]: https://github.com/tenzir/.github/blob/master/contributing.md
97 | [latest-release-badge]: https://img.shields.io/github/commits-since/tenzir/ui-components/latest.svg?color=green
98 | [latest-release-url]: https://github.com/tenzir/ui-components/releases
99 | [license-badge]: https://img.shields.io/badge/license-BSD-blue.svg
100 | [license-url]: https://github.com/tenzir/ui-components/blob/master/COPYING
101 | [alpha-badge]: https://img.shields.io/badge/stage-alpha-blueviolet
102 |
--------------------------------------------------------------------------------
/stories/segment.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
3 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
4 | import { useDarkMode } from 'storybook-dark-mode'
5 | import { make as Segment } from '../src/Segment.bs.js'
6 | import { FiActivity as Activity } from 'react-icons/fi'
7 | import { make as Card } from '../src/Card.bs.js'
8 | import { light, dark } from '../src/UiTypes.bs'
9 |
10 | export default {
11 | title: 'Segment',
12 | }
13 |
14 | const segmentContainer = {
15 | display: 'inline-block',
16 | width: '40%',
17 | marginRight: '100px',
18 | paddingBottom: '100px',
19 | }
20 | const margin = {
21 | margin: '1rem',
22 | }
23 |
24 | export const Segments = () => {
25 | const theme = useDarkMode() ? dark : light
26 | const [value, setValue] = React.useState(0)
27 | return (
28 |
29 |
30 | Dropdowns
31 | Interface
32 |
37 | {`type t = {
38 | disabled: bool,
39 | id: 'a,
40 | title: string,
41 | };
42 |
43 | type Segment('a): (
44 | ~theme: option(UiTypes.theme)
45 | ~segments: array(t('a)),
46 | ~default: option('a), /* The id of the item */
47 | ~onSegmentUpdate: 'a => unit, /* The changed id get's pushed back */
48 | ~title: option(string),
49 | ) => React.element;
50 | `}
51 |
52 |
53 | Preview
54 |
55 |
A regular segment
56 |
null}
59 | segments={[
60 | { title: 'Lorum', id: 0, disabled: false },
61 | { title: 'Ipsum', id: 1, disabled: false },
62 | { title: 'Dolor', id: 2, disabled: false },
63 | ]}
64 | />
65 |
66 |
67 |
68 | When specifying disabled elements, we select the first
69 | non-disabled element in line by default
70 |
71 |
null}
74 | segments={[
75 | { title: 'Lorum', id: 0, disabled: true },
76 | { title: 'Ipsum', id: 1, disabled: false },
77 | { title: 'Dolor', id: 2, disabled: false },
78 | ]}
79 | />
80 |
81 |
82 |
83 | You can also specify a default selection. This will be
84 | selected upon first render.
85 |
86 |
null}
90 | segments={[
91 | { title: 'Lorum', id: 0, disabled: false },
92 | { title: 'Ipsum', id: 1, disabled: false },
93 | { title: 'Dolor', id: 2, disabled: false },
94 | ]}
95 | />
96 |
97 |
98 |
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/src/AlertStyles.re:
--------------------------------------------------------------------------------
1 | open Css;
2 | open Config; /* From UI repo */
3 | open UiTypes;
4 |
5 | [@bs.deriving accessors]
6 | type variant =
7 | | Primary
8 | | Secondary
9 | | Success
10 | | Warning
11 | | Danger;
12 |
13 | let baseColorDirectlyMapped = (colors, variant) =>
14 | switch (variant) {
15 | | Primary => colors.primary
16 | | Secondary => colors.secondary
17 | | Success => colors.success
18 | | Warning => colors.warning
19 | | Danger => colors.danger
20 | };
21 |
22 | let pillShadow = (colors, variant, componentState) => {
23 | let base = Shadow.box(~y=px(0), ~x=px(0));
24 | let modifier =
25 | switch (componentState) {
26 | | Base => (
27 | color => base(~blur=px(5), StyleHelpers.rgbWithAlpha(color, 0.2))
28 | )
29 | | Active => (
30 | color =>
31 | base(
32 | ~blur=px(5),
33 | ~inset=true,
34 | StyleHelpers.rgbWithAlpha(StyleHelpers.darken(5, color), 0.8),
35 | )
36 | )
37 | | Hovering => (
38 | color => base(~blur=px(10), StyleHelpers.rgbWithAlpha(color, 0.5))
39 | )
40 | | _ => (
41 | color => base(~blur=px(5), StyleHelpers.rgbWithAlpha(color, 0.3))
42 | )
43 | };
44 |
45 | modifier(baseColorDirectlyMapped(colors, variant));
46 | };
47 |
48 | let pillFontColor = (colors: colors, variant, componentState) => {
49 | let baseColor =
50 | switch (variant) {
51 | | Primary
52 | | Danger => StyleHelpers.rgbWithAlpha(colors.white, 0.95)
53 | | Secondary
54 | | Success
55 | | Warning => StyleHelpers.rgbWithAlpha(colors.black, 0.7)
56 | };
57 |
58 | let modifier =
59 | switch (componentState) {
60 | | Hovering
61 | | Base => (x => x)
62 | | Focus
63 | | Active => StyleHelpers.lighten(10)
64 | };
65 |
66 | modifier(baseColor);
67 | };
68 |
69 | let defineBackgroundColor = (colors: colors, variant, componentState) => {
70 | let modifier =
71 | switch (componentState) {
72 | | Active => StyleHelpers.activeColorChange
73 | | Hovering => StyleHelpers.hoverColorChange
74 | | _ => (x => x)
75 | };
76 |
77 | modifier(baseColorDirectlyMapped(colors, variant));
78 | };
79 |
80 | let messagePill = (~theme, ~variant, ~size, ()) => {
81 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
82 | let bgColor = defineBackgroundColor(colors, variant);
83 | let pillFontColor = pillFontColor(colors, variant);
84 | let pillShadow = pillShadow(colors, variant);
85 | let padding =
86 | StyleHelpers.adjustForSizeP4(
87 | ~size,
88 | ~left=2.2,
89 | ~right=1.2,
90 | ~top=0.6,
91 | ~bottom=0.65,
92 | );
93 | style([
94 | width(`fitContent),
95 | backgroundColor(bgColor(Base)),
96 | borderStyle(`none),
97 | outlineStyle(`none),
98 | boxShadow(pillShadow(Base)),
99 | color(pillFontColor(Base)),
100 | fontFamily(`custom(Typography.family_default)),
101 | fontWeight(Typography.weight_button),
102 | fontSize(Typography.size),
103 | borderRadius(Misc.borderRadius),
104 | letterSpacing(0.01->rem),
105 | padding,
106 | margin(`zero),
107 | position(`relative),
108 | hover([
109 | boxShadow(pillShadow(Hovering)),
110 | color(pillFontColor(Hovering)),
111 | backgroundColor(bgColor(Hovering)),
112 | ]),
113 | ...Misc.baseTransitions,
114 | ]);
115 | };
116 |
117 | let iconSpace = (size, value) =>
118 | switch (size) {
119 | | Small => 0.6 *. value
120 | | Medium => 1.0 *. value
121 | | Large => 1.2 *. value
122 | };
123 | let iconSize = (size, value) =>
124 | switch (size) {
125 | | Small => 0.6 *. value
126 | | Medium => 1.0 *. value
127 | | Large => 1.2 *. value
128 | };
129 |
130 | let icon = (~size) => {
131 | let top = top(iconSpace(size, 0.6)->rem);
132 | let left = left(iconSpace(size, 0.6)->rem);
133 | let height = height(iconSize(size, 1.1)->rem);
134 | style([position(`absolute), top, left, selector("& svg", [height])]);
135 | };
136 |
--------------------------------------------------------------------------------
/src/TabStyles.re:
--------------------------------------------------------------------------------
1 | open Css;
2 |
3 | let calcBg = (theme, depth) => {
4 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
5 | StyleHelpers.offsetBgColorFlt(theme, depth, colors.background);
6 | };
7 |
8 | let droppable =
9 | style([
10 | margin(`zero),
11 | padding(`zero),
12 | display(`flex),
13 | width(100.->pct),
14 | height(100.->pct),
15 | paddingTop(0.5->rem),
16 | paddingLeft(0.5->rem),
17 | paddingRight(0.5->rem),
18 | overflowX(`auto),
19 | overflowY(`visible),
20 | ]);
21 |
22 | let container = (standalone, theme, depth) =>
23 | style([
24 | backgroundColor(calcBg(theme, float_of_int(depth) +. 0.5)),
25 | boxShadow(
26 | Shadow.box(
27 | ~y=(-10)->px,
28 | ~x=`zero,
29 | ~spread=0->px,
30 | ~blur=10->px,
31 | ~inset=true,
32 | `rgba((0, 0, 0, `num(0.02))),
33 | ),
34 | ),
35 | display(`flex),
36 | position(standalone ? `static : `absolute),
37 | alignItems(`flexEnd),
38 | borderRadius(Config.Misc.borderRadius),
39 | left(standalone ? `unset : `zero),
40 | right(standalone ? `unset : `zero),
41 | top(standalone ? `unset : `zero),
42 | height(standalone ? 3.5->rem : 3.0->rem),
43 | ]);
44 |
45 | let droppableContainer = style([display(`inlineFlex)]);
46 |
47 | let input = style([maxWidth(100->px)]);
48 | let text = (active, canUpdate) =>
49 | style([
50 | display(`block),
51 | maxWidth(100->px),
52 | overflow(`hidden),
53 | whiteSpace(`nowrap),
54 | textOverflow(`ellipsis),
55 | cursor(active && canUpdate ? `text : `pointer)->important,
56 | ]);
57 |
58 | let roundedIconButton = (~leftMargin=false, theme, depth, active) =>
59 | style([
60 | cursor(`pointer)->important,
61 | margin(`zero),
62 | marginLeft(leftMargin ? 0.5->rem : `zero),
63 | width(1.5->rem),
64 | height(1.5->rem),
65 | borderRadius(1.0->rem),
66 | alignItems(`center),
67 | justifyContent(`center),
68 | display(`flex),
69 | hover([
70 | backgroundColor(
71 | active
72 | ? calcBg(theme, float_of_int(depth + 1))
73 | : calcBg(theme, float_of_int(depth + 2)),
74 | ),
75 | ]),
76 | ...Config.Misc.baseTransitions,
77 | ]);
78 |
79 | let tab = (theme, standalone, depth, active, canOpen) =>
80 | style([
81 | cursor(canOpen ? `auto : `notAllowed),
82 | backgroundColor(
83 | active ? calcBg(theme, float_of_int(depth)) : `transparent,
84 | ),
85 | borderTopLeftRadius(Config.Misc.borderRadius),
86 | borderTopRightRadius(Config.Misc.borderRadius),
87 | borderBottomLeftRadius(standalone ? Config.Misc.borderRadius : `zero),
88 | borderBottomRightRadius(standalone ? Config.Misc.borderRadius : `zero),
89 | display(`flex),
90 | justifyContent(`center),
91 | alignItems(`center),
92 | alignContent(`center),
93 | height(2.5->rem),
94 | paddingLeft(1.0->rem),
95 | paddingRight(0.5->rem),
96 | fontWeight(`semiBold),
97 | boxShadow(
98 | Shadow.box(
99 | ~y=`zero,
100 | ~x=`zero,
101 | ~spread=0->px,
102 | ~blur=10->px,
103 | active ? `rgba((0, 0, 0, `num(0.02))) : `transparent,
104 | ),
105 | ),
106 | selector(
107 | "& ."
108 | ++ roundedIconButton(~leftMargin=true, theme, depth, active)
109 | ++ ", & ."
110 | ++ roundedIconButton(~leftMargin=false, theme, depth, active),
111 | [opacity(0.2)],
112 | ),
113 | hover([
114 | backgroundColor(
115 | active
116 | ? calcBg(theme, float_of_int(depth))
117 | : calcBg(theme, float_of_int(depth + 1)),
118 | ),
119 | selector(
120 | "& ."
121 | ++ roundedIconButton(~leftMargin=true, theme, depth, active)
122 | ++ ", & ."
123 | ++ roundedIconButton(~leftMargin=false, theme, depth, active),
124 | [opacity(1.0)],
125 | ),
126 | ]),
127 | ]);
128 |
129 | let addTab = style([cursor(`pointer)->important]);
130 | let addIcon = style([display(`flex), marginRight(0.5->rem)]);
131 | let addText = style([display(`flex), paddingRight(0.5->rem)]);
132 |
--------------------------------------------------------------------------------
/src/TextareaStyles.re:
--------------------------------------------------------------------------------
1 | open Css;
2 | open Config;
3 | open UiTypes;
4 |
5 | [@bs.deriving accessors]
6 | type resize =
7 | | NoResize
8 | | Both
9 | | Horizontal
10 | | Vertical
11 | | Initial
12 | | Inherit;
13 |
14 | let textareaFontWeight = variant =>
15 | switch (variant) {
16 | | Normal => Typography.weight_default
17 | | Mono => Typography.weight_mono
18 | };
19 |
20 | let textareaFontFamily = variant => {
21 | let font =
22 | switch (variant) {
23 | | Normal => Typography.family_default
24 | | Mono => Typography.family_mono
25 | };
26 | `custom(font);
27 | };
28 |
29 | let labelFontColor = (colors: colors, componentState) =>
30 | switch (componentState) {
31 | | Hovering => StyleHelpers.lighten(20, colors.font)
32 | | _ => colors.font
33 | };
34 |
35 | let textareaFontColor = (colors: colors, componentState) =>
36 | switch (componentState) {
37 | | Hovering => StyleHelpers.lighten(20, colors.input)
38 | | _ => colors.input
39 | };
40 |
41 | let textareaBordercolor =
42 | (colors: colors, componentState, ~validity=Valid, ()) =>
43 | switch (componentState, validity) {
44 | | (Base, Invalid) => colors.danger
45 | | (Hovering, Invalid) => StyleHelpers.lighten(25, colors.danger)
46 | | (Active, Invalid)
47 | | (Focus, Invalid) => StyleHelpers.lighten(8, colors.danger)
48 | | (Base, Valid) => StyleHelpers.lighten(80, colors.input)
49 | | (Hovering, Valid) => StyleHelpers.lighten(50, colors.input)
50 | | (Active, Valid)
51 | | (Focus, Valid) => StyleHelpers.lighten(40, colors.input)
52 | };
53 |
54 | let textareaResize = resize =>
55 | switch (resize) {
56 | | NoResize => `none
57 | | Both => `both
58 | | Horizontal => `horizontal
59 | | Vertical => `vertical
60 | | Initial => `initial
61 | | Inherit => `inherit_
62 | };
63 |
64 | let textareaContainerStyles = (~pctWidth=100.0, ~label=Unlabeled, ()) =>
65 | style([
66 | width(pctWidth->pct),
67 | paddingTop(
68 | switch (label) {
69 | | Labeled(_) => 0.7->rem
70 | | Unlabeled => 0.0->rem
71 | },
72 | ),
73 | display(`inlineBlock),
74 | position(`relative),
75 | ]);
76 |
77 | let textareaStyles =
78 | (
79 | ~theme,
80 | ~variant=Normal,
81 | ~resize as resizeType,
82 | ~validity,
83 | ~styles=[],
84 | (),
85 | ) => {
86 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
87 | style([
88 | resize(textareaResize(resizeType)),
89 | display(`inlineBlock),
90 | width(100.0->pct),
91 | borderStyle(`solid),
92 | borderWidth(2->px),
93 | borderColor(textareaBordercolor(colors, Base, ~validity, ())),
94 | outlineStyle(`none),
95 | color(textareaFontColor(colors, Base)),
96 | fontFamily(variant->textareaFontFamily),
97 | fontWeight(variant->textareaFontWeight),
98 | fontSize(Typography.size),
99 | borderRadius(Misc.borderRadius),
100 | paddingLeft(1.1->rem),
101 | paddingRight(1.1->rem),
102 | paddingTop(0.6->rem),
103 | paddingBottom(0.6->rem),
104 | hover([
105 | color(textareaFontColor(colors, Hovering)),
106 | borderColor(textareaBordercolor(colors, Hovering, ~validity, ())),
107 | ]),
108 | focus([
109 | color(textareaFontColor(colors, Focus)),
110 | borderColor(textareaBordercolor(colors, Focus, ~validity, ())),
111 | ]),
112 | transitionProperty("border-color"),
113 | transitionDuration(200),
114 | transitionTimingFunction(`cubicBezier((0.72, 0.37, 0.51, 1.23))),
115 | ...styles,
116 | ]);
117 | };
118 |
119 | let labelStyles = (~theme, ~variant=Normal, ()) => {
120 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
121 | style([
122 | display(`inlineBlock),
123 | color(labelFontColor(colors, Base)),
124 | fontFamily(variant->textareaFontFamily),
125 | fontWeight(variant->textareaFontWeight),
126 | fontSize(Typography.size_label),
127 | paddingLeft(0.6->rem),
128 | paddingRight(0.6->rem),
129 | paddingTop(0.6->rem),
130 | paddingBottom(0.6->rem),
131 | hover([
132 | color(labelFontColor(colors, Hovering)),
133 | borderColor(textareaBordercolor(colors, Hovering, ())),
134 | ]),
135 | ...Misc.baseTransitions,
136 | ]);
137 | };
138 |
--------------------------------------------------------------------------------
/stories/input.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { make as Input } from '../src/Input.bs.js'
3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
4 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
5 | import { useDarkMode } from 'storybook-dark-mode'
6 | import { make as Card } from '../src/Card.bs.js'
7 | import {
8 | labeled,
9 | light,
10 | dark,
11 | normal,
12 | mono,
13 | valid,
14 | invalid,
15 | } from '../src/UiTypes.bs'
16 | import { tiny, huge } from '../src/CardStyles.bs'
17 |
18 | export default {
19 | title: 'Input',
20 | }
21 |
22 | const margin = {
23 | margin: '1rem',
24 | }
25 |
26 | export const input = () => {
27 | const theme = useDarkMode() ? dark : light
28 |
29 | return (
30 |
31 |
32 | Input
33 |
34 | Interface
35 |
36 | As there is the option to have a label, we need to have an
37 | id so that we can make that clickable and link the label and
38 | the input. We can infer the id from the label, but this may
39 | clash. When in doubt, add an id.
40 |
41 |
46 | {`type Input: (
47 | ~_type: option(string), /* maps to "type", defaults to "text" */
48 | ~defaultValue: option(string), /* defaults to empty string */
49 | ~value: option(string),
50 | ~disabled: option(bool), /* defaults to false */
51 | ~label: option(UiTypes.labels), /* defaults to "UiTypes.Unlabeled" */
52 | ~id: option(string), /* the id is used to make labels clickable. This falls back to the label */
53 | ~validity: option(UiTypes.validation), /* defaults to "UiTypes.Valid" */
54 | ~variant: option(UiTypes.fontStyle), /* defaults to "UiTypes.Normal" */
55 | ~width: option(float), /* as a percentage. Defaults to "100.0" */
56 | ~theme: option(UiTypes.theme)
57 | ~placeholder: option(string) /* defaults to empty string */
58 | ~onChange=option(React.SyntheticEvent.t->unit),
59 | ~onBlur=option(React.SyntheticEvent.t->unit),
60 | ) => React.element;
61 | `}
62 |
63 |
64 | Preview
65 | Custom Width
66 |
73 |
80 |
87 |
88 | Labeled vs Unlabeled
89 |
96 |
102 |
103 | Normal / Mono fonts
104 |
111 |
118 |
119 |
120 | )
121 | }
122 |
--------------------------------------------------------------------------------
/__tests__/StyleHelpers_test.re:
--------------------------------------------------------------------------------
1 | open Jest;
2 | open StyleHelpers;
3 | open Css;
4 | open Expect;
5 |
6 | describe("rgbWithAlpha", () => {
7 | let supportedColour = `rgb((0, 0, 0));
8 | let unsupportedColours = [
9 | ("hex", `hex("000")),
10 | ("rgba", `rgba((0, 0, 0, `num(0)))),
11 | ("hsl", `hsl((0.0->deg, [0.0->pct], [0.0->pct]))),
12 | ("hsla", `hsla((0.0->deg, [0.0->pct], [0.0->pct], [0.0->pct]))),
13 | ("transparent", `transparent),
14 | ("currentColor", `currentColor),
15 | ];
16 |
17 | test("Add an alpha channel to RGB colors", () =>
18 | expect(rgbWithAlpha(supportedColour, 0))
19 | |> toEqual(`rgba((0, 0, 0, `num(0))))
20 | );
21 |
22 | unsupportedColours
23 | ->Belt.List.map(colorTuple => {
24 | let (name, color) = colorTuple;
25 | test("Not add an alpha channel to " ++ name, () =>
26 | expect(rgbWithAlpha(color, 0)) |> toEqual(color)
27 | );
28 | ();
29 | })
30 | ->ignore;
31 | });
32 |
33 | describe("mapRGB", () => {
34 | let rgb = 0;
35 | let rgbF = 0;
36 | let fixed = _ => rgbF;
37 |
38 | test("Map", () =>
39 | expect(mapRGB(fixed, rgb, rgb, rgb)) |> toEqual((rgbF, rgbF, rgbF))
40 | );
41 | });
42 |
43 | describe("applyPercentageToColor", () => {
44 | let percentage = 10;
45 | let color = `rgb((50, 50, 50));
46 | let colorWithAlpha = `rgba((50, 50, 50, `num(50.0)));
47 | let unsupportedColours = [
48 | ("hex", `hex("000")),
49 | ("transparent", `transparent),
50 | ("currentColor", `currentColor),
51 | ];
52 |
53 | test("Lighten RGB", () =>
54 | expect(applyPercentageToColor(UiTypes.Lighten, percentage, color))
55 | |> toEqual(`rgb((75, 75, 75)))
56 | );
57 |
58 | test("Darken RGB", () =>
59 | expect(applyPercentageToColor(UiTypes.Darken, percentage, color))
60 | |> toEqual(`rgb((25, 25, 25)))
61 | );
62 |
63 | test(" Lighten RGBA", () =>
64 | expect(
65 | applyPercentageToColor(UiTypes.Lighten, percentage, colorWithAlpha),
66 | )
67 | |> toEqual(`rgba((75, 75, 75, `num(50.0))))
68 | );
69 |
70 | test("Darken RGBA", () =>
71 | expect(
72 | applyPercentageToColor(UiTypes.Darken, percentage, colorWithAlpha),
73 | )
74 | |> toEqual(`rgba((25, 25, 25, `num(50.0))))
75 | );
76 |
77 | unsupportedColours
78 | ->Belt.List.map(colorTuple => {
79 | let (name, color) = colorTuple;
80 | test("Leave unspported colors intact (lighten) -- " ++ name, () =>
81 | expect(applyPercentageToColor(UiTypes.Lighten, percentage, color))
82 | |> toEqual(color)
83 | );
84 | test("Leave unspported colors intact (darken) -- " ++ name, () =>
85 | expect(applyPercentageToColor(UiTypes.Darken, percentage, color))
86 | |> toEqual(color)
87 | );
88 | ();
89 | })
90 | ->ignore;
91 | });
92 |
93 | describe("lighten / darken shorthands", () => {
94 | let percentage = 10;
95 | let color = `rgb((50, 50, 50));
96 | let colorWithAlpha = `rgba((50, 50, 50, `num(50.0)));
97 | let unsupportedColours = [
98 | ("hex", `hex("000")),
99 | ("transparent", `transparent),
100 | ("currentColor", `currentColor),
101 | ];
102 |
103 | test("Lighten RGB", () =>
104 | expect(lighten(percentage, color)) |> toEqual(`rgb((75, 75, 75)))
105 | );
106 |
107 | test("Darken RGB", () =>
108 | expect(darken(percentage, color)) |> toEqual(`rgb((25, 25, 25)))
109 | );
110 |
111 | test("Lighten RGBA", () =>
112 | expect(lighten(percentage, colorWithAlpha))
113 | |> toEqual(`rgba((75, 75, 75, `num(50.0))))
114 | );
115 |
116 | test("Darken RGBA", () =>
117 | expect(darken(percentage, colorWithAlpha))
118 | |> toEqual(`rgba((25, 25, 25, `num(50.0))))
119 | );
120 |
121 | unsupportedColours
122 | ->Belt.List.map(colorTuple => {
123 | let (name, color) = colorTuple;
124 | test("Leave unspported colors intact (lighten) -- " ++ name, () =>
125 | expect(lighten(percentage, color)) |> toEqual(color)
126 | );
127 | test("Leave unspported colors intact (darken) -- " ++ name, () =>
128 | expect(darken(percentage, color)) |> toEqual(color)
129 | );
130 | ();
131 | })
132 | ->ignore;
133 | });
134 |
135 | describe("colorsFromThemeVariant", () => {
136 | test("Return light theme", () =>
137 | expect(colorsFromThemeVariant(UiTypes.Light))
138 | |> toEqual(Config.Colors.light)
139 | );
140 | test("Return dark theme", () =>
141 | expect(colorsFromThemeVariant(UiTypes.Dark))
142 | |> toEqual(Config.Colors.dark)
143 | );
144 | test("Return tenzir blue theme", () =>
145 | expect(colorsFromThemeVariant(UiTypes.TenzirBlue))
146 | |> toEqual(Config.Colors.tenzirBlue)
147 | );
148 | });
149 |
--------------------------------------------------------------------------------
/src/ButtonStyles.re:
--------------------------------------------------------------------------------
1 | open Css;
2 | open Config;
3 | open UiTypes;
4 |
5 | [@bs.deriving accessors]
6 | type variant =
7 | | Primary
8 | | Secondary
9 | | Success
10 | | Warning
11 | | Danger;
12 |
13 | type iconPosition =
14 | | Left
15 | | Right;
16 |
17 | let baseColorDirectlyMapped = (colors, variant) =>
18 | switch (variant) {
19 | | Primary => colors.primary
20 | | Secondary => colors.secondary
21 | | Success => colors.success
22 | | Warning => colors.warning
23 | | Danger => colors.danger
24 | };
25 |
26 | let buttonShadow = (colors, variant, componentState) => {
27 | let base = Shadow.box(~y=px(0), ~x=px(0));
28 | let modifier =
29 | switch (componentState) {
30 | | Base => (
31 | color => base(~blur=px(5), StyleHelpers.rgbWithAlpha(color, 0.2))
32 | )
33 | | Active => (
34 | color =>
35 | base(
36 | ~blur=px(5),
37 | ~inset=true,
38 | StyleHelpers.rgbWithAlpha(StyleHelpers.darken(5, color), 0.8),
39 | )
40 | )
41 | | Hovering => (
42 | color => base(~blur=px(10), StyleHelpers.rgbWithAlpha(color, 0.5))
43 | )
44 | | _ => (
45 | color => base(~blur=px(5), StyleHelpers.rgbWithAlpha(color, 0.3))
46 | )
47 | };
48 |
49 | modifier(baseColorDirectlyMapped(colors, variant));
50 | };
51 |
52 | let buttonFontColor = (colors: colors, variant, componentState) => {
53 | let baseColor =
54 | switch (variant) {
55 | | Primary
56 | | Danger => StyleHelpers.rgbWithAlpha(colors.white, 0.95)
57 | | Secondary
58 | | Success
59 | | Warning => StyleHelpers.rgbWithAlpha(colors.black, 0.7)
60 | };
61 |
62 | let modifier =
63 | switch (componentState) {
64 | | Hovering
65 | | Base => (x => x)
66 | | Focus
67 | | Active => StyleHelpers.lighten(10)
68 | };
69 |
70 | modifier(baseColor);
71 | };
72 |
73 | let defineBackgroundColor = (colors: colors, variant, componentState) => {
74 | let modifier =
75 | switch (componentState) {
76 | | Active => StyleHelpers.activeColorChange
77 | | Hovering => StyleHelpers.hoverColorChange
78 | | _ => (x => x)
79 | };
80 |
81 | modifier(baseColorDirectlyMapped(colors, variant));
82 | };
83 |
84 | let button = (~theme, ~variant, size, icon, iconPosition, ()) => {
85 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
86 | let bgColor = defineBackgroundColor(colors, variant);
87 | let btnFontColor = buttonFontColor(colors, variant);
88 | let btnShadow = buttonShadow(colors, variant);
89 | let padding =
90 | switch (icon, iconPosition) {
91 | | (None, _) =>
92 | StyleHelpers.adjustForSizeP4(
93 | ~size,
94 | ~left=1.2,
95 | ~right=1.2,
96 | ~top=0.6,
97 | ~bottom=0.65,
98 | )
99 | | (Some(_), Left) =>
100 | StyleHelpers.adjustForSizeP4(
101 | ~size,
102 | ~left=2.2,
103 | ~right=1.2,
104 | ~top=0.6,
105 | ~bottom=0.65,
106 | )
107 | | (Some(_), Right) =>
108 | StyleHelpers.adjustForSizeP4(
109 | ~size,
110 | ~left=1.2,
111 | ~right=2.2,
112 | ~top=0.6,
113 | ~bottom=0.65,
114 | )
115 | };
116 | style([
117 | backgroundColor(bgColor(Base)),
118 | borderStyle(`none),
119 | outlineStyle(`none),
120 | cursor(`pointer),
121 | boxShadow(btnShadow(Base)),
122 | color(btnFontColor(Base)),
123 | fontFamily(`custom(Typography.family_default)),
124 | fontWeight(Typography.weight_button),
125 | fontSize(Typography.size),
126 | borderRadius(Misc.borderRadius),
127 | letterSpacing(0.01->rem),
128 | padding,
129 | position(`relative),
130 | hover([
131 | boxShadow(btnShadow(Hovering)),
132 | color(btnFontColor(Hovering)),
133 | backgroundColor(bgColor(Hovering)),
134 | ]),
135 | active([
136 | boxShadow(btnShadow(Active)),
137 | color(btnFontColor(Active)),
138 | backgroundColor(bgColor(Active)),
139 | ]),
140 | selector("&[disabled]", [opacity(0.8), cursor(`notAllowed)]),
141 | ...Misc.baseTransitions,
142 | ]);
143 | };
144 |
145 | let icon = iconPosition => {
146 | let pos =
147 | switch (iconPosition) {
148 | | Left => left(0.8->rem)
149 | | Right => right(0.8->rem)
150 | };
151 | style([
152 | pos,
153 | position(`absolute),
154 | top(11->px),
155 | selector("& svg", [height(16->px)]),
156 | ]);
157 | };
158 |
159 | let buttonGroup =
160 | style([
161 | position(`relative),
162 | selector(
163 | "& > button",
164 | [
165 | borderRadius(`zero),
166 | position(`relative),
167 | selector(
168 | "&:after",
169 | [
170 | contentRule(`text("")),
171 | position(absolute),
172 | width(1->px),
173 | height(50.0->pct),
174 | right(`zero),
175 | top(25.0->pct),
176 | backgroundColor(`rgba((0, 0, 0, `num(0.1)))),
177 | ],
178 | ),
179 | ],
180 | ),
181 | selector(
182 | "& > button:first-child",
183 | [
184 | borderTopLeftRadius(Misc.borderRadius),
185 | borderBottomLeftRadius(Misc.borderRadius),
186 | ],
187 | ),
188 | selector(
189 | "& > button:last-child",
190 | [
191 | borderTopRightRadius(Misc.borderRadius),
192 | borderBottomRightRadius(Misc.borderRadius),
193 | selector("&:after", [display(`none)]),
194 | ],
195 | ),
196 | ]);
197 |
--------------------------------------------------------------------------------
/stories/slider.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { make as Button, Group } from '../src/Button.bs.js'
3 | import { secondary, make as Slider } from '../src/Slider.bs.js'
4 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
5 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
6 | import { make as Dropdown } from '../src/Dropdown.bs.js'
7 | import { useDarkMode } from 'storybook-dark-mode'
8 | import { FiActivity as Activity } from 'react-icons/fi'
9 | import { make as Card } from '../src/Card.bs.js'
10 | import { light, dark } from '../src/UiTypes.bs'
11 |
12 | const ButtonGroup = Group.make
13 | export default {
14 | title: 'Slider',
15 | }
16 |
17 | const margin = {
18 | margin: '1rem',
19 | }
20 |
21 | const heightSpacer = {
22 | height: '6rem',
23 | }
24 |
25 | export const Sliders = () => {
26 | const theme = useDarkMode() ? dark : light
27 | const [vals, setVals] = React.useState({
28 | simple: 50,
29 | rendered: 50,
30 | withChild: {
31 | value: 50,
32 | unit: 'm',
33 | },
34 | })
35 | return (
36 | <>
37 |
38 |
39 | Sliders
40 | Interface
41 |
46 | {`type Slider: (
47 | ~theme: option(UiTypes.theme)),
48 | ~label: UiTypes.labels, /* defaults to "UiTypes.Unlabeled" */
49 | ~min:int, /* defaults to 0 */
50 | ~max:int, /* defaults to 100 */
51 | ~value: int,
52 | ~displayValue: string, /* If none is provided, the stringified value will be used */
53 | ~align: Slider.align, /* Defaults to Left */
54 | ~font: UiTypes.fontStyle, /* Defaults to Normal */
55 | ~variant: Slider.variant, /* Defaults to Primary */
56 | ~id: string,
57 | ~onChange:React.SyntheticEvent.t->unit,
58 | ~onInput:React.SyntheticEvent.t->unit,
59 | ~className:string,
60 | ~childrenOffset:int /* in px. this wil offset child elements vertically */
61 | ~children:option(React.Element)
62 | )=> React.element;`}
63 |
64 |
65 | Previews
66 | Simple Slider
67 |
73 | event.persist() ||
74 | setVals((vals) => ({
75 | ...vals,
76 | simple: event.target.value,
77 | }))
78 | }
79 | >
80 | Slider with a rendered value
81 |
88 | event.persist() ||
89 | setVals((vals) => ({
90 | ...vals,
91 | rendered: event.target.value,
92 | }))
93 | }
94 | >
95 | Slider with a bound child (dropdown)
96 |
106 | event.persist() ||
107 | setVals((vals) => ({
108 | ...vals,
109 | withChild: {
110 | ...vals.withChild,
111 | value: event.target.value,
112 | },
113 | }))
114 | }
115 | >
116 |
119 | setVals((vals) => ({
120 | ...vals,
121 | withChild: {
122 | ...vals.withChild,
123 | unit: newVal,
124 | },
125 | }))
126 | }
127 | value={vals.withChild.unit}
128 | options={[
129 | { name: 'cm', value: 'cm' },
130 | { name: 'm', value: 'm' },
131 | { name: 'km', value: 'km' },
132 | ]}
133 | title="Units"
134 | />
135 |
136 |
137 |
138 |
139 | >
140 | )
141 | }
142 |
--------------------------------------------------------------------------------
/src/ActionDropdown.re:
--------------------------------------------------------------------------------
1 | open Webapi.Dom;
2 | open! Dropdown;
3 |
4 | type t('a) = {
5 | name: string,
6 | value: 'a,
7 | action: unit => unit,
8 | };
9 |
10 | module Styles = {
11 | open! Css;
12 | let buttonContainer = style([display(`flex), flexDirection(`row)]);
13 | let actionButton = style([borderTopRightRadius(`zero)]);
14 | let arrowButton =
15 | style([
16 | borderTopLeftRadius(`zero)->important,
17 | width(4.0->rem)->important,
18 | padding(`zero)->important,
19 | margin(`zero)->important,
20 | minWidth(`zero)->important,
21 | ]);
22 | };
23 |
24 | [@react.component]
25 | let make =
26 | (
27 | ~theme=Config.defaultTheme,
28 | ~options,
29 | ~disabled=false,
30 | ~enableSearch=false,
31 | ~value: 'a,
32 | ~callActionOnChange=false,
33 | ~depth=1,
34 | ~variant=ButtonStyles.Secondary,
35 | ~minWidth=150,
36 | ~maxHeight=200,
37 | ~onChange,
38 | ~title=?,
39 | ~className="",
40 | ) => {
41 | let element = React.useRef(Js.Nullable.null);
42 | let (state, setState) = React.useState(_ => Closed);
43 | let (filter, setFilter) = React.useState(_ => "");
44 | let (filteredOptions, setFilteredOptions) = React.useState(_ => [||]);
45 | let (name, setName) = React.useState(_ => "");
46 |
47 | let possiblyClose = e =>
48 | element.current
49 | ->Js.Nullable.toOption
50 | ->Belt.Option.map(domElement => {
51 | let targetElement =
52 | MouseEvent.target(e) |> EventTarget.unsafeAsElement;
53 | domElement |> Element.contains(targetElement)
54 | ? () : setState(_ => Closed);
55 | })
56 | ->ignore;
57 |
58 | let toggleState = _ => {
59 | switch (state) {
60 | | Open =>
61 | setFilter(_ => "");
62 | setState(_ => Closed);
63 | Document.removeMouseDownEventListener(possiblyClose, document);
64 | | Closed =>
65 | setState(_ => Open);
66 | Document.addMouseDownEventListener(possiblyClose, document);
67 | };
68 | };
69 |
70 | let possiblySubmit = e => {
71 | let head = filteredOptions->Belt.Array.get(0);
72 | switch (e |> ReactEvent.Keyboard.keyCode, head) {
73 | | (13, Some(opt)) =>
74 | onChange(opt.value);
75 | opt.action();
76 | toggleState();
77 | | _ => ()
78 | };
79 | };
80 |
81 | React.useEffect0(() =>
82 | Some(
83 | () => Document.removeMouseDownEventListener(possiblyClose, document),
84 | )
85 | );
86 |
87 | React.useEffect1(
88 | () => {
89 | options
90 | ->Js.Array2.find(_option => _option.value == value)
91 | ->Belt.Option.map(_option => _option.name)
92 | ->Belt.Option.getWithDefault("None")
93 | ->(newName => setName(_ => newName));
94 | None;
95 | },
96 | [|value|],
97 | );
98 |
99 | React.useEffect2(
100 | () => {
101 | options
102 | ->Belt.Array.keep(({name, _}) =>
103 | Js.String.includes(
104 | Js.String.toLowerCase(filter),
105 | Js.String.toLowerCase(name),
106 | )
107 | )
108 | ->(x => setFilteredOptions(_ => x));
109 |
110 | None;
111 | },
112 | (options, filter),
113 | );
114 |
115 |
122 |
123 | {options
124 | ->Js.Array2.find(_option => _option.value == value)
125 | ->Belt.Option.mapWithDefault( , currentOption =>
126 | currentOption.action()}>
135 | {title->Belt.Option.mapWithDefault( , t =>
136 | <> t->React.string ": "->React.string >
137 | )}
138 | name->React.string
139 |
140 | )}
141 |
150 |
151 |
152 |
153 | {switch (state) {
154 | | Open =>
155 |
160 | {enableSearch
161 | ?
167 | (e |> ReactEvent.Form.target)##value |> setFilter
168 | }
169 | />
170 | : }
171 |
172 | {filteredOptions
173 | ->Belt.Array.map(_option =>
174 | {
185 | toggleState(e);
186 | callActionOnChange ? _option.action() : ();
187 | onChange(_option.value);
188 | }}>
189 | _option.name->React.string
190 |
191 | )
192 | ->React.array}
193 |
194 |
195 | | Closed =>
196 | }}
197 |
;
198 | };
199 |
--------------------------------------------------------------------------------
/src/Notification.re:
--------------------------------------------------------------------------------
1 | open BsFlexboxgrid;
2 |
3 | [@bs.deriving accessors]
4 | type t = {
5 | id: string,
6 | title: string,
7 | body: option(string),
8 | action: option((string, unit => unit)),
9 | timeout: option(int),
10 | };
11 |
12 | let create =
13 | (~id=GenericHelpers.v4(), ~body=None, ~action=None, ~timeout=None, title) => {
14 | id,
15 | title,
16 | body,
17 | action,
18 | timeout,
19 | };
20 |
21 | type state =
22 | | Entering
23 | | Stale
24 | | Leaving;
25 |
26 | module Styles = {
27 | open Css;
28 | let notification = (theme, state, defaultAnimationTime) => {
29 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
30 |
31 | let animOut =
32 | keyframes([
33 | (0, [transform(translateX(0.0->pct))]),
34 | (100, [transform(translateX(100.0->pct))]),
35 | ]);
36 |
37 | let animIn =
38 | keyframes([
39 | (0, [transform(translateX(100.0->pct))]),
40 | (100, [transform(translateX(0.0->pct))]),
41 | ]);
42 |
43 | let animation = (state, defaultAnimationTime) =>
44 | switch (state) {
45 | | Entering => [
46 | animationName(animIn),
47 | animationDuration(defaultAnimationTime),
48 | animationTimingFunction(`easeOut),
49 | ]
50 | | Leaving => [
51 | animationName(animOut),
52 | animationDuration(defaultAnimationTime),
53 | animationTimingFunction(`easeOut),
54 | ]
55 | | _ => [transform(translateX(0.0->pct))]
56 | };
57 |
58 | style([
59 | pointerEvents(`auto),
60 | marginRight(0.5->rem),
61 | marginBottom(0.5->rem),
62 | overflow(`visible)->important,
63 | boxShadow(
64 | Shadow.box(
65 | ~y=`zero,
66 | ~x=`zero,
67 | ~spread=2->px,
68 | ~blur=20->px,
69 | StyleHelpers.rgbWithAlpha(
70 | switch (theme) {
71 | | UiTypes.Light => StyleHelpers.darken(15, colors.background)
72 | | _ => StyleHelpers.darken(5, colors.background)
73 | },
74 | 0.6,
75 | ),
76 | ),
77 | ),
78 | hover([selector("& .close-button", [opacity(1.0)])]),
79 | ...animation(state, defaultAnimationTime),
80 | ]);
81 | };
82 |
83 | let closeButton =
84 | style([
85 | position(`absolute)->important,
86 | top((-1.0)->rem),
87 | left((-1.4)->rem),
88 | padding4(
89 | ~top=0.5->rem,
90 | ~right=0.8->rem,
91 | ~bottom=0.45->rem,
92 | ~left=0.8->rem,
93 | )
94 | ->important,
95 | opacity(0.0),
96 | ...Config.Misc.baseTransitions,
97 | ]);
98 |
99 | let title = style([margin(0.5->rem)]);
100 | let body = style([margin(0.5->rem)]);
101 |
102 | let timeout = (theme, timeout, hovered) => {
103 | let size =
104 | keyframes([(0, [width(0.0->pct)]), (100, [width(100.0->pct)])]);
105 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
106 | style([
107 | backgroundColor(
108 | StyleHelpers.rgbWithAlpha(
109 | StyleHelpers.darken(20, colors.background),
110 | 0.6,
111 | ),
112 | ),
113 | borderRadius(Config.Misc.borderRadius),
114 | width(100.0->pct),
115 | height(0.4->rem),
116 | position(`absolute),
117 | bottom(`zero),
118 | left(`zero),
119 | animationName(size),
120 | animationDuration(timeout),
121 | animationTimingFunction(`easeOut),
122 | animationPlayState(hovered ? `paused : `running),
123 | opacity(hovered ? 0.0 : 1.0),
124 | ...Config.Misc.baseTransitions,
125 | ]);
126 | };
127 | };
128 |
129 | type dismissal =
130 | | Initial
131 | | Timeout
132 | | Forced;
133 |
134 | [@react.component]
135 | let make =
136 | (
137 | ~theme=Config.defaultTheme,
138 | ~notification,
139 | ~handleDismissal,
140 | ~defaultAnimationTime,
141 | ) => {
142 | let (state, setState) = React.useState(_ => Entering);
143 | let (hovered, setHovered) = React.useState(_ => false);
144 | let (readyForDismissal, setReadyForDismissal) =
145 | React.useState(_ => Initial);
146 | let mounted = React.useRef(false);
147 |
148 | React.useEffect0(_ => {
149 | mounted.current = true;
150 | Some(_ => mounted.current = false);
151 | });
152 |
153 | React.useEffect2(
154 | () => {
155 | switch (readyForDismissal, hovered) {
156 | | (Forced, _)
157 | | (Timeout, false) =>
158 | setState(_ => Leaving)->ignore;
159 | Js.Global.setTimeout(
160 | () => {handleDismissal(notification.id)},
161 | defaultAnimationTime,
162 | )
163 | ->ignore;
164 | | _ => ()
165 | };
166 |
167 | None;
168 | },
169 | (hovered, readyForDismissal),
170 | );
171 |
172 | let handleAction = (action, _) => {
173 | setReadyForDismissal(_ => Forced);
174 | action()->ignore;
175 | };
176 |
177 | setHovered(_ => true)}
179 | depth={
180 | switch (theme) {
181 | | UiTypes.Light => (-1)
182 | | _ => 1
183 | }
184 | }
185 | theme
186 | className={Styles.notification(theme, state, defaultAnimationTime)}>
187 | setReadyForDismissal(_ => Forced)}
189 | className={"close-button " ++ Styles.closeButton}
190 | variant=ButtonStyles.Secondary>
191 |
192 |
193 | notification.title->React.string
194 |
195 | {notification.body->Belt.Option.getWithDefault("")->React.string}
196 |
197 | {notification.action
198 | ->Belt.Option.mapWithDefault( , ((title, action)) =>
199 |
200 |
201 | title->React.string
202 |
203 |
204 | )}
205 | {notification.timeout
206 | ->Belt.Option.mapWithDefault(
207 | ,
208 | timeout => {
209 | if (mounted.current) {
210 | Js.Global.setTimeout(
211 | () => setReadyForDismissal(_ => Timeout),
212 | timeout,
213 | )
214 | ->ignore;
215 | };
216 |
217 |
218 |
;
219 | },
220 | )}
221 | ;
222 | };
223 |
--------------------------------------------------------------------------------
/stories/textarea.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { make as Textarea } from '../src/Textarea.bs.js'
3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
4 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
5 | import { useDarkMode } from 'storybook-dark-mode'
6 | import { make as Card } from '../src/Card.bs.js'
7 | import {
8 | labeled,
9 | light,
10 | dark,
11 | normal,
12 | mono,
13 | valid,
14 | invalid,
15 | } from '../src/UiTypes.bs'
16 | import { both, horizontal, vertical } from '../src/TextareaStyles.bs'
17 | import { tiny, huge } from '../src/CardStyles.bs'
18 |
19 | export default {
20 | title: 'Textarea',
21 | }
22 |
23 | const margin = {
24 | margin: '1rem',
25 | }
26 |
27 | export const textarea = () => {
28 | const theme = useDarkMode() ? dark : light
29 |
30 | return (
31 |
32 |
33 | Textarea
34 |
35 | Interface
36 |
37 | As there is the option to have a label, we need to have an
38 | id so that we can make that clickable and link the label and
39 | the input. We can infer the id from the label, but this may
40 | clash. When in doubt, add an id.
41 |
42 |
47 | {`type Textarea: (
48 | ~_type: option(string), /* maps to "type", defaults to "text" */
49 | ~defaultValue: option(string), /* defaults to empty string */
50 | ~value: option(string),
51 | ~disabled: option(bool), /* defaults to false */
52 | ~label: option(UiTypes.labels), /* defaults to "UiTypes.Unlabeled" */
53 | ~id: option(string), /* the id is used to make labels clickable. This falls back to the label */
54 | ~validity: option(UiTypes.validation), /* defaults to "UiTypes.Valid" */
55 | ~variant: option(UiTypes.fontStyle), /* defaults to "UiTypes.Normal" */
56 | ~width: option(float), /* as a percentage. Defaults to "100.0" */
57 | ~resize=option(TextareaStyles.resize), /* Defauls to NoResize */
58 | ~theme: option(UiTypes.theme)
59 | ~placeholder: option(string) /* defaults to empty string */
60 | ~onChange=option(React.SyntheticEvent.t->unit),
61 | ~onBlur=option(React.SyntheticEvent.t->unit),
62 | ~styles=option(array(Css.rule)),
63 | ~rows=option(int), /* Defaults to 4 rows */
64 | ~cols=option(int), /* Defaults to 50 rows */
65 | ) => React.element;
66 | `}
67 |
68 |
69 | Preview
70 | Custom Width
71 | Sizing can either be done via the 'Width' attribute.
72 |
79 |
86 |
93 | Custom Height
94 |
102 |
110 | Labeled vs Unlabeled
111 |
118 |
124 |
125 | Normal / Mono fonts
126 |
133 |
140 |
141 | Resizeable
142 |
147 |
153 |
159 |
165 |
166 |
167 | )
168 | }
169 |
--------------------------------------------------------------------------------
/src/Slider.re:
--------------------------------------------------------------------------------
1 | open BsFlexboxgrid;
2 |
3 | module Raw = {
4 | let sliderPosition: (string, int) => int = [%bs.raw
5 | {|
6 | function (id, width) {
7 | const element = document.getElementById(id);
8 | if(!element) return 0;
9 |
10 | const reversed = element.min < 0;
11 | const range = Math.abs(element.max - element.min);
12 | const offset = Math.abs((Number(element.value) / Number(range) * Number(element.clientWidth)) - (Number(element.value) / Number(range) * (width)));
13 |
14 | return reversed ? (element.clientWidth - offset - ( width)) : offset;
15 | }
16 | |}
17 | ];
18 | };
19 |
20 | /* CONFIG */
21 | module Conf = {
22 | open Css;
23 | let width = 50;
24 | let height = 22;
25 | let widthPx = width->px;
26 | let heightPx = height->px;
27 | let borderRadius = 5->px;
28 | };
29 |
30 | type align =
31 | | Left
32 | | Center
33 | | Right;
34 |
35 | [@bs.deriving accessors]
36 | type variant =
37 | | Primary
38 | | Secondary;
39 |
40 | let baseColorDirectlyMapped = (colors: Config.colors, variant) =>
41 | switch (variant) {
42 | | Primary => colors.primary
43 | | Secondary => colors.secondary
44 | };
45 |
46 | module Styles = {
47 | open Css;
48 | let container =
49 | style([
50 | hover([
51 | selector("& div.children", [pointerEvents(`unset), opacity(1.0)]),
52 | ]),
53 | position(`relative),
54 | overflow(`visible),
55 | ]);
56 |
57 | let value = (theme, font) => {
58 | let colors = theme->StyleHelpers.colorsFromThemeVariant;
59 | let family =
60 | switch (font) {
61 | | UiTypes.Mono => Config.Typography.family_mono
62 | | UiTypes.Normal => Config.Typography.family_default
63 | };
64 | style([
65 | color(StyleHelpers.rgbWithAlpha(colors.black, 0.8)),
66 | textAlign(`center),
67 | fontSize(0.8->rem),
68 | fontFamily(`custom(family)),
69 | ]);
70 | };
71 |
72 | let children = (xPos, yOffset) =>
73 | style([
74 | textAlign(`center),
75 | borderRadius(Conf.borderRadius),
76 | position(`absolute),
77 | top((- yOffset)->px),
78 | paddingBottom(60->px), /* We offset the */
79 | height(Conf.heightPx),
80 | left((xPos + Conf.width / 2)->px),
81 | pointerEvents(`none),
82 | transforms([translateX((-50.0)->pct)]),
83 | fontWeight(`semiBold),
84 | opacity(0.0),
85 | transitionProperty("opacity"),
86 | transitionDuration(200),
87 | transitionTimingFunction(`easeInOut),
88 | zIndex(999),
89 | ]);
90 |
91 | let rangeButton = (theme, variant, xPos) => {
92 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
93 | style([
94 | textAlign(`center),
95 | width(Conf.widthPx),
96 | height(Conf.heightPx),
97 | lineHeight(Conf.heightPx),
98 | borderRadius(Conf.borderRadius),
99 | position(`absolute),
100 | pointerEvents(`none),
101 | top(0->px),
102 | left((xPos + Conf.width / 2)->px),
103 | transforms([translateX((-50.0)->pct)]),
104 | fontWeight(`semiBold),
105 | backgroundColor(variant |> baseColorDirectlyMapped(colors)),
106 | transitionProperty("opacity"),
107 | transitionDuration(200),
108 | transitionTimingFunction(`easeInOut),
109 | ]);
110 | };
111 |
112 | let thumb = [
113 | unsafe("WebkitAppearance", "none"),
114 | borderWidth(`zero),
115 | marginTop((- Conf.height / 2)->px),
116 | width(Conf.widthPx),
117 | height(Conf.heightPx),
118 | lineHeight(Conf.heightPx),
119 | borderRadius(Conf.borderRadius),
120 | backgroundColor(transparent),
121 | ];
122 |
123 | let track = theme => {
124 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
125 | [
126 | width(100.0->pct),
127 | backgroundColor(
128 | StyleHelpers.offsetBgColor(theme, 2, colors.background),
129 | ),
130 | height(0.2->rem),
131 | hover([
132 | backgroundColor(
133 | StyleHelpers.offsetBgColor(theme, 3, colors.background),
134 | ),
135 | ]),
136 | ...Config.Misc.baseTransitions,
137 | ];
138 | };
139 | let range = theme =>
140 | style([
141 | width(100.0->pct),
142 | unsafe("WebkitAppearance", "none"),
143 | backgroundColor(`transparent),
144 | cursor(`pointer),
145 | focus([outline(`zero, `none, `transparent)]),
146 | selector("&::-webkit-slider-thumb", thumb),
147 | selector("&::-moz-range-thumb", thumb),
148 | selector("&::-webkit-slider-runnable-track", track(theme)),
149 | selector("&::-moz-range-track", track(theme)),
150 | ]);
151 | };
152 |
153 | [@react.component]
154 | let make =
155 | (
156 | ~theme=Config.defaultTheme,
157 | ~label=UiTypes.Unlabeled,
158 | ~min=0,
159 | ~max=100,
160 | ~value: int,
161 | ~displayValue=?,
162 | ~align=Left,
163 | ~font=UiTypes.Normal,
164 | ~variant=Primary,
165 | ~id=?,
166 | ~onChange=?,
167 | ~onInput=?,
168 | ~className="",
169 | ~childrenOffset=0,
170 | ~children=?,
171 | ) => {
172 | let (overlayPosition, setOverlayPosition) = React.useState(_ => 0);
173 | let identifier = GenericHelpers.genIdentifier(id, label);
174 |
175 | React.useLayoutEffect1(
176 | () => {
177 | setOverlayPosition(_ => Raw.sliderPosition(identifier, Conf.width));
178 | None;
179 | },
180 | [|value, min, max|],
181 | );
182 |
183 | let aligned = elem =>
184 | switch (align) {
185 | | Left => elem
186 | | Center => elem
187 | | Right => elem
188 | };
189 |
190 |
191 | {aligned(
)}
192 |
193 |
string_of_int}
200 | min={min->string_of_int}
201 | max={max->string_of_int}
202 | />
203 | {children->Belt.Option.mapWithDefault(
, children =>
204 |
208 | children
209 |
210 | )}
211 |
212 |
213 | {displayValue
214 | ->Belt.Option.getWithDefault(value |> string_of_int)
215 | ->React.string}
216 |
217 |
218 |
219 |
;
220 | };
221 |
--------------------------------------------------------------------------------
/stories/button.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { make as Button, Group } from '../src/Button.bs.js'
3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
4 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
5 | import { useDarkMode } from 'storybook-dark-mode'
6 | import { FiActivity as Activity } from 'react-icons/fi'
7 | import { make as Card } from '../src/Card.bs.js'
8 | import { small, medium, large, light, dark } from '../src/UiTypes.bs'
9 | import {
10 | primary,
11 | secondary,
12 | success,
13 | warning,
14 | danger,
15 | } from '../src/ButtonStyles.bs'
16 |
17 | const ButtonGroup = Group.make
18 | export default {
19 | title: 'Buttons',
20 | }
21 |
22 | const margin = {
23 | margin: '1rem',
24 | }
25 |
26 | const buttons = [
27 | { variant: primary, text: 'Primary' },
28 | { variant: secondary, text: 'Secondary' },
29 | { variant: success, text: 'Success' },
30 | { variant: warning, text: 'Warning' },
31 | { variant: danger, text: 'Danger' },
32 | ]
33 |
34 | export const Buttons = () => {
35 | const theme = useDarkMode() ? dark : light
36 | return (
37 | <>
38 |
39 |
40 | Buttons
41 | Interface
42 |
47 | {`type Button: (
48 | ~disabled:boolean,
49 | ~type_: string,
50 | ~variant:ButtonStyles.Primary
51 | ~theme: option(UiTypes.theme)),
52 | ~size=UiTypes.size,
53 | ~icon=option(React.Element),
54 | ~onClick=option(React.SyntheticEvent.t->unit),
55 | ~children=option(React.Element)
56 | )=> React.element;`}
57 |
58 |
59 | Previews
60 | Normal Buttons
61 | {buttons.map(({ variant, text }) => (
62 |
63 |
64 | {text}
65 |
66 |
67 | ))}
68 |
69 | Small / Medium / Large
70 |
71 | {buttons.map(({ variant, text }) => (
72 |
73 |
78 | {text}
79 |
80 |
81 | ))}
82 |
83 |
84 | {buttons.map(({ variant, text }) => (
85 |
89 |
94 | {text}
95 |
96 |
97 | ))}
98 |
99 |
100 | {buttons.map(({ variant, text }) => (
101 |
102 |
107 | {text}
108 |
109 |
110 | ))}
111 |
112 |
113 | Disabled
114 | {buttons.map(({ variant, text }) => (
115 |
116 |
117 | {text}
118 |
119 |
120 | ))}
121 |
122 | With Icons
123 | {buttons.map(({ variant, text }) => (
124 |
125 | }
127 | theme={theme}
128 | variant={variant}
129 | >
130 | {text}
131 |
132 |
133 | ))}
134 |
135 | Grouped
136 | {buttons.map(({ variant, text }) => (
137 |
138 |
139 |
140 | {text}
141 |
142 |
143 | {text}
144 |
145 | }
147 | theme={theme}
148 | variant={variant}
149 | >
150 | {text}
151 |
152 |
153 |
154 | ))}
155 |
156 | Grouped with Mixed Variants
157 | {buttons.map(({ variant, text }) => (
158 |
159 |
160 |
161 | {text}
162 |
163 | }
165 | theme={theme}
166 | variant={variant}
167 | >
168 | {text}
169 |
170 |
171 |
172 | ))}
173 |
174 |
175 | >
176 | )
177 | }
178 |
--------------------------------------------------------------------------------
/stories/dropdown.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
3 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
4 | import { useDarkMode } from 'storybook-dark-mode'
5 | import { make as Dropdown } from '../src/Dropdown.bs.js'
6 | import { make as ActionDropdown } from '../src/ActionDropdown.bs.js'
7 | import { FiActivity as Activity } from 'react-icons/fi'
8 | import { make as Card } from '../src/Card.bs.js'
9 | import { light, dark } from '../src/UiTypes.bs'
10 | import {
11 | primary,
12 | secondary,
13 | success,
14 | warning,
15 | danger,
16 | } from '../src/ButtonStyles.bs'
17 |
18 | export default {
19 | title: 'Dropdown',
20 | }
21 |
22 | const dropdownContainer = {
23 | display: 'inline-block',
24 | width: '40%',
25 | marginRight: '100px',
26 | paddingBottom: '300px',
27 | }
28 | const margin = {
29 | margin: '1rem',
30 | }
31 |
32 | export const Dropdowns = () => {
33 | const theme = useDarkMode() ? dark : light
34 | const [value, setValue] = React.useState(0)
35 | return (
36 |
37 |
38 | Dropdowns
39 | Interface
40 |
45 | {`type t('a) = {
46 | name: string,
47 | value: 'a,
48 | };
49 |
50 | type Dropdown: (
51 | ~theme: option(UiTypes.theme)
52 | ~options: array(t('a)),
53 | ~disabled: option(bool), /* defaults to false */
54 | ~enableSearch: option(bool), /* defaults to false */
55 | ~value: 'a,
56 | ~depth: option(int), /* defaults to 1 */
57 | ~minWidth: option(int), /* in px, defaults to 150, width for the button */
58 | ~maxHeight: option(int), /* in px, defaults to 200, height for the dropdown */
59 | ~onChange: 'a => unit,
60 | ~title: option(string),
61 | ~className: option(string), /* Defaults to empty string */
62 | ) => React.element;
63 | `}
64 |
65 |
66 | Preview
67 |
68 |
A regular dropdown
69 |
83 |
84 |
85 |
A regular dropdown with a built in filter
86 |
101 |
102 |
103 |
A regular dropdown without anything
104 |
119 |
120 |
121 |
A regular dropdown without anything
122 |
137 |
138 |
139 |
140 | )
141 | }
142 |
143 | export const ActionDropdowns = () => {
144 | const theme = useDarkMode() ? dark : light
145 | const [value, setValue] = React.useState(0)
146 | return (
147 |
148 |
149 | Action Dropdowns
150 | Interface
151 |
156 | {`type t('a) = {
157 | name: string,
158 | value: 'a,
159 | action: unit -> unit
160 | };
161 |
162 | type Dropdown: (
163 | ~theme: option(UiTypes.theme)
164 | ~options: array(t('a)),
165 | ~disabled: option(bool), /* defaults to false */
166 | ~enableSearch: option(bool), /* defaults to false */
167 | ~value: 'a,
168 | ~depth: option(int), /* defaults to 1 */
169 | ~minWidth: option(int), /* in px, defaults to 150, width for the button */
170 | ~maxHeight: option(int), /* in px, defaults to 200, height for the dropdown */
171 | ~callActionOnChange=false,
172 | ~onChange: 'a => unit,
173 | ~title: option(string),
174 | ~className: option(string), /* Defaults to empty string */
175 | ) => React.element;
176 | `}
177 |
178 |
179 | Preview
180 |
181 |
A dropdown that calls actions
182 |
alert('Hello'),
191 | },
192 | {
193 | name: 'Ipsum',
194 | value: 1,
195 | action: () => alert('Hello from no. 2'),
196 | },
197 | ]}
198 | title="Sample"
199 | />
200 |
201 |
202 |
203 | )
204 | }
205 |
--------------------------------------------------------------------------------
/stories/notifications.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Group, make as Button } from '../src/Button.bs.js'
3 | import { secondary } from '../src/ButtonStyles.bs.js'
4 | import { make as Input } from '../src/Input.bs.js'
5 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
6 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
7 | import { useDarkMode } from 'storybook-dark-mode'
8 | import { make as Card } from '../src/Card.bs.js'
9 | import { create as createNotification } from '../src/Notification.bs.js'
10 | import { make as Notifications } from '../src/Notifications.bs.js'
11 | import {
12 | labeled,
13 | light,
14 | dark,
15 | normal,
16 | mono,
17 | valid,
18 | invalid,
19 | } from '../src/UiTypes.bs'
20 | import { tiny, huge } from '../src/CardStyles.bs'
21 |
22 | export default {
23 | title: 'Notifications',
24 | }
25 |
26 | const margin = {
27 | margin: '1rem',
28 | }
29 |
30 | const ButtonGroup = Group.make
31 |
32 | export const notifications = () => {
33 | const theme = useDarkMode() ? dark : light
34 |
35 | const [notifications, setNotifications] = React.useState([
36 | { id: 0, title: 'Here is the Preview' },
37 | { id: 1, title: 'I also have a body', body: 'Here is the body' },
38 | {
39 | id: 2,
40 | title: 'I have an action',
41 | action: ['Action Title', () => alert('Hello')],
42 | },
43 | {
44 | id: 3,
45 | title: 'I go away automagically',
46 | body: 'Here is some text to go with my notification',
47 | timeout: 3000,
48 | action: ['Action Title', () => alert('Hello')],
49 | },
50 | {
51 | id: 4,
52 | title: 'I go away automagically',
53 | body: 'But hang around a little longer than my mate',
54 | timeout: 8000,
55 | },
56 | ])
57 |
58 | const addNotification = (notification) => (e) => {
59 | setNotifications((arr) => [...arr, notification])
60 | }
61 |
62 | return (
63 |
64 |
65 | Notification
66 |
67 | Interface
68 |
69 | There are two types to care about when using these. The
70 | first one is the Notification itself, the second one is the
71 | one that handles the display logic. The design is made in
72 | such a way that there is a pure way to handle the showing /
73 | hiding of notifications by updating the list. It is expected
74 | of an implementer to implement the logic for this. We use a
75 | centralized store management solution and feed the list into
76 | it from there. Note that the timeout is defined as a tuple
77 | of string / unit -> unit. The string holds the line that
78 | get's put into the button.
79 |
80 |
85 | {`type Notification: {
86 | id: string,
87 | title: string,
88 | body: option(string),
89 | action: option((string, unit => unit)),
90 | timeout: option(int),
91 | };
92 |
93 | type Notifications
94 | ~notifications: array(Notification),
95 | ~handleDismissal: option(id => unit),
96 | ~defaultAnimationTime: int, /* In ms, Defaults to 400 */
97 | ~maxAmount: int, /* Max notifications to show at one point in time. Defaults to 5 */
98 | ) => React.element;
99 | `}
100 |
101 | Helper
102 | Try it:
103 |
104 |
117 | Plain
118 |
119 |
120 |
133 | Body
134 |
135 |
136 | alert('Hello')],
144 | undefined,
145 | 'Title'
146 | )
147 | )}
148 | >
149 | Action
150 |
151 |
152 | alert('Hello')],
160 | 2000,
161 | 'Title'
162 | )
163 | )}
164 | >
165 | Timeout
166 |
167 |
168 |
169 | Helper
170 |
171 | To aid with the creation of these, rather than declaring
172 | them with a record type, we expose a convienience function
173 | with sane defaults for all optional values and automatic id
174 | creation with the help of 'uuid.v4()'. With the help of
175 | optional arguments, we can make the creation much less
176 | verbose.
177 |
178 |
179 |
184 | {`type Notification.create: (
185 | ~id: string, /* Defaults to uuid.v4() */
186 | ~body: option(string), /* Defaults to None */
187 | ~action: option(string), /* Defaults to None */
188 | ~timeout: option((string, unit => unit)), /* Defaults to None */
189 | title: string, /* No default, the only fixed parameter */
190 | );
191 |
192 | let a = Notification.create("Just a title");
193 | let b = Notification.create(~body="Also a body", "Just a title");
194 |
195 | `}
196 |
197 |
198 |
200 | setNotifications((arr) =>
201 | arr.filter(({ id }) => id !== notificationId)
202 | )
203 | }
204 | notifications={notifications}
205 | theme={theme}
206 | />
207 |
208 |
209 | )
210 | }
211 |
--------------------------------------------------------------------------------
/src/Dropdown.re:
--------------------------------------------------------------------------------
1 | open Webapi.Dom;
2 |
3 | type t('a) = {
4 | name: string,
5 | value: 'a,
6 | };
7 |
8 | type dropdownState =
9 | | Open
10 | | Closed;
11 |
12 | module Styles = {
13 | open Css;
14 | let container = (state, variant, theme, minimumWidth) => {
15 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
16 | let bgColor =
17 | state === Open
18 | ? ButtonStyles.defineBackgroundColor(
19 | colors,
20 | variant,
21 | UiTypes.Hovering,
22 | )
23 | : ButtonStyles.defineBackgroundColor(colors, variant, UiTypes.Base);
24 |
25 | let shadow =
26 | state === Open
27 | ? ButtonStyles.buttonShadow(colors, variant, UiTypes.Hovering)
28 | : ButtonStyles.buttonShadow(colors, variant, UiTypes.Base);
29 |
30 | style([
31 | borderRadius(Config.Misc.borderRadius),
32 | boxShadow(shadow),
33 | position(`relative),
34 | backgroundColor(bgColor)->important,
35 | overflow(`visible),
36 | minWidth(minimumWidth->px),
37 | ]);
38 | };
39 | let button = (state, minimumWidth) => {
40 | let openedStyle =
41 | switch (state) {
42 | | Open => [
43 | borderBottomLeftRadius(`zero)->important,
44 | borderBottomRightRadius(`zero)->important,
45 | boxShadow(
46 | Shadow.box(
47 | ~y=px(0),
48 | ~x=px(0),
49 | ~blur=px(5),
50 | `rgba((0, 0, 0, `num(0.0))),
51 | ),
52 | )
53 | ->important,
54 | ]
55 | | Closed => [
56 | borderBottomLeftRadius(Config.Misc.borderRadius),
57 | borderBottomRightRadius(Config.Misc.borderRadius),
58 | ]
59 | };
60 | style([
61 | width(100.0->pct),
62 | wordBreak(`breakAll)->important,
63 | fontSize(0.9->rem)->important,
64 | minWidth(minimumWidth->px),
65 | ...openedStyle,
66 | ]);
67 | };
68 | let searchBox = style([marginBottom(1.0->rem)]);
69 | let listContainer = (variant, theme) => {
70 | let bgColor =
71 | StyleHelpers.colorsFromThemeVariant(theme)
72 | ->ButtonStyles.baseColorDirectlyMapped(variant);
73 | style([
74 | position(`absolute)->important,
75 | left(`zero),
76 | top(100.0->pct),
77 | padding2(~h=0.5->rem, ~v=0.5->rem),
78 | width(`auto)->important,
79 | height(`auto)->important,
80 | backgroundColor(bgColor)->important,
81 | borderTopLeftRadius(`zero)->important,
82 | borderTopRightRadius(`zero)->important,
83 | minWidth(100.0->pct)->important,
84 | zIndex(1),
85 | ]);
86 | };
87 |
88 | let uList = (variant, theme, maximumHeight) => {
89 | let bgColor =
90 | StyleHelpers.colorsFromThemeVariant(theme)
91 | ->ButtonStyles.baseColorDirectlyMapped(variant);
92 | style([
93 | margin(`zero),
94 | padding(`zero),
95 | maxHeight(maximumHeight->px)->important,
96 | borderRadius(Config.Misc.borderRadius),
97 | overflowY(`auto)->important,
98 | listStyleType(`none),
99 | backgroundColor(bgColor),
100 | ]);
101 | };
102 |
103 | let listItem = (state, variant, ~theme, ~depth, ~isActive=false, ()) => {
104 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
105 | let offset = StyleHelpers.offsetBgColor(theme);
106 | let bgColor = colors->ButtonStyles.baseColorDirectlyMapped(variant);
107 | let fontColor =
108 | state === Open
109 | ? ButtonStyles.buttonFontColor(colors, variant, UiTypes.Active)
110 | : ButtonStyles.buttonFontColor(colors, variant, UiTypes.Base);
111 | style([
112 | cursor(isActive ? `default : `pointer),
113 | margin(`zero),
114 | padding2(~h=1.5->rem, ~v=0.5->rem),
115 | color(fontColor)->important,
116 | borderRadius(Config.Misc.borderRadius),
117 | backgroundColor(isActive ? offset(depth - 2, bgColor) : `transparent),
118 | hover([
119 | backgroundColor(offset(depth - 3, bgColor)),
120 | active([backgroundColor(offset(depth - 3, bgColor))]),
121 | ]),
122 | ...Config.Misc.baseTransitions,
123 | ]);
124 | };
125 | };
126 |
127 | [@react.component]
128 | let make =
129 | (
130 | ~theme=Config.defaultTheme,
131 | ~options,
132 | ~disabled=false,
133 | ~enableSearch=false,
134 | ~value: 'a,
135 | ~depth=1,
136 | ~variant=ButtonStyles.Secondary,
137 | ~minWidth=150,
138 | ~maxHeight=200,
139 | ~onChange,
140 | ~title=?,
141 | ~className="",
142 | ) => {
143 | let element = React.useRef(Js.Nullable.null);
144 | let (state, setState) = React.useState(_ => Closed);
145 | let (filter, setFilter) = React.useState(_ => "");
146 | let (filteredOptions, setFilteredOptions) = React.useState(_ => [||]);
147 | let (name, setName) = React.useState(_ => "");
148 |
149 | let possiblyClose = e =>
150 | element.current
151 | ->Js.Nullable.toOption
152 | ->Belt.Option.map(domElement => {
153 | let targetElement =
154 | MouseEvent.target(e) |> EventTarget.unsafeAsElement;
155 | domElement |> Element.contains(targetElement)
156 | ? () : setState(_ => Closed);
157 | })
158 | ->ignore;
159 |
160 | let toggleState = _ => {
161 | switch (state) {
162 | | Open =>
163 | setFilter(_ => "");
164 | setState(_ => Closed);
165 | Document.removeMouseDownEventListener(possiblyClose, document);
166 | | Closed =>
167 | setState(_ => Open);
168 | Document.addMouseDownEventListener(possiblyClose, document);
169 | };
170 | };
171 |
172 | let possiblySubmit = e => {
173 | let head = filteredOptions->Belt.Array.get(0);
174 | switch (e |> ReactEvent.Keyboard.keyCode, head) {
175 | | (13, Some(opt)) =>
176 | onChange(opt.value);
177 | toggleState();
178 | | _ => ()
179 | };
180 | };
181 |
182 | React.useEffect0(() =>
183 | Some(
184 | () => Document.removeMouseDownEventListener(possiblyClose, document),
185 | )
186 | );
187 |
188 | React.useEffect1(
189 | () => {
190 | options
191 | ->Js.Array2.find(_option => _option.value == value)
192 | ->Belt.Option.map(_option => _option.name)
193 | ->Belt.Option.getWithDefault("None")
194 | ->(newName => setName(_ => newName));
195 | None;
196 | },
197 | [|value|],
198 | );
199 |
200 | React.useEffect2(
201 | () => {
202 | options
203 | ->Belt.Array.keep(({name, _}) =>
204 | Js.String.includes(
205 | Js.String.toLowerCase(filter),
206 | Js.String.toLowerCase(name),
207 | )
208 | )
209 | ->(x => setFilteredOptions(_ => x));
210 |
211 | None;
212 | },
213 | (options, filter),
214 | );
215 |
216 |
221 |
222 | }
228 | iconPosition=ButtonStyles.Right>
229 | {title->Belt.Option.mapWithDefault( , t =>
230 | <> t->React.string ": "->React.string >
231 | )}
232 | name->React.string
233 |
234 |
235 | {switch (state) {
236 | | Open =>
237 |
242 | {enableSearch
243 | ?
249 | (e |> ReactEvent.Form.target)##value |> setFilter
250 | }
251 | />
252 | : }
253 |
254 | {filteredOptions
255 | ->Belt.Array.map(_option =>
256 | {
267 | toggleState(e);
268 | onChange(_option.value);
269 | }}>
270 | _option.name->React.string
271 |
272 | )
273 | ->React.array}
274 |
275 |
276 | | Closed =>
277 | }}
278 |
;
279 | };
280 |
--------------------------------------------------------------------------------
/src/Tooltip.re:
--------------------------------------------------------------------------------
1 | [@bs.deriving accessors]
2 | type horizontal =
3 | | Left
4 | | Center
5 | | Right;
6 |
7 | [@bs.deriving accessors]
8 | type vertical =
9 | | Top
10 | | Middle
11 | | Bottom;
12 |
13 | [@bs.deriving accessors]
14 | type tPosition =
15 | | Top(horizontal)
16 | | Bottom(horizontal)
17 | | Left(vertical)
18 | | Right(vertical);
19 |
20 | let tip =
21 | (
22 | ~positionFix=false,
23 | ~theme=UiTypes.light,
24 | ~delayMs=100,
25 | ~tipPosition=Top(Center),
26 | value,
27 | ) => {
28 | open! Css;
29 | let colors = StyleHelpers.colorsFromThemeVariant(theme);
30 |
31 | /* ---- Configuration */
32 | let (tooltipBackground, tooltipTextColor, tooltipShadowColor) =
33 | switch (theme) {
34 | | UiTypes.Dark => (
35 | colors.background,
36 | colors.font,
37 | StyleHelpers.rgbWithAlpha(colors.black, 0.1),
38 | )
39 | | UiTypes.Light => (
40 | colors.background,
41 | colors.font,
42 | StyleHelpers.rgbWithAlpha(colors.black, 0.04),
43 | )
44 | | UiTypes.TenzirBlue => (
45 | colors.background,
46 | colors.font,
47 | StyleHelpers.rgbWithAlpha(colors.black, 0.1),
48 | )
49 | };
50 | let tipHeight = 10; // in px
51 | let offset = 2; // in px
52 | let tipInset = 5;
53 | /* ---- Configuration */
54 |
55 | let horizontalPositioning =
56 | (h: horizontal, vOffset, ~rotation=0.->deg, ~addTipInset=false, ()) =>
57 | switch (h) {
58 | | Left => [
59 | left(0.0->pct),
60 | marginLeft((addTipInset ? tipInset : 0)->px),
61 | transforms([translateY(vOffset), rotate(rotation)]),
62 | ]
63 | | Center => [
64 | left(50.0->pct),
65 | transforms([translate((-50.)->pct, vOffset), rotate(rotation)]),
66 | ]
67 | | Right => [
68 | left(100.0->pct),
69 | marginLeft((addTipInset ? - tipInset : 0)->px),
70 | transforms([translate((-100.)->pct, vOffset), rotate(rotation)]),
71 | ]
72 | };
73 |
74 | let verticalPositioning =
75 | (v: vertical, hOffset, ~rotation=0.->deg, ~addTipInset=false, ()) =>
76 | switch (v) {
77 | | Top => [
78 | top(0.->pct),
79 | marginTop((addTipInset ? tipInset : 0)->px),
80 | transforms([translateX(hOffset), rotate(rotation)]),
81 | ]
82 | | Middle => [
83 | top(50.0->pct),
84 | transforms([translate(hOffset, (-50.)->pct), rotate(rotation)]),
85 | ]
86 | | Bottom => [
87 | top(100.0->pct),
88 | marginTop((addTipInset ? - tipInset : 0)->px),
89 | transforms([translate(hOffset, (-100.)->pct), rotate(rotation)]),
90 | ]
91 | };
92 |
93 | let positions =
94 | switch ((tipPosition: tPosition)) {
95 | | Top(h) => [
96 | bottom(100.->pct),
97 | ...horizontalPositioning(h, (- tipHeight - offset)->px, ()),
98 | ]
99 | | Bottom(h) => [
100 | top(100.->pct),
101 | ...horizontalPositioning(h, (tipHeight + offset)->px, ()),
102 | ]
103 | | Left(v) => [
104 | right(100.->pct),
105 | ...verticalPositioning(v, (- tipHeight - offset)->px, ()),
106 | ]
107 | | Right(v) => [
108 | left(100.->pct),
109 | ...verticalPositioning(v, (tipHeight + offset)->px, ()),
110 | ]
111 | };
112 |
113 | let tipPositions =
114 | switch ((tipPosition: tPosition)) {
115 | | Top(h) => [
116 | bottom(100.->pct),
117 | ...horizontalPositioning(
118 | h,
119 | (- tipHeight - offset - 2)->px,
120 | ~rotation=180.->deg,
121 | ~addTipInset=true,
122 | (),
123 | ),
124 | ]
125 | | Bottom(h) => [
126 | top(100.->pct),
127 | ...horizontalPositioning(h, (offset + 3)->px, ~addTipInset=true, ()),
128 | ]
129 | | Left(v) => [
130 | right(100.->pct),
131 | ...verticalPositioning(
132 | v,
133 | (- tipHeight - offset - 4)->px,
134 | ~rotation=90.->deg,
135 | ~addTipInset=true,
136 | (),
137 | ),
138 | ]
139 | | Right(v) => [
140 | left(100.->pct),
141 | ...verticalPositioning(
142 | v,
143 | (offset + 3)->px,
144 | ~rotation=270.->deg,
145 | ~addTipInset=true,
146 | (),
147 | ),
148 | ]
149 | };
150 |
151 | style([
152 | position(positionFix ? `static : `relative),
153 | selector(
154 | "&:before",
155 | [
156 | position(`absolute),
157 | display(`inlineBlock),
158 | padding2(~h=0.8->rem, ~v=0.4->rem),
159 | boxShadow(
160 | Shadow.box(~y=0->px, ~x=0->px, ~blur=10->px, tooltipShadowColor),
161 | ),
162 | fontWeight(`semiBold),
163 | borderRadius(Config.Misc.borderRadius),
164 | backgroundColor(tooltipBackground),
165 | color(tooltipTextColor),
166 | unsafe("content", "\"" ++ value ++ "\""),
167 | whiteSpace(`nowrap),
168 | zIndex(9999),
169 | opacity(0.),
170 | pointerEvents(`none),
171 | transitionProperty("all"),
172 | transitionDuration(200),
173 | transitionDelay(0),
174 | transitionTimingFunction(`easeInOut),
175 | ...positions,
176 | ],
177 | ),
178 | selector(
179 | "&:after",
180 | [
181 | left(`zero),
182 | top(`zero),
183 | position(`absolute),
184 | display(`inlineBlock),
185 | width(0->px),
186 | height(0->px),
187 | borderLeft(6->px, `solid, `transparent),
188 | borderRight(6->px, `solid, `transparent),
189 | borderBottom(8->px, `solid, tooltipBackground),
190 | zIndex(100),
191 | opacity(0.),
192 | pointerEvents(`none),
193 | transitionProperty("all"),
194 | transitionDuration(200),
195 | transitionDelay(0),
196 | transitionTimingFunction(`easeInOut),
197 | unsafe("content", ""),
198 | ...tipPositions,
199 | ],
200 | ),
201 | hover([
202 | selector(
203 | "&:before",
204 | [opacity(1.), pointerEvents(`auto), transitionDelay(delayMs)],
205 | ),
206 | selector(
207 | "&:after",
208 | [opacity(1.), pointerEvents(`auto), transitionDelay(delayMs)],
209 | ),
210 | ]),
211 | ]);
212 | };
213 |
214 | module Examples = {
215 | module Styles = {
216 | open! Css;
217 | let exampleContainer =
218 | style([
219 | padding2(~h=2.->rem, ~v=1.->rem),
220 | selector(
221 | "& span",
222 | [
223 | display(`inlineBlock),
224 | padding(1.->rem),
225 | margin(1.->rem),
226 | backgroundColor(`rgba((128, 128, 128, `num(0.5)))),
227 | ],
228 | ),
229 | ]);
230 | };
231 |
232 | [@react.component]
233 | let make = (~theme) => {
234 | let themedTip = tip(~theme);
235 | <>
236 |
237 |
238 | "Top Left"->React.string
239 |
240 |
241 | "Top Center"->React.string
242 |
243 |
244 | "Top Right"->React.string
245 |
246 |
247 |
248 |
249 | "Bottom Left"->React.string
250 |
251 |
252 | "Bottom Center"->React.string
253 |
254 |
255 | "Bottom Right"->React.string
256 |
257 |
258 |
259 |
260 | "Left Top"->React.string
261 |
262 |
263 | "Left Middle"->React.string
264 |
265 |
266 | "Left Bottom"->React.string
267 |
268 |
269 |
270 |
271 | "Right Top"->React.string
272 |
273 |
274 | "Right Middle"->React.string
275 |
276 |
277 | "Right Bottom"->React.string
278 |
279 |
280 | >;
281 | };
282 | };
283 |
--------------------------------------------------------------------------------
/src/Tabs.re:
--------------------------------------------------------------------------------
1 | module Styles = TabStyles;
2 | open Lib.Function.Infix;
3 | open Lib.Jsx.Infix;
4 |
5 | type t = {
6 | id: string,
7 | title: string,
8 | };
9 |
10 | module Helpers = {
11 | let create = title => {id: GenericHelpers.v4(), title};
12 | let createMany = arr => Belt.Array.map(arr, create);
13 | let add = (arr, tab) => Belt.Array.concat(arr, [|tab|]);
14 | let update = (arr, x) => Belt.Array.map(arr, y => y.id === x.id ? x : y);
15 | let removeById = (arr, id) => Belt.Array.keep(arr, x => x.id !== id);
16 | let removeByIndex = (arr, idx) =>
17 | Belt.Array.concat(
18 | Belt.Array.slice(arr, ~offset=0, ~len=idx),
19 | Belt.Array.sliceToEnd(arr, idx + 1),
20 | );
21 | let duplicateById = (arr, id) => {
22 | let newId = GenericHelpers.v4();
23 | let arr =
24 | Js.Array2.find(arr, x => x.id === id)
25 | ->Belt.Option.map(elem =>
26 | Belt.Array.concat(arr, [|{...elem, id: newId}|])
27 | )
28 | ->Belt.Option.getWithDefault(arr);
29 | (arr, newId);
30 | };
31 | let duplicateByIdx = (arr, idx) => {
32 | let newId = GenericHelpers.v4();
33 | let arr =
34 | Belt.Array.get(arr, idx)
35 | ->Belt.Option.map(elem =>
36 | Belt.Array.concat(arr, [|{...elem, id: newId}|])
37 | )
38 | ->Belt.Option.getWithDefault(arr);
39 | (arr, newId);
40 | };
41 | let move = (arr, from, to_) => {
42 | Belt.Array.get(arr, from)
43 | ->Belt.Option.map(elem => {
44 | let copy = Belt.Array.copy(arr);
45 | Js.Array2.spliceInPlace(copy, ~pos=from, ~remove=1, ~add=[||])
46 | ->ignore;
47 | Js.Array2.spliceInPlace(copy, ~pos=to_, ~remove=0, ~add=[|elem|])
48 | ->ignore;
49 | copy;
50 | })
51 | ->Belt.Option.getWithDefault(arr);
52 | };
53 | };
54 |
55 | module TabInput = {
56 | [@react.component]
57 | let make = (~defaultValue, ~onRename) => {
58 | let ref = React.useRef(Js.Nullable.null);
59 |
60 | React.useEffect0(() => {
61 | Lib.Jsx.focus(ref);
62 | None;
63 | });
64 |
65 | let handleKeyDown = e => {
66 | Lib.Event.Keyboard.keyWas("Enter", e)
67 | ? onRename(e |> Lib.Event.getValueFromEvent) : ();
68 | };
69 |
70 | > onRename}
75 | onKeyUp=handleKeyDown
76 | />;
77 | };
78 | };
79 |
80 | type action =
81 | | Add(t)
82 | | Open(string)
83 | | Rename(t)
84 | | Close(string)
85 | | Duplicate(string);
86 |
87 | module AddTab = {
88 | [@react.component]
89 | let make =
90 | (
91 | ~dispatch: action => unit,
92 | ~depth: int,
93 | ~standalone,
94 | ~theme,
95 | ~title=Some("New"),
96 | ) => {
97 | /*
98 | The index below is technically incorrect. But by adding it to the
99 | list of draggable items, it means the placeholder will be correctly
100 | updated.
101 | */
102 |
103 | Add(Helpers.create("New tab"))->dispatch}>
112 |
113 | {Belt.Option.mapWithDefault(title, , x =>
114 | x->React.string
115 | )}
116 |
117 | ;
118 | };
119 | };
120 | module Tab = {
121 | [@react.component]
122 | let make =
123 | (
124 | ~tab: t,
125 | ~dispatch: action => unit,
126 | ~index: int,
127 | ~active: bool,
128 | ~standalone: bool,
129 | ~depth: int,
130 | ~features,
131 | ~theme,
132 | ) => {
133 | let (editing, setEditing) = React.useState(Lib.Function.const(false));
134 | let (canOpen, canClose, canUpdate, canDuplicate, canMove) = features;
135 |
136 | Open(tab.id)->dispatch}>
140 | {editing
141 | ? {
144 | setEditing(Lib.Function.const(false));
145 | Rename({...tab, title})->dispatch;
146 | }}
147 | />
148 | : {
150 | e->ReactEvent.Synthetic.stopPropagation;
151 | active && canUpdate
152 | ? setEditing(Lib.Function.const(true))
153 | : Open(tab.id)->dispatch;
154 | }}
155 | className={Styles.text(active, canUpdate)}>
156 | tab.title->React.string
157 | }
158 | {(!canDuplicate && !canClose)
159 | <&&> Css.rem} />}
160 | {canDuplicate
161 | <&&> {
163 | e->ReactEvent.Synthetic.stopPropagation;
164 | Duplicate(tab.id)->dispatch;
165 | }}
166 | className={Styles.roundedIconButton(
167 | ~leftMargin=true,
168 | theme,
169 | depth,
170 | active,
171 | )}>
172 |
173 | }
174 | {canClose
175 | <&&> {
177 | e->ReactEvent.Synthetic.stopPropagation;
178 | Close(tab.id)->dispatch;
179 | }}
180 | className={Styles.roundedIconButton(theme, depth, active)}>
181 |
182 | }
183 |
184 | ;
185 | };
186 | };
187 |
188 | [@react.component]
189 | let make =
190 | (
191 | ~standalone=true,
192 | ~activeTabId: string,
193 | ~tabs: array(t),
194 | ~theme,
195 | ~depth=0,
196 | ~onAdd=?,
197 | ~onMove=?,
198 | ~onOpen=?,
199 | ~onRename=?,
200 | ~onClose=?,
201 | ~addButtonText=?,
202 | ~onDuplicate=?,
203 | ) => {
204 | let onDispatch = action =>
205 | (
206 | switch (action) {
207 | | Add(x) => Belt.Option.map(onAdd, Lib.Function.apply(x))
208 | | Open(x) => Belt.Option.map(onOpen, Lib.Function.apply(x))
209 | | Close(x) => Belt.Option.map(onClose, Lib.Function.apply(x))
210 | | Rename(x) =>
211 | Belt.Option.map(
212 | onRename,
213 | Lib.Function.apply(x.title === "" ? {...x, title: "Untitled"} : x),
214 | )
215 | | Duplicate(x) => Belt.Option.map(onDuplicate, Lib.Function.apply(x))
216 | }
217 | )
218 | ->ignore;
219 |
220 | let onDragEnd = (e: Dnd.Context.dragEndEvent) => {
221 | let move =
222 | e.destination
223 | ->Js.Nullable.toOption
224 | ->Belt.Option.map(d => (e.source.index, d.index));
225 |
226 | switch (move, onMove) {
227 | | (Some((from, to_)), Some(fn)) => fn((from, to_))
228 | | _ => ()
229 | };
230 | };
231 |
232 | let onDragStart = (e: Dnd.Context.dragStartEvent) =>
233 | Belt.Option.map(onOpen, fn => {
234 | Belt.Array.get(tabs, e.source.index)
235 | ->Belt.Option.map((x => x.id) >> Lib.Function.applyF(fn))
236 | })
237 | ->ignore;
238 |
239 |
240 |
241 |
242 |
243 | {tabs
244 | ->Belt.Array.mapWithIndex((index, tab) =>
245 |
Belt.Option.isSome,
254 | onClose->Belt.Option.isSome,
255 | onRename->Belt.Option.isSome,
256 | onDuplicate->Belt.Option.isSome,
257 | onMove->Belt.Option.isSome,
258 | )
259 | dispatch=onDispatch
260 | active={tab.id === activeTabId}
261 | />
262 | )
263 | ->React.array}
264 | {onAdd->Belt.Option.isSome
265 | <&&> }
272 |
273 |
274 |
275 |
;
276 | };
277 |
--------------------------------------------------------------------------------
/stories/card.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
3 | import { make as Button, Group } from '../src/Button.bs.js'
4 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
5 | import { useDarkMode } from 'storybook-dark-mode'
6 | import { make as Card, Tabbed } from '../src/Card.bs.js'
7 | import { Helpers } from '../src/Tabs.bs.js'
8 | import { light, dark } from '../src/UiTypes.bs'
9 | import { tiny, large, huge } from '../src/CardStyles.bs'
10 |
11 | const TabbedCard = Tabbed.make
12 | export default {
13 | title: 'Card',
14 | }
15 |
16 | const cardContainer = {
17 | width: '20%',
18 | height: '500px',
19 | margin: '1rem',
20 | display: 'inline-block',
21 | }
22 |
23 | const smallCardContainer = {
24 | ...cardContainer,
25 | height: '200px',
26 | }
27 |
28 | const smallCardContainerWide = {
29 | ...smallCardContainer,
30 | width: '80%',
31 | }
32 |
33 | const nestedCardContainer = {
34 | ...cardContainer,
35 | width: '40%',
36 | height: 'unset',
37 | }
38 |
39 | const headerStyles = {
40 | padding: '0 1.5rem',
41 | }
42 |
43 | const footerStyles = {
44 | width: '100%',
45 | padding: '0 0.5rem',
46 | display: 'flex',
47 | justifyContent: 'flex-end',
48 | }
49 |
50 | const margin = {
51 | margin: '1rem',
52 | }
53 |
54 | export const card = () => {
55 | const theme = useDarkMode() ? dark : light
56 | return (
57 |
58 |
59 | Cards
60 | Interface
61 |
66 | {`type Card: (
67 | ~onMouseOut: option(ReactEvent.Mouse.t -> unit),
68 | ~onMouseOver: option(ReactEvent.Mouse.t -> unit),
69 | ~spacing: option(CardStyles.spacing),
70 | ~theme: option(UiTypes.theme),
71 | ~depth: option(int),
72 | ~header: option(React.Element),
73 | ~footer: option(React.Element),
74 | ~children: option(React.Element),
75 | ~className: option(string),
76 | ) => React.element;`}
77 |
78 |
79 | Preview
80 | Padding
81 |
82 |
83 | Tiny Padding
84 |
85 |
86 |
87 |
88 | Normal Padding
89 |
90 |
91 |
92 |
93 | Large Padding
94 |
95 |
96 |
97 |
98 | Huge Padding
99 |
100 |
101 |
102 | Nesting
103 |
104 | Using the 'Depth' Parameter, one can stack multiple cards.
105 | The stacking here actuall starts with the background, which
106 | is one of our cards.
107 |
108 |
109 |
110 | No. 1
111 |
112 | No. 2
113 |
114 | Cards all the way down
115 |
116 |
117 |
118 |
119 |
120 | Header / Footer
121 |
122 | One can pass in a header and footer component. To be as
123 | flexible as possible here, we do not implement any spacing
124 | options here. The component passed into the header should
125 | place itself according to the space available
126 |
127 |
128 |
131 | Some Header
132 |
133 | }
134 | footer={
135 |
136 |
Some Footer
137 |
138 | }
139 | spacing={tiny}
140 | depth={2}
141 | theme={theme}
142 | >
143 | No. 1
144 | Some text inside here still
145 |
146 |
147 |
148 |
151 | Login
152 |
153 | }
154 | footer={
155 |
156 | Login
157 |
158 | }
159 | spacing={tiny}
160 | depth={2}
161 | theme={theme}
162 | >
163 | Some forms could go here...
164 |
165 |
166 |
167 |
168 | )
169 | }
170 |
171 | export const cardWithTabs = () => {
172 | const theme = useDarkMode() ? dark : light
173 | const [tabs, setTabs] = React.useState([
174 | {
175 | id: 'one',
176 | title: 'foo',
177 | },
178 | {
179 | id: 'two',
180 | title: 'bar',
181 | },
182 | {
183 | id: 'three',
184 | title: 'baz',
185 | },
186 | ])
187 | const [activeTabId, setActiveTabId] = React.useState('one')
188 | return (
189 |
190 |
191 | Tabbed Card
192 | Interface
193 |
198 | {`type Card: (
199 | ~onMouseOver: option(ReactEvent.Mouse.t => unit),
200 | ~onMouseOut: option(ReactEvent.Mouse.t => unit),
201 | ~spacing: option(CardStyles.spacing),
202 | ~theme: option(UiTypes.theme),
203 | ~depth: option(int),
204 | ~className: option(string),
205 | ~footer: option(React.element),
206 | ~children: option(React.element),
207 | /* Tab specific */
208 | ~activeTabId: string, /* tabId */
209 | ~tabs: array(Tabs.t),
210 | ~onMove: option(((int, int)) => unit), /* (fromIdx, toIdx) */
211 | ~onOpen: option(string => 'a), /* tabId */
212 | ~onClose: option(string => 'a), /* tabId */
213 | ~onDuplicate: option(string => 'a), /* tabId */
214 | ~onRename: option(Tabs.t => 'a),
215 | ) => React.element;`}
216 |
217 |
218 | Preview
219 |
220 | Tabs support a number of callbacks:
221 |
222 | Open a tab (onMove)
223 | Close a tab (onClose)
224 | Duplicate a tab (onDuplicate)
225 | Rename a tab (onRename)
226 | Move a tab (via drag 'n drop) (onMove)
227 |
228 | Features can be disabled by omitting the callback. I.e if
229 | there is no function to rename the tab, the text won't be
230 | editable.
231 |
232 |
233 |
{
235 | setTabs((tabs) => Helpers.add(tabs, newTab))
236 | setActiveTabId(newTab.id)
237 | }}
238 | onOpen={setActiveTabId}
239 | onMove={([from, to]) => {
240 | setTabs(Helpers.move(tabs, from, to))
241 | }}
242 | onRename={(x) => {
243 | setTabs((tabs) => Helpers.update(tabs, x))
244 | }}
245 | onClose={(x) => {
246 | const tabIdx = tabs.findIndex((y) => y.id === x)
247 | console.log(tabIdx, x)
248 | if (tabs[tabIdx + 1]) {
249 | setActiveTabId(tabs[tabIdx + 1].id)
250 | } else if (tabs[tabIdx - 1]) {
251 | setActiveTabId(tabs[tabIdx - 1].id)
252 | } else {
253 | setActiveTabId('')
254 | }
255 | setTabs((tabs) => Helpers.removeById(tabs, x))
256 | }}
257 | onDuplicate={(x) => {
258 | const [newTabs, newId] = Helpers.duplicateById(
259 | tabs,
260 | x
261 | )
262 | setActiveTabId(newId)
263 | setTabs(newTabs)
264 | }}
265 | tabs={tabs}
266 | activeTabId={activeTabId}
267 | spacing={tiny}
268 | depth={2}
269 | theme={theme}
270 | >
271 | Tabs
272 |
273 | Active Tab:{' '}
274 | {tabs.find((x) => x.id === activeTabId).title}
275 |
276 |
277 |
278 | A Tabbed card with almost no features enabled
279 |
280 |
288 | Tabs
289 |
290 | Active Tab:{' '}
291 | {tabs.find((x) => x.id === activeTabId).title}
292 |
293 |
294 |
295 |
296 |
297 | )
298 | }
299 |
--------------------------------------------------------------------------------
/stories/tabs.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
3 | import { make as Button, Group } from '../src/Button.bs.js'
4 | import { okaidia } from 'react-syntax-highlighter/dist/esm/styles/prism'
5 | import { useDarkMode } from 'storybook-dark-mode'
6 | import { make as Card, Tabbed } from '../src/Card.bs.js'
7 | import { make as Tabs, Helpers } from '../src/Tabs.bs.js'
8 | import { light, dark } from '../src/UiTypes.bs'
9 | import { tiny, large, huge } from '../src/CardStyles.bs'
10 |
11 | export default {
12 | title: 'Tabs',
13 | }
14 |
15 | const cardContainer = {
16 | width: '20%',
17 | height: '500px',
18 | margin: '1rem',
19 | display: 'inline-block',
20 | }
21 |
22 | const smallCardContainer = {
23 | ...cardContainer,
24 | height: '200px',
25 | }
26 |
27 | const smallCardContainerWide = {
28 | width: '20%',
29 | margin: '1rem',
30 | display: 'inline-block',
31 | width: '80%',
32 | }
33 |
34 | const nestedCardContainer = {
35 | ...cardContainer,
36 | width: '40%',
37 | height: 'unset',
38 | }
39 |
40 | const headerStyles = {
41 | padding: '0 1.5rem',
42 | }
43 |
44 | const footerStyles = {
45 | width: '100%',
46 | padding: '0 0.5rem',
47 | display: 'flex',
48 | justifyContent: 'flex-end',
49 | }
50 |
51 | const margin = {
52 | margin: '1rem',
53 | }
54 |
55 | export const tabs = () => {
56 | const theme = useDarkMode() ? dark : light
57 | const [tabs, setTabs] = React.useState([
58 | {
59 | id: 'one',
60 | title: 'foo',
61 | },
62 | {
63 | id: 'two',
64 | title: 'bar',
65 | },
66 | {
67 | id: 'three',
68 | title: 'baz',
69 | },
70 | ])
71 | const [activeTabId, setActiveTabId] = React.useState('one')
72 | return (
73 |
74 |
75 | Tabbed Card
76 | Interface
77 |
82 | {`type tab = {
83 | id: string,
84 | title: string,
85 | };
86 |
87 | type Tabs: (
88 | ~activeTabId: string;
89 | ~depth: option(int);
90 | ~addButtonText: option(string); // Defaults to "New"
91 | ~onAdd: option(t -> unit);
92 | ~onClose: option(string -> unit);
93 | ~onDuplicate: option(string -> unit);
94 | ~onMove: option(( int, int ) -> unit);
95 | ~onOpen: option(string -> unit);
96 | ~onRename: option(t -> unit);
97 | ~standalone: option(bool);
98 | ~tabs: array(t);
99 | ~theme: UiTypes.theme;
100 | ) => React.element;`}
101 |
102 |
103 | Preview
104 |
105 | Tabs support a number of callbacks:
106 |
107 | Add a tab (onAdd)
108 | Open a tab (onMove)
109 | Close a tab (onClose)
110 | Duplicate a tab (onDuplicate)
111 | Rename a tab (onRename)
112 | Move a tab (via drag 'n drop) (onMove)
113 |
114 | Features can be disabled by omitting the callback. I.e if
115 | there is no function to rename the tab, the text won't be
116 | editable.
117 |
118 | All Functionality Enabled
119 |
120 |
{
122 | setTabs((tabs) => Helpers.add(tabs, newTab))
123 | setActiveTabId(newTab.id)
124 | }}
125 | onOpen={setActiveTabId}
126 | onMove={([from, to]) => {
127 | setTabs(Helpers.move(tabs, from, to))
128 | }}
129 | onRename={(x) => {
130 | setTabs((tabs) => Helpers.update(tabs, x))
131 | }}
132 | onClose={(x) => {
133 | const tabIdx = tabs.findIndex((y) => y.id === x)
134 | console.log(tabIdx, x)
135 | if (tabs[tabIdx + 1]) {
136 | setActiveTabId(tabs[tabIdx + 1].id)
137 | } else if (tabs[tabIdx - 1]) {
138 | setActiveTabId(tabs[tabIdx - 1].id)
139 | } else {
140 | setActiveTabId('')
141 | }
142 | setTabs((tabs) => Helpers.removeById(tabs, x))
143 | }}
144 | onDuplicate={(x) => {
145 | const [newTabs, newId] = Helpers.duplicateById(
146 | tabs,
147 | x
148 | )
149 | setActiveTabId(newId)
150 | setTabs(newTabs)
151 | }}
152 | tabs={tabs}
153 | activeTabId={activeTabId}
154 | depth={2}
155 | theme={theme}
156 | >
157 | Tabs
158 |
159 | Active Tab:{' '}
160 | {tabs.find((x) => x.id === activeTabId).title}
161 |
162 |
163 |
164 | All Functionality Enabled - hiding add text
165 |
166 |
{
169 | setTabs((tabs) => Helpers.add(tabs, newTab))
170 | setActiveTabId(newTab.id)
171 | }}
172 | onOpen={setActiveTabId}
173 | onMove={([from, to]) => {
174 | setTabs(Helpers.move(tabs, from, to))
175 | }}
176 | onRename={(x) => {
177 | setTabs((tabs) => Helpers.update(tabs, x))
178 | }}
179 | onClose={(x) => {
180 | const tabIdx = tabs.findIndex((y) => y.id === x)
181 | console.log(tabIdx, x)
182 | if (tabs[tabIdx + 1]) {
183 | setActiveTabId(tabs[tabIdx + 1].id)
184 | } else if (tabs[tabIdx - 1]) {
185 | setActiveTabId(tabs[tabIdx - 1].id)
186 | } else {
187 | setActiveTabId('')
188 | }
189 | setTabs((tabs) => Helpers.removeById(tabs, x))
190 | }}
191 | onDuplicate={(x) => {
192 | const [newTabs, newId] = Helpers.duplicateById(
193 | tabs,
194 | x
195 | )
196 | setActiveTabId(newId)
197 | setTabs(newTabs)
198 | }}
199 | tabs={tabs}
200 | activeTabId={activeTabId}
201 | depth={2}
202 | theme={theme}
203 | >
204 | Tabs
205 |
206 | Active Tab:{' '}
207 | {tabs.find((x) => x.id === activeTabId).title}
208 |
209 |
210 |
211 | No Duplication
212 |
213 |
{
216 | setTabs(Helpers.move(tabs, from, to))
217 | }}
218 | onRename={(x) => {
219 | setTabs((tabs) => Helpers.update(tabs, x))
220 | }}
221 | onClose={(x) => {
222 | const tabIdx = tabs.findIndex((y) => y.id === x)
223 | console.log(tabIdx, x)
224 | if (tabs[tabIdx + 1]) {
225 | setActiveTabId(tabs[tabIdx + 1].id)
226 | } else if (tabs[tabIdx - 1]) {
227 | setActiveTabId(tabs[tabIdx - 1].id)
228 | } else {
229 | setActiveTabId('')
230 | }
231 | setTabs((tabs) => Helpers.removeById(tabs, x))
232 | }}
233 | tabs={tabs}
234 | activeTabId={activeTabId}
235 | depth={2}
236 | theme={theme}
237 | >
238 | Tabs
239 |
240 | Active Tab:{' '}
241 | {tabs.find((x) => x.id === activeTabId).title}
242 |
243 |
244 |
245 | No Duplication or Moving
246 |
247 |
{
250 | setTabs((tabs) => Helpers.update(tabs, x))
251 | }}
252 | onClose={(x) => {
253 | const tabIdx = tabs.findIndex((y) => y.id === x)
254 | console.log(tabIdx, x)
255 | if (tabs[tabIdx + 1]) {
256 | setActiveTabId(tabs[tabIdx + 1].id)
257 | } else if (tabs[tabIdx - 1]) {
258 | setActiveTabId(tabs[tabIdx - 1].id)
259 | } else {
260 | setActiveTabId('')
261 | }
262 | setTabs((tabs) => Helpers.removeById(tabs, x))
263 | }}
264 | tabs={tabs}
265 | activeTabId={activeTabId}
266 | depth={2}
267 | theme={theme}
268 | >
269 | Tabs
270 |
271 | Active Tab:{' '}
272 | {tabs.find((x) => x.id === activeTabId).title}
273 |
274 |
275 |
276 | No Duplication or Moving or Renaming
277 |
278 |
{
281 | const tabIdx = tabs.findIndex((y) => y.id === x)
282 | console.log(tabIdx, x)
283 | if (tabs[tabIdx + 1]) {
284 | setActiveTabId(tabs[tabIdx + 1].id)
285 | } else if (tabs[tabIdx - 1]) {
286 | setActiveTabId(tabs[tabIdx - 1].id)
287 | } else {
288 | setActiveTabId('')
289 | }
290 | setTabs((tabs) => Helpers.removeById(tabs, x))
291 | }}
292 | tabs={tabs}
293 | activeTabId={activeTabId}
294 | depth={2}
295 | theme={theme}
296 | >
297 | Tabs
298 |
299 | Active Tab:{' '}
300 | {tabs.find((x) => x.id === activeTabId).title}
301 |
302 |
303 |
304 | Only Opening
305 |
306 |
313 | Tabs
314 |
315 | Active Tab:{' '}
316 | {tabs.find((x) => x.id === activeTabId).title}
317 |
318 |
319 |
320 |
321 |
322 | )
323 | }
324 |
325 | export const TabHelpers = () => {
326 | const theme = useDarkMode() ? dark : light
327 | return (
328 |
329 | Helper Functions
330 |
331 | These helper functions should abstract away most of the logic of
332 | updating the underlying array of tabs. They are quite self
333 | explanatory given the name and type signature.
334 |
335 |
340 | {`type create: string -> t
341 | type createMany: array(string) -> array(t)
342 | type add: (array(t), t) -> array(t)
343 | type update: (array(t), t) -> array(t)
344 | type removeById: (array(t), string) -> array(t)
345 | type removeByIndex: (array(t), int) -> array(t)
346 | type duplicateById: (array(t), string) -> array(t)
347 | type duplicateByIdx: (array(t), int) -> array(t)
348 | type move : (array(t), int, int) -> array(t) /* from / to */`}
349 |
350 |
351 | )
352 | }
353 |
--------------------------------------------------------------------------------