diff --git a/poetry.lock b/poetry.lock index 22686273..fa37e7a6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -358,6 +358,17 @@ files = [ [package.dependencies] beautifulsoup4 = "*" +[[package]] +name = "cachelib" +version = "0.9.0" +description = "A collection of cache libraries in the same API interface." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachelib-0.9.0-py3-none-any.whl", hash = "sha256:811ceeb1209d2fe51cd2b62810bd1eccf70feba5c52641532498be5c675493b3"}, + {file = "cachelib-0.9.0.tar.gz", hash = "sha256:38222cc7c1b79a23606de5c2607f4925779e37cdcea1c2ad21b8bae94b5425a5"}, +] + [[package]] name = "cachetools" version = "5.4.0" @@ -897,6 +908,29 @@ files = [ {file = "dash_core_components-2.0.0.tar.gz", hash = "sha256:c6733874af975e552f95a1398a16c2ee7df14ce43fa60bb3718a3c6e0b63ffee"}, ] +[[package]] +name = "dash-extensions" +version = "1.0.18" +description = "Extensions for Plotly Dash." +optional = false +python-versions = "<4,>=3.9" +files = [ + {file = "dash_extensions-1.0.18-py3-none-any.whl", hash = "sha256:17f4469670bd70ce12fac1a05baaae119fb65eee7b012af47aff7377d0399eeb"}, + {file = "dash_extensions-1.0.18.tar.gz", hash = "sha256:a6b6c0952b3af7ae84c418fea4b43cbd0bd4e82f20d91f1573380b8a3d90df0a"}, +] + +[package.dependencies] +dash = ">=2.17.0" +dataclass-wizard = ">=0.22.2,<0.23.0" +Flask-Caching = ">=2.1.0,<3.0.0" +jsbeautifier = ">=1.14.3,<2.0.0" +more-itertools = ">=10.2.0,<11.0.0" +pydantic = ">=2.7.1,<3.0.0" +ruff = ">=0.4.5,<0.5.0" + +[package.extras] +mantine = ["dash-mantine-components (>=0.14.3,<0.15.0)"] + [[package]] name = "dash-html-components" version = "2.0.0" @@ -919,6 +953,22 @@ files = [ {file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"}, ] +[[package]] +name = "dataclass-wizard" +version = "0.22.3" +description = "Marshal dataclasses to/from JSON. Use field properties with initial values. Construct a dataclass schema with JSON input." +optional = false +python-versions = "*" +files = [ + {file = "dataclass-wizard-0.22.3.tar.gz", hash = "sha256:4c46591782265058f1148cfd1f54a3a91221e63986fdd04c9d59f4ced61f4424"}, + {file = "dataclass_wizard-0.22.3-py2.py3-none-any.whl", hash = "sha256:63751203e54b9b9349212cc185331da73c1adc99c51312575eb73bb5c00c1962"}, +] + +[package.extras] +dev = ["Sphinx (==5.3.0)", "bump2version (==1.0.1)", "coverage (>=6.2)", "dataclass-factory (==2.12)", "dataclasses-json (==0.5.6)", "flake8 (>=3)", "jsons (==1.6.1)", "pip (>=21.3.1)", "pytest (==7.0.1)", "pytest-cov (==3.0.0)", "pytest-mock (>=3.6.1)", "pytimeparse (==1.1.8)", "sphinx-issues (==3.0.1)", "sphinx-issues (==4.0.0)", "tox (==3.24.5)", "twine (==3.8.0)", "watchdog[watchmedo] (==2.1.6)", "wheel (==0.37.1)", "wheel (==0.42.0)"] +timedelta = ["pytimeparse (>=1.1.7)"] +yaml = ["PyYAML (>=5.3)"] + [[package]] name = "decorator" version = "5.1.1" @@ -989,6 +1039,16 @@ files = [ {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] +[[package]] +name = "editorconfig" +version = "0.12.4" +description = "EditorConfig File Locator and Interpreter for Python" +optional = false +python-versions = "*" +files = [ + {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"}, +] + [[package]] name = "et-xmlfile" version = "1.1.0" @@ -1094,6 +1154,21 @@ Werkzeug = ">=3.0.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "flask-caching" +version = "2.3.0" +description = "Adds caching support to Flask applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Flask_Caching-2.3.0-py3-none-any.whl", hash = "sha256:51771c75682e5abc1483b78b96d9131d7941dc669b073852edfa319dd4e29b6e"}, + {file = "flask_caching-2.3.0.tar.gz", hash = "sha256:d7e4ca64a33b49feb339fcdd17e6ba25f5e01168cf885e53790e885f83a4d2cf"}, +] + +[package.dependencies] +cachelib = ">=0.9.0,<0.10.0" +Flask = "*" + [[package]] name = "flask-healthz" version = "1.0.1" @@ -1978,6 +2053,20 @@ docs = ["furo", "sphinx (>=5.0.0)", "sphinx-copybutton"] opt = ["PyJWT", "filemagic (>=1.6)", "requests-jwt", "requests-kerberos"] test = ["MarkupSafe (>=0.23)", "PyYAML (>=5.1)", "docutils (>=0.12)", "flaky", "oauthlib", "parameterized (>=0.8.1)", "pytest (>=6.0.0)", "pytest-cache", "pytest-cov", "pytest-instafail", "pytest-sugar", "pytest-timeout (>=1.3.1)", "pytest-xdist (>=2.2)", "requests-mock", "requires.io", "tenacity", "wheel (>=0.24.0)", "yanc (>=0.3.3)"] +[[package]] +name = "jsbeautifier" +version = "1.15.1" +description = "JavaScript unobfuscator and beautifier." +optional = false +python-versions = "*" +files = [ + {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +six = ">=1.13.0" + [[package]] name = "jsonpointer" version = "3.0.0" @@ -2430,6 +2519,17 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "more-itertools" +version = "10.4.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.8" +files = [ + {file = "more-itertools-10.4.0.tar.gz", hash = "sha256:fe0e63c4ab068eac62410ab05cccca2dc71ec44ba8ef29916a0090df061cf923"}, + {file = "more_itertools-10.4.0-py3-none-any.whl", hash = "sha256:0f7d9f83a0a8dcfa8a2694a770590d98a67ea943e3d9f5298309a484758c4e27"}, +] + [[package]] name = "multidict" version = "6.0.5" @@ -4055,29 +4155,28 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.5.7" +version = "0.4.10" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, - {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, - {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, - {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, - {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, - {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, - {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, + {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, + {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, + {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, + {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, + {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, + {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, ] [[package]] @@ -5250,4 +5349,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "0e17893f396e1e140d092af6b293b4aa3d08214c6e02e40353e0a2e400cca0fb" +content-hash = "9ee5d7d21c84d66750ec4a60cb639f9e84fd20b22ffa2567f926e8aa7013662f" diff --git a/pyproject.toml b/pyproject.toml index 6e432dee..77f0e0af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ python-dotenv = ">=1.0.1" ssb-dash-components = ">=0.8.1" dapla-toolbelt-metadata = ">=0.2.0" gunicorn = ">=23.0.0" +dash-extensions = "^1.0.18" [tool.poetry.group.dev.dependencies] mypy = ">=0.950" @@ -108,6 +109,7 @@ module = [ "pyarrow.parquet", "dash.development.base_component", "pytest_mock", + "dash_extensions", ] ignore_missing_imports = true diff --git a/src/datadoc/app.py b/src/datadoc/app.py index af515462..d0d2f78d 100644 --- a/src/datadoc/app.py +++ b/src/datadoc/app.py @@ -9,12 +9,14 @@ import logging from pathlib import Path +import ssb_dash_components as ssb from dapla_metadata.datasets import Datadoc from dapla_metadata.datasets.code_list import CodeList from dapla_metadata.datasets.statistic_subject_mapping import StatisticSubjectMapping from dash import Dash from dash import dcc from dash import html +from dash_extensions import Keyboard from flask_healthz import healthz from datadoc import config @@ -51,24 +53,44 @@ def build_app(app: type[Dash]) -> Dash: ), build_controls_bar(), html.Div(id="alerts-section"), - dcc.Tabs( - id="tabs", - className="ssb-tabs", - value="dataset", - children=[ - dcc.Tab( - label="Datasett", + html.Div( + [ + dcc.Tabs( + id="tabs", + className="ssb-tabs", value="dataset", - className="workspace-tab", + children=[ + dcc.Tab( + label="Datasett", + children=ssb.Title( + "Rediger datasett", + size=2, + className="workspace-tab-title", + ), + value="dataset", + className="workspace-tab", + ), + dcc.Tab( + label="Variabler", + children=ssb.Title( + "Rediger variabler", + size=2, + className="workspace-tab-title", + ), + value="variables", + className="workspace-tab", + ), + ], ), - dcc.Tab( - label="Variabler", - value="variables", - className="workspace-tab", + html.Div(id="display-tab"), + Keyboard( + id="keyboard", + captureKeys=["ArrowLeft", "ArrowRight"], ), ], + tabIndex="0", + className="focusable-tabs-section", ), - html.Div(id="display-tab"), ], className="main-content-app", ), diff --git a/src/datadoc/assets/workspace_style.css b/src/datadoc/assets/workspace_style.css index 2173e90d..e5b8e5b9 100644 --- a/src/datadoc/assets/workspace_style.css +++ b/src/datadoc/assets/workspace_style.css @@ -27,7 +27,7 @@ .workspace-info-paragraph { display: inline-block; - align-self: flex-end; + align-self: center; margin-bottom: 0; } diff --git a/src/datadoc/assets/workspace_tab.css b/src/datadoc/assets/workspace_tab.css index a418c363..33693c88 100644 --- a/src/datadoc/assets/workspace_tab.css +++ b/src/datadoc/assets/workspace_tab.css @@ -7,3 +7,7 @@ .workspace-tab{ padding: 1rem; } + +.ssb-title.workspace-tab-title{ + margin-top: 1rem; +} diff --git a/src/datadoc/frontend/callbacks/register_callbacks.py b/src/datadoc/frontend/callbacks/register_callbacks.py index e94599f1..b035e65b 100644 --- a/src/datadoc/frontend/callbacks/register_callbacks.py +++ b/src/datadoc/frontend/callbacks/register_callbacks.py @@ -26,6 +26,7 @@ from datadoc.frontend.callbacks.dataset import dataset_control from datadoc.frontend.callbacks.dataset import open_dataset_handling from datadoc.frontend.callbacks.utils import render_tabs +from datadoc.frontend.callbacks.utils import select_tabs from datadoc.frontend.callbacks.variables import accept_variable_metadata_date_input from datadoc.frontend.callbacks.variables import accept_variable_metadata_input from datadoc.frontend.callbacks.variables import populate_variables_workspace @@ -203,6 +204,21 @@ def callback_render_tabs(tab: html.Article) -> html.Article | None: """Return correct tab content.""" return render_tabs(tab) + @app.callback( + Output("tabs", "value"), + Input("keyboard", "keydown"), + Input("tabs", "value"), + ) + def switch_tabs(keydown: dict, current_tab: str) -> str: + """Handle keyboard events and switch tabs. + + This callback is designed to make Dash core component Tabs + with children Tab focusable and keyboard interactive, + enhancing accessibility by allowing users to navigate between tabs + using the arrow keys. + """ + return select_tabs(keydown, current_tab) + @app.callback( Output(VARIABLES_INFORMATION_ID, "children"), Input("dataset-opened-counter", "data"), diff --git a/src/datadoc/frontend/callbacks/utils.py b/src/datadoc/frontend/callbacks/utils.py index 044eaf51..eac142ef 100644 --- a/src/datadoc/frontend/callbacks/utils.py +++ b/src/datadoc/frontend/callbacks/utils.py @@ -226,3 +226,13 @@ def render_tabs(tab: str) -> html.Article | None: ) return None + + +def select_tabs(keydown: dict, current_tab: str) -> str: + """Return correct tab based on keydown.""" + if keydown: + if keydown["key"] == "ArrowRight": + return "variables" if current_tab == "dataset" else "dataset" + if keydown["key"] == "ArrowLeft": + return "dataset" if current_tab == "variables" else "variables" + return current_tab diff --git a/tests/frontend/callbacks/test_callbacks_utils.py b/tests/frontend/callbacks/test_callbacks_utils.py index 36a00220..52dbe039 100644 --- a/tests/frontend/callbacks/test_callbacks_utils.py +++ b/tests/frontend/callbacks/test_callbacks_utils.py @@ -4,6 +4,7 @@ from datadoc.frontend.callbacks.utils import find_existing_language_string from datadoc.frontend.callbacks.utils import render_tabs +from datadoc.frontend.callbacks.utils import select_tabs from datadoc.frontend.components.identifiers import ACCORDION_WRAPPER_ID from datadoc.frontend.components.identifiers import SECTION_WRAPPER_ID @@ -67,3 +68,12 @@ def test_render_tabs(tab: str, identifier: str): result = render_tabs(tab) assert isinstance(result, html.Article) assert result.children[-1].id == identifier + + +def test_switch_tabs(): + keydown_right = {"key": "ArrowRight"} + keydown_left = {"key": "ArrowLeft"} + assert select_tabs(keydown_right, "dataset") == "variables" + assert select_tabs(keydown_right, "variables") == "dataset" + assert select_tabs(keydown_left, "dataset") == "variables" + assert select_tabs(keydown_left, "variables") == "dataset"