Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
Andrey Antukh 2024-12-12 10:56:20 +01:00
commit 1c76587d70
19 changed files with 1291 additions and 317 deletions

View file

@ -9,6 +9,7 @@
[app.config :as cf]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
@ -38,6 +39,7 @@
:Loader loader*
:RawSvg raw-svg*
:Select select*
:Combobox combobox*
:Text text*
:TabSwitcher tab-switcher*
:Toast toast*

View file

@ -0,0 +1,252 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.ds.controls.combobox
(:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl])
(:require
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i]
[app.util.array :as array]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[rumext.v2 :as mf]))
(def listbox-id-index (atom 0))
(defn- get-option
[options id]
(array/find #(= id (obj/get % "id")) options))
(defn- handle-focus-change
[options focused* new-index options-nodes-refs]
(let [option (aget options new-index)
id (obj/get option "id")
nodes (mf/ref-val options-nodes-refs)
node (obj/get nodes id)]
(reset! focused* id)
(dom/scroll-into-view-if-needed! node)))
(defn- handle-selection
[focused* selected* open*]
(when-let [focused (deref focused*)]
(reset! selected* focused))
(reset! open* false)
(reset! focused* nil))
(def ^:private schema:combobox-option
[:and
[:map {:title "option"}
[:id :string]
[:icon {:optional true}
[:and :string [:fn #(contains? icon-list %)]]]
[:label {:optional true} :string]
[:aria-label {:optional true} :string]]
[:fn {:error/message "invalid data: missing required props"}
(fn [option]
(or (and (contains? option :icon)
(or (contains? option :label)
(contains? option :aria-label)))
(contains? option :label)))]])
(def ^:private schema:combobox
[:map
[:options [:vector {:min 1} schema:combobox-option]]
[:class {:optional true} :string]
[:disabled {:optional true} :boolean]
[:default-selected {:optional true} :string]
[:on-change {:optional true} fn?]])
(mf/defc combobox*
{::mf/props :obj
::mf/schema schema:combobox}
[{:keys [options class disabled default-selected on-change] :rest props}]
(let [open* (mf/use-state false)
open (deref open*)
selected* (mf/use-state default-selected)
selected (deref selected*)
focused* (mf/use-state nil)
focused (deref focused*)
has-focus* (mf/use-state false)
has-focus (deref has-focus*)
dropdown-options
(mf/use-memo
(mf/deps options selected)
(fn []
(->> options
(array/filter (fn [option]
(let [lower-option (.toLowerCase (obj/get option "id"))
lower-filter (.toLowerCase selected)]
(.includes lower-option lower-filter)))))))
on-click
(mf/use-fn
(mf/deps disabled)
(fn [event]
(dom/stop-propagation event)
(when-not disabled
(reset! has-focus* true)
(if (= "INPUT" (.-tagName (.-target event)))
(reset! open* true)
(swap! open* not)))))
on-option-click
(mf/use-fn
(mf/deps on-change)
(fn [event]
(let [node (dom/get-current-target event)
id (dom/get-data node "id")]
(reset! selected* id)
(reset! focused* nil)
(reset! open* false)
(when (fn? on-change)
(on-change id)))))
options-nodes-refs (mf/use-ref nil)
options-ref (mf/use-ref nil)
listbox-id-ref (mf/use-ref (dm/str "listbox-" (swap! listbox-id-index inc)))
listbox-id (mf/ref-val listbox-id-ref)
combobox-ref (mf/use-ref nil)
set-ref
(mf/use-fn
(fn [node id]
(let [refs (or (mf/ref-val options-nodes-refs) #js {})
refs (if node
(obj/set! refs id node)
(obj/unset! refs id))]
(mf/set-ref-val! options-nodes-refs refs))))
on-blur
(mf/use-fn
(fn [event]
(let [target (.-relatedTarget event)
outside? (not (.contains (mf/ref-val combobox-ref) target))]
(when outside?
(reset! focused* nil)
(reset! open* false)
(reset! has-focus* false)))))
on-key-down
(mf/use-fn
(mf/deps open focused disabled dropdown-options)
(fn [event]
(when-not disabled
(let [options dropdown-options
focused (deref focused*)
len (alength options)
index (array/find-index #(= (deref focused*) (obj/get % "id")) options)]
(dom/stop-propagation event)
(when (< len 0)
(reset! index len))
(cond
(and (not open) (kbd/down-arrow? event))
(reset! open* true)
open
(cond
(kbd/home? event)
(handle-focus-change options focused* 0 options-nodes-refs)
(kbd/up-arrow? event)
(let [new-index (if (= index -1)
(dec len)
(mod (- index 1) len))]
(handle-focus-change options focused* new-index options-nodes-refs))
(kbd/down-arrow? event)
(let [new-index (if (= index -1)
0
(mod (+ index 1) len))]
(handle-focus-change options focused* new-index options-nodes-refs))
(or (kbd/space? event) (kbd/enter? event))
(when (deref open*)
(dom/prevent-default event)
(handle-selection focused* selected* open*)
(when (fn? on-change)
(on-change focused)))
(kbd/esc? event)
(do (reset! open* false)
(reset! focused* nil))))))))
on-input-change
(mf/use-fn
(fn [event]
(let [value (.-value (.-currentTarget event))]
(reset! selected* value)
(reset! focused* nil)
(when (fn? on-change)
(on-change value)))))
on-focus
(mf/use-fn
(fn [_] (reset! has-focus* true)))
class (dm/str class " " (stl/css :combobox))
selected-option (get-option options selected)
icon (obj/get selected-option "icon")]
(mf/with-effect [options]
(mf/set-ref-val! options-ref options))
[:div {:ref combobox-ref
:class (stl/css-case
:combobox-wrapper true
:focused has-focus)}
[:div {:class class
:on-click on-click
:on-focus on-focus
:on-blur on-blur}
[:span {:class (stl/css-case :combobox-header true
:header-icon (some? icon))}
(when icon
[:> icon* {:id icon
:size "s"
:aria-hidden true}])
[:input {:type "text"
:role "combobox"
:aria-autocomplete "both"
:aria-expanded open
:aria-controls listbox-id
:aria-activedescendant focused
:class (stl/css :input)
:data-testid "combobox-input"
:disabled disabled
:value selected
:on-change on-input-change
:on-key-down on-key-down}]]
[:> :button {:tab-index "-1"
:aria-expanded open
:aria-controls listbox-id
:class (stl/css :button-toggle-list)
:on-click on-click}
[:> icon* {:id i/arrow
:class (stl/css :arrow)
:size "s"
:aria-hidden true
:data-testid "combobox-open-button"}]]]
(when (and open (seq dropdown-options))
[:> options-dropdown* {:on-click on-option-click
:options dropdown-options
:selected selected
:focused focused
:set-ref set-ref
:id listbox-id
:data-testid "combobox-options"}])]))

View file

@ -0,0 +1,62 @@
import { Canvas, Meta } from "@storybook/blocks";
import * as ComboboxStories from "./combobox.stories";
<Meta title="Controls/Combobox" />
# Combobox
Combobox lets users choose one option from an options menu or enter a custom value that is not listed in the menu. It combines the functionality of a dropdown menu and an input field, allowing for both selection and free-form input.
## Variants
**Text**: We will use this variant when there are enough space and icons don't add any useful context.
<Canvas of={ComboboxStories.Default} />
**Icon and text**: We will use this variant when there are enough space and icons add any useful context.
<Canvas of={ComboboxStories.WithIcons} />
## Technical notes
### Icons
Each option of `combobox*` may accept an `icon`, which must contain an [icon ID](../foundations/assets/icon.mdx).
These are available in the `app.main.ds.foundations.assets.icon` namespace.
```clj
(ns app.main.ui.foo
(:require
[app.main.ui.ds.foundations.assets.icon :as i]))
```
```clj
[:> combobox*
{:options [{ :label "Code"
:id "option-code"
:icon i/fill-content }
{ :label "Design"
:id "option-design"
:icon i/pentool }
{ :label "Menu"
:id "option-menu" }
]}]
```
<Canvas of={ComboboxStories.WithIcons} />
## Usage guidelines (design)
### Where to Use
Combobox is used in applications where users need to select from a range of text-based options or enter custom input.
### When to Use
Consider using a combobox when you have five or more options to present, and users may benefit from the ability to search or input a custom value that is not in the predefined list.
### Interaction / Behavior
- **Opening Options**: When the user clicks on the input area, a dropdown menu of options appears. Users can either scroll through the options, type to filter them, or input a new value directly.
- **Selecting an Option**: Once an option is selected or a custom value is entered, the dropdown closes, and the input field displays the chosen value.
- **Keyboard Support**: Combobox supports navigation using keyboard input, including arrow keys to navigate the list and Enter to make a selection.

View file

@ -0,0 +1,87 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "../_borders.scss" as *;
@use "../_sizes.scss" as *;
@use "../typography.scss" as *;
.combobox-wrapper {
--combobox-icon-fg-color: var(--color-foreground-secondary);
--combobox-fg-color: var(--color-foreground-primary);
--combobox-bg-color: var(--color-background-tertiary);
--combobox-outline-color: none;
--combobox-border-color: none;
@include use-typography("body-small");
position: relative;
display: grid;
grid-template-rows: auto;
gap: var(--sp-xxs);
width: 100%;
&:hover {
--combobox-bg-color: var(--color-background-quaternary);
}
}
.combobox {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--sp-xs);
height: $sz-32;
width: 100%;
padding: var(--sp-s);
border: none;
border-radius: $br-8;
outline: $b-1 solid var(--combobox-outline-color);
border: $b-1 solid var(--combobox-border-color);
background: var(--combobox-bg-color);
color: var(--combobox-fg-color);
appearance: none;
&:disabled {
--combobox-bg-color: var(--color-background-primary);
--combobox-border-color: var(--color-background-quaternary);
--combobox-fg-color: var(--color-foreground-secondary);
}
}
.focused {
--combobox-outline-color: var(--color-accent-primary);
}
.arrow {
color: var(--combobox-icon-fg-color);
transform: rotate(90deg);
}
.combobox-header {
display: grid;
justify-items: start;
gap: var(--sp-xs);
}
.input {
all: unset;
@include use-typography("body-small");
background-color: transparent;
overflow: hidden;
text-align: left;
inline-size: 100%;
padding-inline-start: var(--sp-xxs);
color: var(--combobox-fg-color);
}
.header-icon {
grid-template-columns: auto 1fr;
color: var(--combobox-icon-fg-color);
}
.button-toggle-list {
all: unset;
display: flex;
}

View file

@ -0,0 +1,216 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import * as React from "react";
import Components from "@target/components";
import { userEvent, within, expect } from "@storybook/test";
const { Combobox } = Components;
let lastValue = null;
export default {
title: "Controls/Combobox",
component: Combobox,
argTypes: {
disabled: { control: "boolean" },
},
args: {
disabled: false,
options: [
{ id: "January", label: "January" },
{ id: "February", label: "February" },
{ id: "March", label: "March" },
{ id: "April", label: "April" },
{ id: "May", label: "May" },
{ id: "June", label: "June" },
{ id: "July", label: "July" },
{ id: "August", label: "August" },
{ id: "September", label: "September" },
{ id: "October", label: "October" },
{ id: "November", label: "November" },
{ id: "December", label: "December" },
],
defaultSelected: "February",
onChange: (value) => (lastValue = value),
},
parameters: {
controls: {
exclude: ["options", "defaultSelected"],
},
},
render: ({ ...args }) => (
<div style={{ padding: "5px" }}>
<Combobox {...args} />
</div>
),
};
export const Default = {
parameters: {
docs: {
story: {
height: "450px",
},
},
},
};
export const WithIcons = {
args: {
options: [
{ id: "January", label: "January", icon: "fill-content" },
{ id: "February", label: "February", icon: "pentool" },
{ id: "March", label: "March" },
{ id: "April", label: "April" },
{ id: "May", label: "May" },
{ id: "June", label: "June" },
{ id: "July", label: "July" },
{ id: "August", label: "August" },
{ id: "September", label: "September" },
{ id: "October", label: "October" },
{ id: "November", label: "November" },
{ id: "December", label: "December" },
],
},
parameters: {
docs: {
story: {
height: "450px",
},
},
},
};
export const TestInteractions = {
...WithIcons,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const combobox = await canvas.getByRole("combobox");
const button = await canvas.getByTestId("combobox-open-button");
const input = await canvas.getByTestId("combobox-input");
const waitOptionNotPresent = async () => {
expect(canvas.queryByTestId("combobox-options")).not.toBeInTheDocument();
};
const waitOptionsPresent = async () => {
const options = await canvas.findByTestId("combobox-options");
expect(options).toBeVisible();
return options;
};
await userEvent.clear(input);
await step("Toggle dropdown on click arrow button", async () => {
await userEvent.click(button);
await waitOptionsPresent();
expect(combobox).toHaveAttribute("aria-expanded", "true");
await userEvent.click(button);
await waitOptionNotPresent();
expect(combobox).toHaveAttribute("aria-expanded", "false");
});
await step("Aria controls is set correctly", async () => {
await userEvent.click(button);
const ariaControls = combobox.getAttribute("aria-controls");
const options = await canvas.findByTestId("combobox-options");
expect(options).toHaveAttribute("id", ariaControls);
});
await step("Navigation keys", async () => {
// Arrow down
await userEvent.click(input);
await waitOptionsPresent();
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Enter}");
expect(input).toHaveValue("February");
expect(lastValue).toBe("February");
await userEvent.clear(input);
// Arrow up
await userEvent.keyboard("{ArrowDown}");
await waitOptionsPresent();
await userEvent.keyboard("{ArrowUp}");
await userEvent.keyboard("{ArrowUp}");
expect(combobox).toHaveAttribute("aria-activedescendant", "November");
await userEvent.keyboard("{Enter}");
expect(input).toHaveValue("November");
expect(lastValue).toBe("November");
await userEvent.clear(input);
// Home
await userEvent.keyboard("{ArrowDown}");
await waitOptionsPresent();
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Home}");
expect(combobox).toHaveAttribute("aria-activedescendant", "January");
await userEvent.keyboard("{Enter}");
expect(input).toHaveValue("January");
expect(lastValue).toBe("January");
await userEvent.clear(input);
});
await step("Toggle dropdown with arrow down and ESC", async () => {
userEvent.click(input);
await waitOptionsPresent();
await userEvent.keyboard("{Escape}");
expect(combobox).toHaveAttribute("aria-expanded", "false");
await waitOptionNotPresent();
await userEvent.keyboard("{ArrowDown}");
await waitOptionsPresent();
expect(combobox).toHaveAttribute("aria-expanded", "true");
await userEvent.keyboard("{Escape}");
await waitOptionNotPresent();
expect(combobox).toHaveAttribute("aria-expanded", "false");
});
await step("Filter with 'Ju' and select July", async () => {
await userEvent.type(input, "Ju");
const options = await canvas.findAllByTestId("dropdown-option");
expect(options).toHaveLength(2);
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Enter}");
expect(input).toHaveValue("July");
expect(lastValue).toBe("July");
});
await step("Close dropdown when focus out", async () => {
await userEvent.click(button);
await waitOptionsPresent();
await userEvent.tab();
await waitOptionNotPresent();
});
},
};

View file

@ -9,6 +9,7 @@
[app.common.data.macros :as dm]
[app.main.style :as stl])
(:require
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i]
[app.util.array :as array]
[app.util.dom :as dom]
@ -16,63 +17,6 @@
[app.util.object :as obj]
[rumext.v2 :as mf]))
(mf/defc option*
{::mf/props :obj
::mf/private true}
[{:keys [id label icon aria-label on-click selected set-ref focused] :rest props}]
[:> :li {:value id
:class (stl/css-case :option true
:option-with-icon (some? icon)
:option-current focused)
:aria-selected selected
:ref (fn [node]
(set-ref node id))
:role "option"
:id id
:on-click on-click
:data-id id}
(when (some? icon)
[:> icon*
{:id icon
:size "s"
:class (stl/css :option-icon)
:aria-hidden (when label true)
:aria-label (when (not label) aria-label)}])
[:span {:class (stl/css :option-text)} label]
(when selected
[:> icon*
{:id i/tick
:size "s"
:class (stl/css :option-check)
:aria-hidden (when label true)}])])
(mf/defc options-dropdown*
{::mf/props :obj
::mf/private true}
[{:keys [set-ref on-click options selected focused] :rest props}]
(let [props (mf/spread-props props
{:class (stl/css :option-list)
:tab-index "-1"
:role "listbox"})]
[:> "ul" props
(for [option ^js options]
(let [id (obj/get option "id")
label (obj/get option "label")
aria-label (obj/get option "aria-label")
icon (obj/get option "icon")]
[:> option* {:selected (= id selected)
:key id
:id id
:label label
:icon icon
:aria-label aria-label
:set-ref set-ref
:focused (= id focused)
:on-click on-click}]))]))
(def ^:private schema:select-option
[:and
[:map {:title "option"}

View file

@ -14,7 +14,6 @@
--select-bg-color: var(--color-background-tertiary);
--select-outline-color: none;
--select-border-color: none;
--select-dropdown-border-color: var(--color-background-quaternary);
&:hover {
--select-bg-color: var(--color-background-quaternary);
@ -81,67 +80,3 @@
grid-template-columns: auto 1fr;
color: var(--select-icon-fg-color);
}
.option-list {
--options-dropdown-bg-color: var(--color-background-tertiary);
position: absolute;
right: 0;
top: $sz-36;
width: 100%;
background-color: var(--options-dropdown-bg-color);
border-radius: $br-8;
border: $b-1 solid var(--select-dropdown-border-color);
padding-block: var(--sp-xs);
margin-block-end: 0;
max-height: $sz-400;
overflow-y: auto;
overflow-x: hidden;
}
.option {
--select-option-fg-color: var(--color-foreground-primary);
--select-option-bg-color: unset;
&:hover {
--select-option-bg-color: var(--color-background-quaternary);
}
&[aria-selected="true"] {
--select-option-bg-color: var(--color-background-quaternary);
}
display: grid;
align-items: center;
justify-items: start;
grid-template-columns: 1fr auto;
gap: var(--sp-xs);
width: 100%;
height: $sz-32;
padding: var(--sp-s);
border-radius: $br-8;
outline: $b-1 solid var(--select-outline-color);
outline-offset: -1px;
background-color: var(--select-option-bg-color);
}
.option-with-icon {
grid-template-columns: auto 1fr auto;
}
.option-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
min-width: 0;
padding-inline-start: var(--sp-xxs);
}
.option-icon {
color: var(--select-icon-fg-color);
}
.option-current {
--select-option-outline-color: var(--color-accent-primary);
outline: $b-1 solid var(--select-option-outline-color);
}

View file

@ -0,0 +1,69 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.ds.controls.shared.options-dropdown
(:require-macros
[app.main.style :as stl])
(:require
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.util.object :as obj]
[rumext.v2 :as mf]))
(mf/defc option*
{::mf/props :obj
::mf/private true}
[{:keys [id label icon aria-label on-click selected set-ref focused] :rest props}]
[:> :li {:value id
:class (stl/css-case :option true
:option-with-icon (some? icon)
:option-current focused)
:aria-selected selected
:ref (fn [node]
(set-ref node id))
:role "option"
:id id
:on-click on-click
:data-id id
:data-testid "dropdown-option"}
(when (some? icon)
[:> icon*
{:id icon
:size "s"
:class (stl/css :option-icon)
:aria-hidden (when label true)
:aria-label (when (not label) aria-label)}])
[:span {:class (stl/css :option-text)} label]
(when selected
[:> icon*
{:id i/tick
:size "s"
:class (stl/css :option-check)
:aria-hidden (when label true)}])])
(mf/defc options-dropdown*
{::mf/props :obj}
[{:keys [set-ref on-click options selected focused] :rest props}]
(let [props (mf/spread-props props
{:class (stl/css :option-list)
:tab-index "-1"
:role "listbox"})]
[:> "ul" props
(for [option ^js options]
(let [id (obj/get option "id")
label (obj/get option "label")
aria-label (obj/get option "aria-label")
icon (obj/get option "icon")]
[:> option* {:selected (= id selected)
:key id
:id id
:label label
:icon icon
:aria-label aria-label
:set-ref set-ref
:focused (= id focused)
:on-click on-click}]))]))

View file

@ -0,0 +1,74 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "../../_borders.scss" as *;
@use "../../_sizes.scss" as *;
@use "../../typography.scss" as *;
.option-list {
--options-dropdown-icon-fg-color: var(--color-foreground-secondary);
--options-dropdown-bg-color: var(--color-background-tertiary);
--options-dropdown-outline-color: none;
--options-dropdown-border-color: var(--color-background-quaternary);
position: absolute;
right: 0;
top: $sz-36;
width: 100%;
background-color: var(--options-dropdown-bg-color);
border-radius: $br-8;
border: $b-1 solid var(--options-dropdown-dropdown-border-color);
padding-block: var(--sp-xs);
margin-block-end: 0;
max-height: $sz-400;
overflow-y: auto;
overflow-x: hidden;
}
.option {
--options-dropdown-fg-color: var(--color-foreground-primary);
--options-dropdown-bg-color: unset;
display: grid;
align-items: center;
justify-items: start;
grid-template-columns: 1fr auto;
gap: var(--sp-xs);
width: 100%;
height: $sz-32;
padding: var(--sp-s);
border-radius: $br-8;
outline: $b-1 solid var(--options-dropdown-outline-color);
outline-offset: -1px;
background-color: var(--options-dropdown-bg-color);
&:hover,
&[aria-selected="true"] {
--options-dropdown-bg-color: var(--color-background-quaternary);
}
}
.option-with-icon {
grid-template-columns: auto 1fr auto;
}
.option-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
min-width: 0;
padding-inline-start: var(--sp-xxs);
}
.option-icon {
color: var(--options-dropdown-icon-fg-color);
}
.option-current {
--options-dropdown-outline-color: var(--color-accent-primary);
outline: $b-1 solid var(--options-dropdown-outline-color);
}

View file

@ -284,7 +284,8 @@
(p/fmap (fn [ready?]
(when ready?
(reset! canvas-init? true)
(wasm.api/assign-canvas canvas)))))
(wasm.api/assign-canvas canvas)
(wasm.api/set-canvas-background background)))))
(fn []
(wasm.api/clear-canvas))))
@ -304,6 +305,10 @@
(when @canvas-init?
(wasm.api/set-view zoom vbox)))
(mf/with-effect [background]
(when @canvas-init?
(wasm.api/set-canvas-background background)))
(hooks/setup-dom-events zoom disable-paste in-viewport? read-only? drawing-tool drawing-path?)
(hooks/setup-viewport-size vport viewport-ref)
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool drawing-path? node-editing? z? read-only?)

View file

@ -345,6 +345,11 @@
(set! (.-width canvas) (* dpr (.-clientWidth ^js canvas)))
(set! (.-height canvas) (* dpr (.-clientHeight ^js canvas))))
(defn set-canvas-background
[background]
(let [rgba (rgba-from-hex background 1)]
(h/call internal-module "_set_canvas_background" rgba)))
(defonce module
(delay
(if (exists? js/dynamicImport)