🐛 Fix errors from numeric input design review (#8993)

* 🐛 Fix blur input after enter value

* 🐛 Catch error on invalid maths

* 🐛 Fix race condition

* 🎉 Add tests that cover issues

* 🐛 Fix padding applying only to one side

* 🐛 Fix show broken pill when reference is on not active set
This commit is contained in:
Eva Marco 2026-04-21 14:39:06 +02:00 committed by GitHub
parent 78c48f1953
commit f18670ed00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 538 additions and 82 deletions

View File

@ -5,6 +5,7 @@ import {
setupTokensFileRender,
setupTypographyTokensFileRender,
unfoldTokenType,
createToken,
} from "./helpers";
test.beforeEach(async ({ page }) => {
@ -813,7 +814,7 @@ test.describe("Tokens: Apply token", () => {
// Check if token pill is visible on right sidebar
const layoutItemSectionSidebar = rightSidebar.getByRole("region", {
name: "layout item menu",
name: "Layout item section",
});
await expect(layoutItemSectionSidebar).toBeVisible();
const marginPillMd = layoutItemSectionSidebar.getByRole("button", {
@ -931,8 +932,7 @@ test.describe("Tokens: Detach token", () => {
test("Bug: 13959, User select shapes with different hidden state.", async ({
page,
}) => {
const { workspacePage } =
await setupTokensFileRender(page);
const { workspacePage } = await setupTokensFileRender(page);
await page.getByRole("tab", { name: "Layers" }).click();
@ -955,8 +955,7 @@ test("Bug: 13959, User select shapes with different hidden state.", async ({
test("Bug: 13960, User select shapes with different opacity and input show mixed state.", async ({
page,
}) => {
const { workspacePage } =
await setupTokensFileRender(page);
const { workspacePage } = await setupTokensFileRender(page);
await page.getByRole("tab", { name: "Layers" }).click();
@ -965,21 +964,22 @@ test("Bug: 13960, User select shapes with different opacity and input show mixed
name: "Layer menu section",
});
await expect(layerMenuSection).toBeVisible();
await layerMenuSection
.getByRole('textbox', { name: 'Opacity' })
.fill('50');
await layerMenuSection.getByRole("textbox", { name: "Opacity" }).fill("50");
await expect(layerMenuSection).toBeVisible();
await workspacePage.layers
.getByTestId("layer-row")
.nth(0)
.click({ modifiers: ["Shift"] });
await expect(layerMenuSection
.getByRole('textbox', { name: 'Opacity' })).toBeVisible();
await expect(layerMenuSection
.getByRole('textbox', { name: 'Opacity' })).toBeVisible();
await expect(
layerMenuSection.getByRole("textbox", { name: "Opacity" }),
).toBeVisible();
await expect(
layerMenuSection.getByRole("textbox", { name: "Opacity" }),
).toBeVisible();
await expect(layerMenuSection
.getByRole('textbox', { name: 'Opacity' })).toHaveAttribute("placeholder", "Mixed");
await expect(
layerMenuSection.getByRole("textbox", { name: "Opacity" }),
).toHaveAttribute("placeholder", "Mixed");
});
test("BUG: 13930, Token colors are shown on selected colors section", async ({
@ -1029,3 +1029,396 @@ test("BUG: 13930, Token colors are shown on selected colors section", async ({
.getByRole("button", { name: "colors.black" }),
).toBeVisible();
});
test.describe("Numeric Input and Token Integration Tests", () => {
test("Token pill persists after blur in gap inputs", async ({ page }) => {
// Setup the workspace with token features enabled
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Transform a rectangle into a flex container to expose gap properties
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const layoutSection =
workspacePage.rightSidebar.getByTestId("inspect-layout");
const addLayoutButton = layoutSection
.getByRole("button", { name: "Add layout" })
.first();
await addLayoutButton.click();
await page.getByText("Flex layout").click();
// Apply a spacing token to the Column gap property
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await unfoldTokenType(tokensSidebar, "spacing");
await tokensSidebar
.getByRole("button", { name: "spacing.lg" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Column gap").click();
// Verify that the token pill appears in the layout section, check after blur
await expect(
page
.getByTestId("inspect-layout")
.getByRole("button", { name: "spacing.lg" }),
).toBeVisible();
await page
.getByTestId("inspect-layout")
.getByRole("textbox", { name: "Vertical padding" })
.click();
await expect(
page
.getByTestId("inspect-layout")
.getByRole("button", { name: "spacing.lg" }),
).toBeVisible();
});
test("Padding tokens are applied to both vertical or horizontal properties", async ({
page,
}) => {
// Setup the workspace with token features enabled
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Transform a rectangle into a flex container to expose gap properties
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const layoutSection =
workspacePage.rightSidebar.getByTestId("inspect-layout");
const addLayoutButton = layoutSection
.getByRole("button", { name: "Add layout" })
.first();
await addLayoutButton.click();
await page.getByText("Flex layout").click();
// Apply a spacing token to the Column gap property
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await unfoldTokenType(tokensSidebar, "spacing");
await tokensSidebar
.getByRole("button", { name: "spacing.lg" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Horizontal").click();
// Verify that the token pill appears in the layout section, check after blur
await expect(
page
.getByTestId("inspect-layout")
.getByRole("button", { name: "spacing.lg" }),
).toBeVisible();
await layoutSection
.getByRole("button", { name: "Show 4 sided padding options" })
.click();
await expect(
page
.getByTestId("inspect-layout")
.getByRole("button", { name: "spacing.lg" }),
).toHaveCount(2);
await layoutSection
.getByRole("button", { name: "Show 4 sided padding options" })
.click();
await expect(
page
.getByTestId("inspect-layout")
.getByRole("button", { name: "spacing.lg" }),
).toBeVisible();
});
test("Token pill persists after blur in min/max width inputs", async ({
page,
}) => {
// Setup the workspace with token features enabled
const { workspacePage } = await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Create a flex container to expose min/max width properties
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(2).click();
const layoutSection =
workspacePage.rightSidebar.getByTestId("inspect-layout");
const addLayoutButton = layoutSection
.getByRole("button", { name: "Add layout" })
.first();
await addLayoutButton.click();
await page.getByText("Flex layout").click();
// Verify that the flex container (Flex board) is created
await expect(
page.getByRole("button", { name: "Flex board" }),
).toBeVisible();
// Select element inside flex container to access to layout constrains inputs
// Apply token to min width property
await workspacePage.layers
.getByTestId("layer-row")
.nth(2)
.getByTestId("toggle-content")
.click();
await workspacePage.layers.getByTestId("layer-row").nth(3).click();
const layoutItemSection = page.getByRole("region", {
name: "Layout item section",
});
await layoutItemSection.getByTestId("behaviour-h-fill").click();
const constraintsSection = layoutItemSection.getByRole("region", {
name: "layout item size constraints",
});
await expect(constraintsSection).toBeVisible();
await constraintsSection
.getByRole("button", { name: "Open token list" })
.nth(0)
.click();
await expect(
page.getByRole("option", { name: "dimension.md" }),
).toBeVisible();
await page.getByRole("option", { name: "dimension.md" }).click();
await expect(
constraintsSection.getByRole("button", { name: "dimension.md" }),
).toBeVisible();
// Focus another input (Max width) to trigger blur and check if token pill persists
await constraintsSection
.getByRole("textbox", { name: "Max width" })
.click();
await expect(
constraintsSection.getByRole("button", { name: "dimension.md" }),
).toBeVisible();
});
test("Invalid formula reverts to previous value in padding inputs", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const layoutSection =
workspacePage.rightSidebar.getByTestId("inspect-layout");
const addLayoutButton = layoutSection
.getByRole("button", { name: "Add layout" })
.first();
await addLayoutButton.click();
await page.getByText("Flex layout").click();
// Apply a spacing token to the Column gap property
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await unfoldTokenType(tokensSidebar, "spacing");
await tokensSidebar
.getByRole("button", { name: "spacing.lg" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Column gap").click();
const verticalPaddingInput = layoutSection.getByRole("textbox", {
name: "Vertical padding",
});
// Enter a valid value first
await verticalPaddingInput.fill("23");
await verticalPaddingInput.press("Enter");
// Wait for potential error handling
await page.waitForTimeout(500);
expect(await verticalPaddingInput.inputValue()).toMatch("23");
// Enter invalid expression
await verticalPaddingInput.fill("abc+1");
await verticalPaddingInput.press("Enter");
// Wait for potential error handling
await page.waitForTimeout(500);
// Value should revert to previous valid value
expect(await verticalPaddingInput.inputValue()).toMatch("23");
// Should NOT contain invalid characters
expect(await verticalPaddingInput.inputValue()).not.toContain("abc");
});
test("Division by zero reverts to previous value", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const layoutSection =
workspacePage.rightSidebar.getByTestId("inspect-layout");
const addLayoutButton = layoutSection
.getByRole("button", { name: "Add layout" })
.first();
await addLayoutButton.click();
await page.getByText("Flex layout").click();
// Apply a spacing token to the Column gap property
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await unfoldTokenType(tokensSidebar, "spacing");
await tokensSidebar
.getByRole("button", { name: "spacing.lg" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Column gap").click();
const verticalPaddingInput = layoutSection.getByRole("textbox", {
name: "Vertical padding",
});
// Enter a valid value first
await verticalPaddingInput.fill("23");
await verticalPaddingInput.press("Enter");
// Wait for potential error handling
await page.waitForTimeout(500);
expect(await verticalPaddingInput.inputValue()).toMatch("23");
// Enter invalid expression
await verticalPaddingInput.fill("10/0");
await verticalPaddingInput.press("Enter");
// Wait for potential error handling
await page.waitForTimeout(500);
// Value should revert to previous valid value
expect(await verticalPaddingInput.inputValue()).toMatch("23");
// Should NOT contain invalid characters
expect(await verticalPaddingInput.inputValue()).not.toContain("10/0");
// Value should revert
expect(await verticalPaddingInput.inputValue()).toMatch(/^(\d+|--)$/);
expect(await verticalPaddingInput.inputValue()).not.toBe("Infinity");
});
test("Negative expression result handled correctly", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const widthInput = workspacePage.rightSidebar.getByRole("textbox", {
name: "Width",
});
await expect(widthInput).toBeVisible();
// Enter a valid value first
await widthInput.fill("23");
await widthInput.press("Enter");
// Wait for potential error handling
await page.waitForTimeout(500);
expect(await widthInput.inputValue()).toMatch("23");
// Enter a negative expression
await widthInput.fill("10-50");
await widthInput.press("Enter");
// Wait for potential error handling
await page.waitForTimeout(500);
expect(await widthInput.inputValue()).toMatch("0.01");
// Should NOT negative values
expect(await widthInput.inputValue()).not.toContain("-40");
});
test("Token pill show broken reference when set is not activated", async ({
page,
}) => {
// Setup the workspace with token features enabled
const {
workspacePage,
tokensSidebar,
tokenContextMenuForToken,
tokenThemesSetsSidebar,
} = await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Create a token with a reference value in other set.
await createToken(page, "Dimensions", "reference-token", "Value", "{card.padding}");
// Apply this token to a shape
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await unfoldTokenType(tokensSidebar, "dimensions");
await tokensSidebar
.getByRole("button", { name: "reference-token" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("X", { exact: true }).click();
//Check if token is applied and visible on right sidebar
const measuresSection = page.getByRole("region", {
name: "shape-measures-section",
});
await expect(measuresSection).toBeVisible();
await expect(measuresSection.getByRole('button', { name: 'reference-token' })).toBeVisible();
// Deactivate token set where reference token exist to make token broken
await tokenThemesSetsSidebar.getByRole('button', { name: 'theme' }).getByRole('checkbox').click();
// Check if token pill show broken reference state
const brokenPill = measuresSection.getByRole("button", {
name: "is not in any active set",
});
await expect(brokenPill).toHaveCount(2);
});
});

View File

@ -109,6 +109,12 @@
j)))
indices)))
(defn- find-token-by-name
[data name]
(some (fn [tokens-data]
(some #(when (= (:name %) name) %) tokens-data))
(vals data)))
(def ^:private schema:icon
[:and :string [:fn #(contains? icon-list %)]])
@ -153,7 +159,7 @@
icon disabled inner-class
min max max-length step
is-selected-on-focus nillable
tokens applied-token empty-to-end
tokens applied-token-name empty-to-end
on-change on-change-start on-change-end
on-blur on-focus on-detach
property align ref name
@ -166,9 +172,16 @@
tokens (if (object? tokens)
(mfu/bean tokens)
tokens)
value (if (= :multiple applied-token)
value (if (= :multiple applied-token-name)
:multiple
value)
token-applied (mf/with-memo [tokens applied-token-name]
(find-token-by-name tokens applied-token-name))
token-has-errors? (-> token-applied :errors seq boolean)
is-multiple? (= :multiple value)
value (cond
is-multiple? nil
@ -203,8 +216,8 @@
is-open* (mf/use-state false)
is-open (deref is-open*)
token-applied* (mf/use-state applied-token)
token-applied (deref token-applied*)
token-applied-name* (mf/use-state applied-token-name)
token-applied-name (deref token-applied-name*)
focused-id* (mf/use-state nil)
focused-id (deref focused-id*)
@ -215,6 +228,10 @@
raw-value* (mf/use-ref nil)
last-value* (mf/use-ref nil)
;; Flag to prevent effect from overwriting token during selection
;; This prevents race condition between blur and token selection
token-selection-in-progress* (mf/use-ref false)
;; Refs
wrapper-ref (mf/use-ref nil)
nodes-ref (mf/use-ref nil)
@ -237,8 +254,8 @@
selected-id*
(mf/use-state (fn []
(if applied-token
(:id (get-option-by-name dropdown-options applied-token))
(if applied-token-name
(:id (get-option-by-name dropdown-options applied-token-name))
nil)))
selected-id
(deref selected-id*)
@ -272,7 +289,7 @@
(if-let [parsed (parse-value raw-value (mf/ref-val last-value*) min max nillable)]
(when-not (= parsed (mf/ref-val last-value*))
(mf/set-ref-val! last-value* parsed)
(reset! token-applied* nil)
(reset! token-applied-name* nil)
(when (fn? on-change)
(on-change parsed))
@ -283,7 +300,7 @@
(do
(mf/set-ref-val! last-value* nil)
(mf/set-ref-val! raw-value* "")
(reset! token-applied* nil)
(reset! token-applied-name* nil)
(update-input "")
(when (fn? on-change)
(on-change nil)))
@ -291,7 +308,7 @@
(let [fallback-value (or (mf/ref-val last-value*) default)]
(mf/set-ref-val! raw-value* fallback-value)
(mf/set-ref-val! last-value* fallback-value)
(reset! token-applied* nil)
(reset! token-applied-name* nil)
(update-input (fmt/format-number fallback-value))
(when (and (fn? on-change) (not= fallback-value (str value)))
@ -318,13 +335,15 @@
(mf/use-fn
(mf/deps apply-token)
(fn [id value name]
(mf/set-ref-val! token-selection-in-progress* true)
(reset! selected-id* id)
(reset! focused-id* nil)
(reset! is-open* false)
(reset! token-applied* name)
(reset! token-applied-name* name)
(apply-token value name)
(ts/schedule-on-idle
(fn []
(mf/set-ref-val! token-selection-in-progress* false)
(when token-wrapper-ref
(dom/focus! (mf/ref-val token-wrapper-ref)))))))
@ -354,7 +373,7 @@
(on-token-apply focused-id value name)
(reset! filter-id* ""))))
on-blur
handle-blur
(mf/use-fn
(mf/deps apply-value on-blur)
(fn [event]
@ -369,7 +388,8 @@
(when (mf/ref-val dirty-ref)
(apply-value (mf/ref-val raw-value*)))
(when (fn? on-blur)
(on-blur event))))
(on-blur event))
(dom/blur! (mf/ref-val ref))))
on-key-down
(mf/use-fn
@ -409,8 +429,9 @@
value (get option :resolved-value)
name (get option :name)]
(on-token-apply option-id value name)
(reset! filter-id* ""))))
(on-blur event))
(reset! filter-id* "")
(handle-blur event))))
(handle-blur event))
esc?
(do
@ -485,7 +506,7 @@
(when-not (or disabled is-open is-multiple?)
(let [node (mf/ref-val ref)
is-focused (and (some? node) (dom/active? node))
has-token (some? (deref token-applied*))]
has-token (some? (deref token-applied-name*))]
(when-not (or is-focused has-token)
(let [client-x (.-clientX event)
parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
@ -569,16 +590,16 @@
detach-token
(mf/use-fn
(mf/deps on-detach tokens disabled token-applied)
(mf/deps on-detach tokens disabled token-applied-name)
(fn [event]
(when-not disabled
(dom/prevent-default event)
(dom/stop-propagation event)
(reset! token-applied* nil)
(reset! token-applied-name* nil)
(reset! selected-id* nil)
(reset! focused-id* nil)
(when on-detach
(on-detach token-applied))
(on-detach token-applied-name))
(ts/schedule-on-idle
(fn []
(dom/focus! (mf/ref-val ref)))))))
@ -640,7 +661,7 @@
(tr "labels.mixed-values")
placeholder)
:default-value (or (mf/ref-val last-value*) (fmt/format-number value))
:on-blur on-blur
:on-blur handle-blur
:on-key-down on-key-down
:on-focus on-focus
:on-change store-raw-value
@ -665,10 +686,10 @@
:max-length max-length})
token-props
(when (and token-applied (not= :multiple token-applied))
(let [token (get-option-by-name dropdown-options token-applied)
(when (and token-applied-name (not= :multiple token-applied-name))
(let [token (get-option-by-name dropdown-options token-applied-name)
id (get token :id)
label (or (get token :name) applied-token)
label (or (get token :name) applied-token-name)
token-value (or (get token :resolved-value)
(or (mf/ref-val last-value*)
(fmt/format-number value)))
@ -683,7 +704,8 @@
:on-focus on-focus
:on-token-key-down on-token-key-down
:disabled disabled
:on-blur on-blur
:on-blur handle-blur
:token-has-errors token-has-errors?
:class inner-class
:property property
:is-open is-open
@ -704,7 +726,7 @@
:token-detach-btn-ref token-detach-btn-ref
:detach-token detach-token})))]
(mf/with-effect [value default applied-token]
(mf/with-effect [value default applied-token-name]
(let [value' (cond
is-multiple?
""
@ -714,22 +736,27 @@
:else
(fmt/format-number (d/parse-double value default)))]
(mf/set-ref-val! raw-value* value')
(mf/set-ref-val! last-value* value')
(reset! token-applied* applied-token)
(if applied-token
(let [token-id (:id (get-option-by-name dropdown-options applied-token))]
(reset! selected-id* token-id))
(reset! selected-id* nil))
;; Only sync token state if not in the middle of a selection
;; This prevents race condition between blur and token selection
(when-not (mf/ref-val token-selection-in-progress*)
(reset! token-applied-name* applied-token-name)
(if applied-token-name
(let [token-id (:id (get-option-by-name dropdown-options applied-token-name))]
(reset! selected-id* token-id))
(reset! selected-id* nil)))
(when-let [node (mf/ref-val ref)]
(dom/set-value! node value'))))
(mf/with-effect [applied-token]
(when (nil? applied-token)
(reset! token-applied* nil)
(reset! selected-id* nil)))
(mf/with-effect [applied-token-name]
(when (nil? applied-token-name)
;; Only clear if not in the middle of a selection
(when-not (mf/ref-val token-selection-in-progress*)
(reset! token-applied-name* nil)
(reset! selected-id* nil))))
(mf/with-layout-effect [on-mouse-wheel]
(when-let [node (mf/ref-val ref)]
@ -746,8 +773,8 @@
:on-pointer-up on-scrub-pointer-up
:on-lost-pointer-capture on-scrub-lost-pointer-capture}
(if (and (some? token-applied)
(not= :multiple token-applied))
(if (and (some? token-applied-name)
(not= :multiple token-applied-name))
[:> token-field* token-props]
[:> input-field* input-props])

View File

@ -32,7 +32,7 @@
[:map
[:id {:optional true} :string]
[:resolved-value {:optional true}
[:or :int :string :float]]
[:maybe [:or :int :string :float]]]
[:name {:optional true} :string]
[:value {:optional true} :keyword]
[:icon {:optional true} schema:icon-list]

View File

@ -48,6 +48,7 @@
:id id
:name name
:resolved (get option :resolved-value)
:value (get option :value)
:ref ref
:role "option"
:focused (= id focused)

View File

@ -18,7 +18,8 @@
[:map
[:id {:optiona true} :string]
[:ref some?]
[:resolved {:optional true} [:or :int :string :float :map]]
[:resolved {:optional true} [:maybe [:or :int :string :float :map]]]
[:value {:optional true} [:maybe [:or :int :string :float :map]]]
[:name {:optional true} :string]
[:on-click {:optional true} fn?]
[:selected {:optional true} :boolean]
@ -26,7 +27,7 @@
(mf/defc token-option*
{::mf/schema schema:token-option}
[{:keys [id name on-click selected ref focused resolved] :rest props}]
[{:keys [id name on-click selected ref focused resolved value] :rest props}]
(let [internal-id (mf/use-id)
id (d/nilv id internal-id)
element-ref (mf/use-ref nil)]
@ -61,5 +62,8 @@
:ref element-ref}
name]]
(when (and resolved (not (map? resolved)))
[:> :span {:class (stl/css :option-pill)}
resolved])]))
[:span {:class (stl/css :option-pill)}
resolved])
(when (and (nil? resolved) value)
[:span {:class (stl/css :option-pill)}
"--"])]))

View File

@ -39,11 +39,15 @@
{::mf/schema schema:token-field}
[{:keys [id label value slot-start disabled class
on-click on-token-key-down on-blur detach-token tooltip-placement
token-wrapper-ref token-detach-btn-ref on-focus property is-open]}]
token-wrapper-ref token-detach-btn-ref on-focus property is-open
token-has-errors]}]
(let [set-active? (some? id)
content (if set-active?
label
(tr "ds.inputs.token-field.no-active-token-option" label))
content (cond
token-has-errors (tr "workspace.tokens.ref-not-valid")
(not set-active?) (tr "ds.inputs.token-field.no-active-token-option" label)
:else label)
default-id (mf/use-id)
id (d/nilv id default-id)
pill-ref (mf/use-ref nil)
@ -80,13 +84,15 @@
[:button {:on-click on-click
:ref pill-ref
:class (stl/css-case :pill true
:no-set-pill (not set-active?)
:no-set-pill (or (not set-active?)
token-has-errors)
:pill-disabled disabled)
:disabled disabled
:aria-labelledby (dm/str id "-pill")
:on-key-down on-token-key-down}
value
(when-not set-active?
(when (or (not set-active?)
token-has-errors)
[:div {:class (stl/css :pill-dot)}])]]]
(when-not ^boolean disabled

View File

@ -25,7 +25,7 @@
(tr "settings.multiple")
"--"))
:class [class (stl/css :numeric-input-wrapper)]
:applied-token applied-token
:applied-token-name applied-token
:tokens (if (delay? tokens) @tokens tokens)
:align align
:on-detach on-detach-attr

View File

@ -337,7 +337,7 @@
(mf/deps on-change ids)
(fn [value attr event]
(let [on-change-fn #(on-change :simple attr % event)]
(soc/emit-value-or-token value on-change-fn ids #{attr}))))
(soc/emit-value-or-token value on-change-fn ids attr))))
on-detach-token
(mf/use-fn
@ -364,10 +364,10 @@
(mf/use-fn (mf/deps on-focus) #(on-focus :p2))
on-p1-change
(mf/use-fn (mf/deps on-change') #(on-change' % :p1))
(mf/use-fn (mf/deps on-change') #(on-change' % #{:p1 :p3}))
on-p2-change
(mf/use-fn (mf/deps on-change') #(on-change' % :p2))]
(mf/use-fn (mf/deps on-change') #(on-change' % #{:p2 :p4}))]
[:div {:class (stl/css :paddings-simple)}
(if token-numeric-inputs
@ -460,12 +460,8 @@
(mf/use-fn
(mf/deps on-change ids)
(fn [value attr event]
(if (or (string? value) (number? value))
(on-change :multiple attr value event)
(do
(st/emit! (dwta/apply-token-from-input {:token (first value)
:attrs #{attr}
:shape-ids ids}))))))
(let [on-change-fn #(on-change :multiple attr % event)]
(soc/emit-value-or-token value on-change-fn ids #{attr}))))
on-focus
(mf/use-fn
@ -642,7 +638,7 @@
:value p4}]])]))
(mf/defc padding-section*
[{:keys [type on-type-change on-change] :as props}]
[{:keys [type on-type-change] :as props}]
(let [on-type-change'
(mf/use-fn
(mf/deps on-type-change)
@ -650,9 +646,7 @@
(let [type (-> (dom/get-current-target event)
(dom/get-data "type"))
type (if (= type "multiple") :simple :multiple)]
(on-type-change type))))
props (mf/spread-object props {:on-change on-change})]
(on-type-change type))))]
(mf/with-effect []
;; on destroy component
@ -1182,10 +1176,10 @@
(fn [type prop val]
(let [val (mth/finite val 0)]
(cond
(and (= type :simple) (= prop :p1))
(and (= type :simple) (or (= prop :p1) (= prop #{:p1 :p3})))
(st/emit! (dwsl/update-layout ids {:layout-padding {:p1 val :p3 val}}))
(and (= type :simple) (= prop :p2))
(and (= type :simple) (or (= prop :p2) (= prop #{:p2 :p4})))
(st/emit! (dwsl/update-layout ids {:layout-padding {:p2 val :p4 val}}))
(some? prop)

View File

@ -586,7 +586,8 @@
on-layout-item-max-h-change
(mf/use-fn (mf/deps on-size-change) #(on-size-change % :layout-item-max-h))]
[:div {:class (stl/css :advanced-options)}
[:section {:class (stl/css :advanced-options)
:aria-label "Layout item size constraints"}
(when (= (:layout-item-h-sizing values) :fill)
[:div {:class (stl/css :horizontal-fill)}
(if token-numeric-inputs
@ -834,7 +835,7 @@
(st/emit! (dwsl/update-layout-child ids {:layout-item-z-index value}))))]
[:section {:class (stl/css :element-set)
:aria-label "layout item menu"}
:aria-label "Layout item section"}
[:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable has-content?
:collapsed (not open?)

View File

@ -9,7 +9,8 @@
[token]
{:id (str (get token :id))
:type :token
:resolved-value (or (get token :resolved-value) (get token :value))
:value (get token :value)
:resolved-value (get token :resolved-value)
:name (get token :name)})
(defn- generate-dropdown-options

View File

@ -90,7 +90,16 @@
init-value (or init-value 0)]
(s/assert number? init-value)
(if-not (insta/failure? result)
(interpret result init-value)
(try
(let [value (interpret result init-value)]
;; Check for division by zero (Infinity or -Infinity)
(if (or (js/Number.isFinite value) (nil? value))
value
nil))
(catch :default err
(js/console.debug (str "Expression evaluation error: " (ex-message err))
(str "Expression: '" expr "'"))
nil))
(let [text (:text result)
index (:index result)
expecting (->> result

View File

@ -8,7 +8,6 @@
(:require
[app.common.math :as cm]
[app.util.simple-math :as sm]
[cljs.pprint :refer [pprint]]
[cljs.test :as t :include-macros true]))
(t/deftest test-parser-inst
@ -88,3 +87,24 @@
result2 (sm/expr-eval "(20,333 + 10%) * (1 / 3)" 20)]
(t/is (cm/close? result1 result2 7.44433333)))))
(t/deftest test-error-handling
(t/testing "Division by zero should return nil"
(let [result (sm/expr-eval "10/0" 999)]
(t/is (= result nil))))
(t/testing "Expression with division by zero should return nil"
(let [result (sm/expr-eval "(10 + 5) / 0" 999)]
(t/is (= result nil))))
(t/testing "Invalid syntax should return nil"
(let [result (sm/expr-eval "asdasd+2" 999)]
(t/is (= result nil))))
(t/testing "Empty expression with no init-value should return nil"
(let [result (sm/expr-eval "" nil)]
(t/is (= result nil))))
(t/testing "Partial invalid expression should return nil"
(let [result (sm/expr-eval "10 + abc" 100)]
(t/is (= result nil)))))