diff --git a/src/standalone/plugins/top-bar/assets/lightbulb-off.svg b/src/standalone/plugins/top-bar/assets/lightbulb-off.svg new file mode 100644 index 00000000000..19b398fa6a7 --- /dev/null +++ b/src/standalone/plugins/top-bar/assets/lightbulb-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/standalone/plugins/top-bar/assets/lightbulb.svg b/src/standalone/plugins/top-bar/assets/lightbulb.svg new file mode 100644 index 00000000000..d9bfec1b070 --- /dev/null +++ b/src/standalone/plugins/top-bar/assets/lightbulb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/standalone/plugins/top-bar/components/DarkMode.jsx b/src/standalone/plugins/top-bar/components/DarkMode.jsx new file mode 100644 index 00000000000..cfe3f21a753 --- /dev/null +++ b/src/standalone/plugins/top-bar/components/DarkMode.jsx @@ -0,0 +1,27 @@ +/** + * @prettier + */ +import React, { useState, useCallback } from "react" +import LightBulb from "../assets/lightbulb.svg" +import LightBulbOff from "../assets/lightbulb-off.svg" + +const LightBulbIcon = () => { + const [isDarkMode, setIsDarkMode] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches) + + const toggleDarkMode = useCallback(() => { + document.documentElement.classList.toggle("dark") + setIsDarkMode((prevState) => !prevState) // Toggle the state + }, []) + + return ( +
+ {!isDarkMode ? ( + + ) : ( + + )} +
+ ) +} + +export default LightBulbIcon diff --git a/src/standalone/plugins/top-bar/components/TopBar.jsx b/src/standalone/plugins/top-bar/components/TopBar.jsx index 1344d347939..16e512980a4 100644 --- a/src/standalone/plugins/top-bar/components/TopBar.jsx +++ b/src/standalone/plugins/top-bar/components/TopBar.jsx @@ -113,6 +113,7 @@ class TopBar extends React.Component { const Button = getComponent("Button") const Link = getComponent("Link") const Logo = getComponent("Logo") + const DarkMode = getComponent("DarkMode") let isLoading = specSelectors.loadingStatus() === "loading" let isFailed = specSelectors.loadingStatus() === "failed" @@ -164,6 +165,7 @@ class TopBar extends React.Component {
{control.map((el, i) => cloneElement(el, { key: i }))}
+ diff --git a/src/standalone/plugins/top-bar/index.js b/src/standalone/plugins/top-bar/index.js index 03092fc692d..b46592bbe81 100644 --- a/src/standalone/plugins/top-bar/index.js +++ b/src/standalone/plugins/top-bar/index.js @@ -3,9 +3,10 @@ */ import TopBar from "./components/TopBar" import Logo from "./components/Logo" +import DarkMode from "./components/DarkMode" const TopBarPlugin = () => ({ - components: { Topbar: TopBar, Logo }, + components: { Topbar: TopBar, Logo, DarkMode }, }) export default TopBarPlugin diff --git a/src/style/_themeDark.scss b/src/style/_themeDark.scss new file mode 100644 index 00000000000..5bdd93a6c96 --- /dev/null +++ b/src/style/_themeDark.scss @@ -0,0 +1,172 @@ +// Variables for consistent theme +$text_color_1: #ffffffde; +$text_color_2: #f1f1f1e5; +$text_color_3: #f1f1f16e; + +$background_main: #141316; +$background_topBar: #222129; +$background_body: #0f0e14; +$background_schemeCont: #16151a; + +$opblock_colors: ( + post: (#0025142e, #09633a, #16231f99), + deprecated: (#1b161e, #594d69, #29242f), + put: (#a1661e1f, #fca13096, #412d24), + get: (#3f78b31f, #5a99d99c, #355d855e), + delete: (#77191933, #993434, #511c1ccc), +); + +$models_colors: ( + background: #1f2027, + border: #4b507175, + item_background: #2e2e35d9, + item_hover: #37373f, +); + +html.dark { + background: $background_main; + + + .swagger-ui { + color: $text_color_2; + background: $background_main; + + .expand-operation, + .authorization__btn svg, + .expand-operation svg, + .opblock-control-arrow svg { + fill: $text_color_2; + } + + a.nostyle, + a.nostyle:visited, + .opblock-tag small { + color: $text_color_1; + } + + .info { + h1, h2, h3, h4, h5, .title { + color: $text_color_1; + } + li, p, table { + color: $text_color_2; + } + .base-url { + color: $text_color_3; + } + } + + .topbar { + background: $background_topBar; + .download-url-wrapper .download-url-button { + color: $text_color_2; + } + } + + .scheme-container { + background: $background_schemeCont; + .schemes > .schemes-server-container > label { + color: $text_color_2; + } + } + + table thead tr { + td, th { + color: $text_color_2; + } + } + + // OPblock styles + @each $type, $colors in $opblock_colors { + .opblock-#{$type} { + background: nth($colors, 1); + border-color: nth($colors, 2); + .opblock-section-header { + background-color: nth($colors, 3); + } + .opblock-summary { + border-color: nth($colors, 2); + } + } + } + + .opblock { + .opblock-section-header { + h4 { + color: $text_color_1; + } + label { + color: $text_color_3; + } + .btn { + color: $text_color_2; + border-color: $text_color_3; + box-shadow: none; + } + } + + .model-box { + background: map-get($models_colors, background); + border-color: map-get($models_colors, border); + .model-title { + color: $text_color_1; + } + .model { + color: $text_color_2; + } + } + + .markdown p, + .markdown pre, + .renderedMarkdown p, + .renderedMarkdown pre { + color: $text_color_2; + } + + .opblock-summary-operation-id, + .opblock-summary-path, + .opblock-summary-path__deprecated, + .opblock-summary-description, + .description-wrapper, + .opblock-external-docs-wrapper, + .opblock-title_normal, + .parameter__name, + .parameter__type, + .parameter__in, + .response-col_status { + color: $text_color_3; + } + } + + section.models { + background: map-get($models_colors, background); + border-color: map-get($models_colors, border); + + h4 span, + .model-title { + color: $text_color_1; + } + + .models-control svg { + fill: $text_color_2; + } + + .model-container { + background: map-get($models_colors, item_background); + &:hover { + background: map-get($models_colors, item_hover); + } + } + + .model { + color: $text_color_3; + } + } + + .model-box-control:focus, + .models-control:focus, + .opblock-summary-control:focus { + outline: none; + } + } +} diff --git a/src/style/_topbar.scss b/src/style/_topbar.scss index da3196030f1..48fac1673f4 100644 --- a/src/style/_topbar.scss +++ b/src/style/_topbar.scss @@ -1,7 +1,6 @@ .topbar { padding: 10px 0; - background-color: $topbar-background-color; .topbar-wrapper { @@ -22,13 +21,10 @@ { font-size: 1.5em; font-weight: bold; - display: flex; align-items: center; flex: 1; - max-width: 300px; - text-decoration: none; @include text_headline($topbar-link-font-color); @@ -45,6 +41,8 @@ display: flex; flex: 3; justify-content: flex-end; + max-width: 600px; + margin-left: auto; input[type=text] { @@ -110,4 +108,19 @@ width: 100%; } } + + .dark-toggle { + margin-left: 10px; + opacity: 0.8; + transition: all .2s; + svg { + fill: #f1f1f1cb; + } + &:hover { + opacity: 1; + svg { + fill: #ffff0081; + } + } + } } diff --git a/src/style/main.scss b/src/style/main.scss index 413786180c3..28e6d9f7e9a 100644 --- a/src/style/main.scss +++ b/src/style/main.scss @@ -21,3 +21,6 @@ @import '../core/plugins/json-schema-2020-12/components/all'; @import '../core/plugins/oas31/components/all'; } + +// Themes must live outside of the '.swagger-ui' class to target HTML parent. +@import 'themeDark'; diff --git a/test/unit/components/darkMode.jsx b/test/unit/components/darkMode.jsx new file mode 100644 index 00000000000..e890be8f716 --- /dev/null +++ b/test/unit/components/darkMode.jsx @@ -0,0 +1,32 @@ +import React from "react" +import { mount } from "enzyme" +import DarkMode from "../../../src/standalone/plugins/top-bar/components/DarkMode" + +// Mock SVG imports +jest.mock("../../../src/assets/lightbulb.svg", () => () =>
LightBulb
) +jest.mock("../../../src/assets/lightbulb-off.svg", () => () =>
LightBulbOff
) + +describe("LightBulbIcon Component", () => { + it("toggles the dark class on the html element and switches icons on click", () => { + // Mount the component + const wrapper = mount() + + // Access the root html element + const htmlElement = document.documentElement + + // Ensure initial state no "dark" class + expect(htmlElement.classList.contains("dark")).toBe(false) + + // Simulate the first click + wrapper.find(".dark-toggle").simulate("click") + + // After the first click "dark" class is added + expect(htmlElement.classList.contains("dark")).toBe(true) + + // Simulate the second click + wrapper.find(".dark-toggle").simulate("click") + + // After the second click "dark" class is removed + expect(htmlElement.classList.contains("dark")).toBe(false) + }) +})