diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c89823376..b18c817fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - Modernized `dcc.Tabs` - Modernized `dcc.DatePickerSingle` and `dcc.DatePickerRange` - DatePicker calendars can now accept translations as an external script, either with Dash's `external_scripts` or from the assets folder. See [documentation](https://date-fns.org/v4.1.0/docs/CDN) for the underlying library that supports this. +- New `dcc.Button` component that mirrors `html.Button` but with default styles applied ## Changed - `dcc.Tab` now accepts a `width` prop which can be a pixel or percentage width for an individual tab. diff --git a/components/dash-core-components/src/components/Button.tsx b/components/dash-core-components/src/components/Button.tsx new file mode 100644 index 0000000000..e129302628 --- /dev/null +++ b/components/dash-core-components/src/components/Button.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {ButtonProps} from '../types'; +import './css/button.css'; + +/** + * Similar to dash.html.Button, but with theming and styles applied. + */ +const Button = ({ + setProps, + n_blur = 0, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + n_blur_timestamp = -1, + n_clicks = 0, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + n_clicks_timestamp = -1, + type = 'button', + className, + children, + ...props +}: ButtonProps) => { + const ctx = window.dash_component_api.useDashContext(); + const isLoading = ctx.useLoading(); + + return ( + + ); +}; + +export default Button; diff --git a/components/dash-core-components/src/components/css/button.css b/components/dash-core-components/src/components/css/button.css new file mode 100644 index 0000000000..92594df6e3 --- /dev/null +++ b/components/dash-core-components/src/components/css/button.css @@ -0,0 +1,55 @@ +.dash-button { + line-height: 32px; + background: color-mix( + in srgb, + var(--Dash-Fill-Interactive-Strong) 5%, + transparent + ); + color: var(--Dash-Fill-Interactive-Strong); + padding: 0 calc(var(--Dash-Spacing) * 2); + border-radius: 4px; + border: 1px solid var(--Dash-Fill-Interactive-Strong); + box-sizing: border-box; + vertical-align: middle; +} + +/* Hover state - stronger background */ +.dash-button:hover { + cursor: pointer; + background: var(--Dash-Fill-Inverse-Strong); + color: var(--Dash-Fill-Interactive-Strong); +} + +/* Active state (clicking) - inverted colors */ +.dash-button:active { + background: var(--Dash-Fill-Interactive-Strong); + color: var(--Dash-Fill-Inverse-Strong); +} + +/* Keyboard focus - inverted colors */ +.dash-button:focus-visible { + outline: none; + background: var(--Dash-Fill-Interactive-Strong); + color: var(--Dash-Fill-Inverse-Strong); +} + +/* Hover after keyboard focus - keep inverted but acknowledge hover */ +.dash-button:focus-visible:hover { + background: var(--Dash-Fill-Interactive-Strong); + color: var(--Dash-Fill-Inverse-Strong); +} + +/* Active state after keyboard focus - inverted colors */ +.dash-button:focus-visible:active { + background: var(--Dash-Fill-Interactive-Strong); + color: var(--Dash-Fill-Inverse-Strong); +} + +/* Disabled state */ +.dash-button:disabled { + opacity: 0.6; + cursor: not-allowed; + background: var(--Dash-Fill-Disabled); + color: var(--Dash-Text-Strong); + border-color: var(--Dash-Stroke-Weak); +} diff --git a/components/dash-core-components/src/index.ts b/components/dash-core-components/src/index.ts index 80c5c6f0ec..a2555149d4 100644 --- a/components/dash-core-components/src/index.ts +++ b/components/dash-core-components/src/index.ts @@ -1,4 +1,5 @@ /* eslint-disable import/prefer-default-export */ +import Button from './components/Button'; import Checklist from './components/Checklist'; import Clipboard from './components/Clipboard.react'; import ConfirmDialog from './components/ConfirmDialog.react'; @@ -28,6 +29,7 @@ import Upload from './components/Upload.react'; import './components/css/dcc.css'; export { + Button, Checklist, Clipboard, ConfirmDialog, diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index b337c59b2f..72d8aab961 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {ButtonHTMLAttributes, DetailedHTMLProps} from 'react'; import {BaseDashProps, DashComponent} from '@dash-renderer/types'; export enum PersistenceTypes { @@ -52,6 +52,117 @@ export interface BaseDccProps persistence_type?: PersistenceTypes; } +export type ButtonProps = BaseDccProps & { + /** + * The children of this component. + */ + children?: React.ReactNode; + /** + * Defines the type of the element. + */ + type?: 'submit' | 'reset' | 'button'; + /** + * The element should be automatically focused after the page loaded. + */ + autoFocus?: boolean; + /** + * Indicates whether the user can interact with the element. + */ + disabled?: boolean; + /** + * Indicates the form that is the owner of the element. + */ + form?: string; + /** + * Indicates the action of the element, overriding the action defined in the
. + */ + formAction?: string; + /** + * If the button/input is a submit button (type="submit"), this attribute sets the encoding type to use during form submission. If this attribute is specified, it overrides the enctype attribute of the button's form owner. + */ + formEncType?: string; + /** + * If the button/input is a submit button (type="submit"), this attribute sets the submission method to use during form submission (GET, POST, etc.). If this attribute is specified, it overrides the method attribute of the button's form owner. + */ + formMethod?: string; + /** + * If the button/input is a submit button (type="submit"), this boolean attribute specifies that the form is not to be validated when it is submitted. If this attribute is specified, it overrides the novalidate attribute of the button's form owner. + */ + formNoValidate?: boolean; + /** + * If the button/input is a submit button (type="submit"), this attribute specifies the browsing context (for example, tab, window, or inline frame) in which to display the response that is received after submitting the form. If this attribute is specified, it overrides the target attribute of the button's form owner. + */ + formTarget?: string; + /** + * Name of the element. For example used by the server to identify the fields in form submits. + */ + name?: string; + /** + * Defines a default value which will be displayed in the element on page load. + */ + value?: string | string[] | number; + /** + * Keyboard shortcut to activate or add focus to the element. + */ + accessKey?: string; + /** + * Indicates whether the element's content is editable. + */ + contentEditable?: boolean | 'true' | 'false' | 'inherit'; + /** + * Defines the text direction. Allowed values are ltr (Left-To-Right) or rtl (Right-To-Left). + */ + dir?: string; + /** + * Defines whether the element can be dragged. + */ + draggable?: boolean; + /** + * Prevents rendering of given element, while keeping child elements, e.g. script elements, active. + */ + hidden?: boolean; + /** + * Defines the language used in the element. + */ + lang?: string; + /** + * Defines the role of an element in the context of accessibility. + */ + role?: string; + /** + * Indicates whether spell checking is allowed for the element. + */ + spellCheck?: boolean; + /** + * Defines CSS styles which will override styles previously set. + */ + style?: React.CSSProperties; + /** + * Overrides the browser's default tab order and follows the one specified instead. + */ + tabIndex?: number; + /** + * Text to be displayed in a tooltip when hovering over the element. + */ + title?: string; + /** + * Number of times the button lost focus. + */ + n_blur?: number; + /** + * Last time the button lost focus. + */ + n_blur_timestamp?: number; + /** + * Number of times the button has been clicked. + */ + n_clicks?: number; + /** + * Last time the button was clicked. + */ + n_clicks_timestamp?: number; +}; + export enum HTMLInputTypes { // Only allowing the input types with wide browser compatibility 'text' = 'text', diff --git a/components/dash-core-components/tests/integration/button/test_button.py b/components/dash-core-components/tests/integration/button/test_button.py new file mode 100644 index 0000000000..2d70528f7f --- /dev/null +++ b/components/dash-core-components/tests/integration/button/test_button.py @@ -0,0 +1,227 @@ +from dash import Dash, Input, Output, dcc, html +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys + + +def test_btev001_clicks_and_blur(dash_dcc): + """Test that n_clicks, n_clicks_timestamp, n_blur, and n_blur_timestamp work correctly""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Button( + "Click Me", + id="test-button", + className="custom-button-class", + ), + html.Div(id="click-output"), + html.Div(id="blur-output"), + html.Div(id="click-timestamp-output"), + html.Div(id="blur-timestamp-output"), + # Add a second element to blur to + html.Button("Other Element", id="other-element"), + ] + ) + + @app.callback( + Output("click-output", "children"), + Input("test-button", "n_clicks"), + ) + def update_clicks(n_clicks): + if n_clicks is None: + return "Clicks: 0" + return f"Clicks: {n_clicks}" + + @app.callback( + Output("blur-output", "children"), + Input("test-button", "n_blur"), + ) + def update_blur(n_blur): + if n_blur is None: + return "Blurs: 0" + return f"Blurs: {n_blur}" + + @app.callback( + Output("click-timestamp-output", "children"), + Input("test-button", "n_clicks_timestamp"), + ) + def update_click_timestamp(n_clicks_timestamp): + if n_clicks_timestamp is None or n_clicks_timestamp == -1: + return "Click timestamp: None" + return f"Click timestamp: {n_clicks_timestamp}" + + @app.callback( + Output("blur-timestamp-output", "children"), + Input("test-button", "n_blur_timestamp"), + ) + def update_blur_timestamp(n_blur_timestamp): + if n_blur_timestamp is None or n_blur_timestamp == -1: + return "Blur timestamp: None" + return f"Blur timestamp: {n_blur_timestamp}" + + dash_dcc.start_server(app) + + # Verify custom class is applied + button = dash_dcc.find_element(".custom-button-class") + assert button is not None, "Custom className should be applied" + + # Check initial state + dash_dcc.wait_for_text_to_equal("#click-output", "Clicks: 0") + dash_dcc.wait_for_text_to_equal("#blur-output", "Blurs: 0") + dash_dcc.wait_for_text_to_equal("#click-timestamp-output", "Click timestamp: None") + dash_dcc.wait_for_text_to_equal("#blur-timestamp-output", "Blur timestamp: None") + + # Click the button + button.click() + dash_dcc.wait_for_text_to_equal("#click-output", "Clicks: 1") + + # Verify click timestamp is set + click_timestamp_text = dash_dcc.find_element("#click-timestamp-output").text + assert "Click timestamp: " in click_timestamp_text + assert click_timestamp_text != "Click timestamp: None" + + # Click again + button.click() + dash_dcc.wait_for_text_to_equal("#click-output", "Clicks: 2") + + # Blur by clicking on other element + other_element = dash_dcc.find_element("#other-element") + other_element.click() + + # Check blur was registered + dash_dcc.wait_for_text_to_equal("#blur-output", "Blurs: 1") + + # Verify blur timestamp is set + blur_timestamp_text = dash_dcc.find_element("#blur-timestamp-output").text + assert "Blur timestamp: " in blur_timestamp_text + assert blur_timestamp_text != "Blur timestamp: None" + + # Click the button again to focus it + button.click() + dash_dcc.wait_for_text_to_equal("#click-output", "Clicks: 3") + + # Blur again by clicking other element + other_element.click() + dash_dcc.wait_for_text_to_equal("#blur-output", "Blurs: 2") + + assert dash_dcc.get_logs() == [] + + +def test_btev002_disabled_button(dash_dcc): + """Test that disabled button doesn't trigger click or blur events""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Button( + "Disabled Button", + id="disabled-button", + className="disabled-test-button", + disabled=True, + ), + html.Div(id="click-output"), + html.Div(id="blur-output"), + html.Button("Other Element", id="other-element"), + ] + ) + + @app.callback( + Output("click-output", "children"), + Input("disabled-button", "n_clicks"), + ) + def update_clicks(n_clicks): + return f"Clicks: {n_clicks or 0}" + + @app.callback( + Output("blur-output", "children"), + Input("disabled-button", "n_blur"), + ) + def update_blur(n_blur): + return f"Blurs: {n_blur or 0}" + + dash_dcc.start_server(app) + + button = dash_dcc.find_element(".disabled-test-button") + other_element = dash_dcc.find_element("#other-element") + + # Verify button is disabled + assert button.get_attribute("disabled") is not None + + # Initial state + dash_dcc.wait_for_text_to_equal("#click-output", "Clicks: 0") + dash_dcc.wait_for_text_to_equal("#blur-output", "Blurs: 0") + + # Try to click - should not increment + button.click() + + # Give it a moment and verify it's still 0 + import time + + time.sleep(0.5) + + click_text = dash_dcc.find_element("#click-output").text + assert click_text == "Clicks: 0", "Disabled button should not trigger clicks" + + # Try to blur by clicking other element - should not increment + other_element.click() + + time.sleep(0.5) + + blur_text = dash_dcc.find_element("#blur-output").text + assert blur_text == "Blurs: 0", "Disabled button should not trigger blur events" + + assert dash_dcc.get_logs() == [] + + +def test_btev003_button_states_visual(dash_dcc): + """Visual test for button states: base, hover, and focus in one snapshot""" + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div( + [ + html.H3("Base State"), + dcc.Button("Base Button", id="base-button", className="state-base"), + ], + style={"marginBottom": "30px"}, + ), + html.Div( + [ + html.H3("Hover State"), + dcc.Button( + "Hover Button", id="hover-button", className="state-hover" + ), + ], + style={"marginBottom": "30px"}, + ), + html.Div( + [ + html.H3("Focus State"), + dcc.Button( + "Focus Button", id="focus-button", className="state-focus" + ), + ], + style={"marginBottom": "30px"}, + ), + ], + style={"padding": "40px"}, + ) + + dash_dcc.start_server(app) + + # Wait for all buttons to render + dash_dcc.wait_for_element(".state-base") + + # Set up each state + # Tab to focus the focus button (using keyboard navigation) + body = dash_dcc.find_element("body") + body.send_keys(Keys.TAB) # Focus base button + body.send_keys(Keys.TAB) # Focus hover button + body.send_keys(Keys.TAB) # Focus focus button + + # Hover over the hover button + hover_button = dash_dcc.find_element(".state-hover") + ActionChains(dash_dcc.driver).move_to_element(hover_button).perform() + + # Take single snapshot showing all states + dash_dcc.percy_snapshot("Button States - Base, Hover, Focus") + + assert dash_dcc.get_logs() == []