mirror of
https://github.com/penpot/penpot.git
synced 2025-06-12 18:31:38 +02:00
Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
commit
1c76587d70
19 changed files with 1291 additions and 317 deletions
|
@ -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*
|
||||
|
|
252
frontend/src/app/main/ui/ds/controls/combobox.cljs
Normal file
252
frontend/src/app/main/ui/ds/controls/combobox.cljs
Normal 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"}])]))
|
62
frontend/src/app/main/ui/ds/controls/combobox.mdx
Normal file
62
frontend/src/app/main/ui/ds/controls/combobox.mdx
Normal 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.
|
87
frontend/src/app/main/ui/ds/controls/combobox.scss
Normal file
87
frontend/src/app/main/ui/ds/controls/combobox.scss
Normal 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;
|
||||
}
|
216
frontend/src/app/main/ui/ds/controls/combobox.stories.jsx
Normal file
216
frontend/src/app/main/ui/ds/controls/combobox.stories.jsx
Normal 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();
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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"}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}]))]))
|
|
@ -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);
|
||||
}
|
|
@ -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?)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue