From ba56175ac9ecb7d18921dd7832816bee526b7d25 Mon Sep 17 00:00:00 2001 From: novykh Date: Wed, 4 Nov 2026 21:48:20 +0200 Subject: [PATCH 1/4] Refresh components, theme and tooltip for a modern instrument-panel look - Theme: green70 primary with hover/pressed keys, surface ladder (grey195 panels, grey190 sidebar), neutral field borders (controlBorder grey130), soft status text keys, dark limedSpruce tooltip, flat panelBgGrad. - Controls sized by CONTROL_SIZES (24/28/32), green keyboard focus rings, 0.4 disabled; Button link flavour and compact size, IconButton sizes, focusableWhenDisabled and useRovingFocus, segmented ButtonGroup. - Pill soft default with filled, dot and muted; StatusWord and MarkCounts. - Tabs line/contained variants with icon and count slots and tablist ARIA. - Tooltip: 600ms delay, no arrow, { title, description } content, bubble inks custom content with tooltipText; TooltipInk for app-drawn surfaces; IconButton uses the standard bubble; inputs keep a given background on focus. - Exported focusRing mixins plus fieldFocusWithinRing. - IBM Plex Sans in global styles; News organism removed. - Storybook autodocs and usage pages for every component, story render test in light and dark, Playwright visual test setup. --- .gitignore | 2 + .storybook/preview.js | 20 +- jest/config.js | 2 +- package.json | 7 +- .../button/__snapshots__/button.test.js.snap | 354 ++++++++++----- src/components/button/button.stories.js | 205 ++++++++- src/components/button/button.test.js | 393 ++++++++++++++++- src/components/button/button.usage.mdx | 67 +++ src/components/button/buttonGroup.js | 97 +++-- src/components/button/buttonGroup.stories.js | 134 +++++- src/components/button/buttonGroup.test.js | 185 ++++++++ src/components/button/buttonGroup.usage.mdx | 54 +++ src/components/button/constants.js | 1 + src/components/button/iconButton.js | 30 +- src/components/button/iconButton.test.js | 166 +++++++ src/components/button/index.d.ts | 39 +- src/components/button/index.js | 1 + src/components/button/segmented.js | 31 ++ src/components/button/styled.js | 203 ++++++--- src/components/button/useRovingFocus.js | 49 +++ src/components/button/useRovingFocus.test.js | 84 ++++ src/components/checkbox/checkbox.js | 33 +- src/components/checkbox/checkbox.stories.js | 76 +++- src/components/checkbox/checkbox.test.js | 133 ++++++ src/components/checkbox/checkbox.usage.mdx | 50 +++ src/components/checkbox/styled.js | 45 +- src/components/collapsible/index.js | 7 +- src/components/collapsible/index.stories.js | 95 +++- src/components/collapsible/index.test.js | 31 ++ src/components/collapsible/index.usage.mdx | 43 ++ .../confirmation-dialog.js | 30 +- .../confirmation-dialog.stories.js | 151 ++++++- .../confirmation-dialog.stories.test.js | 22 + .../confirmation-dialog.test.js | 123 ++++++ .../confirmation-dialog.usage.mdx | 62 +++ src/components/confirmation-dialog/styled.js | 40 +- src/components/copyToClipboard/index.js | 90 +++- .../copyToClipboard/index.stories.js | 38 +- src/components/copyToClipboard/index.test.js | 183 ++++++++ .../copyToClipboard/index.usage.mdx | 40 ++ src/components/drops/container.js | 34 +- src/components/drops/container.test.js | 50 +++ src/components/drops/drop/container.js | 17 +- src/components/drops/drop/index.js | 1 - src/components/drops/drop/index.stories.js | 223 ++++++++-- src/components/drops/drop/index.test.js | 54 +++ src/components/drops/drop/index.usage.mdx | 55 +++ src/components/drops/dropdownStyles.js | 19 +- src/components/drops/dropdownStyles.test.js | 53 ++- src/components/drops/menu/dropdown.js | 10 +- src/components/drops/menu/dropdown.test.js | 99 +++++ src/components/drops/menu/dropdownItem.js | 51 ++- .../drops/menu/dropdownItem.test.js | 160 +++++++ src/components/drops/menu/index.js | 2 +- src/components/drops/menu/index.stories.js | 189 +++++++- .../drops/menu/index.stories.test.js | 23 + src/components/drops/menu/index.test.js | 85 ++++ src/components/drops/menu/index.usage.mdx | 60 +++ src/components/drops/menu/menuButton.js | 10 + .../drops/mixins/useClonedChildren.js | 3 +- src/components/drops/popover/index.js | 6 +- src/components/drops/popover/index.stories.js | 113 ++++- src/components/drops/popover/index.test.js | 83 ++++ src/components/drops/popover/index.usage.mdx | 53 +++ src/components/drops/tooltip/index.d.ts | 2 + src/components/drops/tooltip/index.js | 33 +- src/components/drops/tooltip/index.stories.js | 220 +++++++++- src/components/drops/tooltip/index.test.js | 195 +++++++-- src/components/drops/tooltip/index.usage.mdx | 67 +++ src/components/drops/tooltip/tooltipInk.js | 18 + .../drops/tooltip/tooltipInk.test.js | 35 ++ .../assets/integrations/gitlab_colored.svg | 6 + src/components/icon/assets/speaker.svg | 3 + src/components/icon/icon.stories.js | 136 +++++- src/components/icon/icon.test.js | 68 +++ src/components/icon/icon.usage.mdx | 53 +++ src/components/icon/iconsList.js | 4 + src/components/icon/iconsList.stories.js | 29 +- src/components/icon/styled.js | 4 +- src/components/input/input.d.ts | 2 + src/components/input/input.js | 8 +- src/components/input/input.stories.js | 123 ++++-- src/components/input/input.test.js | 204 ++++++++- src/components/input/input.usage.mdx | 49 +++ src/components/input/multiRange/index.js | 6 +- .../input/multiRange/index.stories.js | 27 +- src/components/input/multiRange/index.test.js | 44 +- .../input/multiRange/multiRange.usage.mdx | 32 ++ src/components/input/multiRange/styled.js | 16 +- src/components/input/range/range.stories.js | 47 +- src/components/input/range/range.test.js | 106 ++++- src/components/input/range/range.usage.mdx | 38 ++ src/components/input/range/styled.js | 106 ++++- src/components/input/styled.js | 83 +++- src/components/intersection/index.stories.js | 93 ++++ src/components/intersection/index.usage.mdx | 38 ++ src/components/modal/index.js | 49 ++- src/components/modal/index.stories.js | 259 ++++++++--- src/components/modal/index.test.js | 119 +++++ src/components/modal/index.usage.mdx | 49 +++ src/components/pill/alertMastercard.js | 53 ++- src/components/pill/alertMastercard.test.js | 77 +++- src/components/pill/dot.js | 27 ++ src/components/pill/icon.js | 2 +- src/components/pill/icon.test.js | 2 +- src/components/pill/index.d.ts | 53 ++- src/components/pill/index.js | 40 +- src/components/pill/index.stories.js | 347 ++++++++++++++- src/components/pill/index.usage.mdx | 101 +++++ src/components/pill/markCounts.js | 51 +++ src/components/pill/markCounts.test.js | 51 +++ src/components/pill/mastercard.js | 44 +- src/components/pill/mastercard.test.js | 123 +++++- src/components/pill/mastercardPill.js | 14 +- src/components/pill/mastercardPill.test.js | 30 ++ src/components/pill/mixins/background.js | 14 +- src/components/pill/mixins/colors.js | 48 +- src/components/pill/mixins/height.js | 8 +- src/components/pill/mixins/look.js | 14 + src/components/pill/mixins/padding.js | 8 +- src/components/pill/mixins/segment.js | 13 + src/components/pill/mixins/soft.js | 29 ++ src/components/pill/pill.test.js | 374 +++++++++++++++- src/components/pill/softContrast.test.js | 21 + src/components/pill/statusWord.js | 42 ++ src/components/pill/statusWord.test.js | 62 +++ src/components/pill/styled.js | 25 +- src/components/progressBar/index.js | 16 +- src/components/progressBar/index.stories.js | 118 ++++- src/components/progressBar/index.test.js | 85 ++++ src/components/progressBar/index.usage.mdx | 50 +++ src/components/progressBar/styled.js | 21 + src/components/radio-button/index.js | 30 +- src/components/radio-button/index.stories.js | 117 +++++ src/components/radio-button/index.test.js | 87 +++- .../radio-button/mixins/disabled.js | 2 +- .../radio-button/mixins/disabled.test.js | 1 + .../radio-button/radioButton.usage.mdx | 48 ++ src/components/radio-button/styled.js | 20 +- src/components/search/index.js | 3 + src/components/search/index.stories.js | 68 +++ src/components/search/index.test.js | 38 ++ src/components/search/search.usage.mdx | 40 ++ src/components/select/index.js | 261 +++++++---- src/components/select/index.stories.js | 73 +++- src/components/select/index.test.js | 277 ++++++++++++ src/components/select/select.usage.mdx | 49 +++ src/components/sidebar/portal-sidebar.test.js | 70 ++- .../sidebar/portaled-sidebar.stories.js | 121 ++++++ .../sidebar/portaled-sidebar.usage.mdx | 50 +++ src/components/sidebar/sidebar.test.js | 10 + src/components/sidebar/styled.js | 14 +- src/components/table/body/header/cell.js | 13 +- src/components/table/body/header/cell.test.js | 6 +- .../table/body/header/headerStyles.test.js | 168 +++++++ src/components/table/body/header/index.js | 9 - .../table/body/header/resizeHandler.js | 39 +- src/components/table/body/header/sorting.js | 10 +- src/components/table/body/row.js | 33 +- src/components/table/body/rowStyles.test.js | 103 +++++ src/components/table/components/pagination.js | 19 +- .../table/components/pagination.test.js | 88 ++++ src/components/table/header/actions/action.js | 1 - .../header/actions/actionSurface.test.js | 37 ++ .../actions/columnVisibility/columnsMenu.js | 1 - .../columnVisibility/columnsMenu.test.js | 41 ++ .../header/actions/columnVisibility/index.js | 1 - src/components/table/header/index.js | 4 +- src/components/table/header/index.test.js | 37 ++ src/components/table/table.js | 6 +- src/components/table/table.stories.js | 396 ++++++++++++++++- src/components/table/table.usage.mdx | 109 +++++ src/components/table/tableStates.test.js | 69 +++ .../tabs/__snapshots__/tabs.test.js.snap | 133 ++++-- src/components/tabs/styled.js | 154 ++++--- src/components/tabs/tab.d.ts | 2 + src/components/tabs/tab.js | 25 +- src/components/tabs/tabs-hooks.js | 15 +- src/components/tabs/tabs.d.ts | 1 + src/components/tabs/tabs.js | 41 +- src/components/tabs/tabs.stories.js | 249 +++++++++++ src/components/tabs/tabs.stories.test.js | 22 + src/components/tabs/tabs.test.js | 410 ++++++++++++++++++ src/components/tabs/tabs.usage.mdx | 71 +++ src/components/templates/box/index.stories.js | 86 +++- src/components/templates/box/index.usage.mdx | 49 +++ .../templates/flex/index.stories.js | 152 +++++++ src/components/templates/flex/index.usage.mdx | 61 +++ .../templates/layer/backdropContainer.js | 4 +- src/components/templates/layer/container.js | 4 +- .../templates/layer/index.stories.js | 141 +++++- src/components/templates/layer/index.test.js | 54 +++ .../templates/layer/index.usage.mdx | 55 +++ .../templates/layer/mixins/elevation.js | 33 ++ .../templates/layer/mixins/elevation.test.js | 39 ++ .../templates/mixins/background.test.js | 7 +- .../toggle/__snapshots__/toggle.test.js.snap | 172 +++++--- src/components/toggle/styled.js | 125 ++++-- src/components/toggle/toggle.js | 6 +- src/components/toggle/toggle.stories.js | 136 +++++- src/components/toggle/toggle.test.js | 196 ++++++++- src/components/toggle/toggle.usage.mdx | 49 +++ src/components/typography/fontFamily.test.js | 18 + src/components/typography/index.test.js | 65 ++- src/components/typography/typography.js | 15 +- .../typography/typography.stories.js | 155 ++++++- .../typography/typography.usage.mdx | 51 +++ src/global-styles.js | 4 + src/global-styles.test.js | 11 + src/hooks/useColor/index.test.js | 3 +- src/index.exports.test.js | 6 + src/index.js | 6 +- src/mixins/controlSize.js | 25 ++ src/mixins/controlSize.test.js | 41 ++ src/mixins/focusRing.exports.test.js | 32 ++ src/mixins/focusRing.js | 36 ++ src/mixins/focusRing.test.js | 58 +++ src/mixins/index.js | 1 + src/mixins/segmented.js | 38 ++ src/mixins/segmented.test.js | 41 ++ .../documentation/dashboard/section.js | 2 +- .../documentation/dashboard/section.test.js | 34 ++ src/organisms/documentation/general.js | 11 +- src/organisms/documentation/index.js | 16 +- src/organisms/documentation/index.stories.js | 112 ++++- src/organisms/documentation/index.test.js | 100 +++++ src/organisms/documentation/index.usage.mdx | 57 +++ src/organisms/documentation/search/input.js | 2 +- .../documentation/search/input.test.js | 47 ++ src/organisms/documentation/search/results.js | 46 +- .../documentation/search/results.test.js | 91 ++++ .../navigation/hooks/useNavigationScroll.js | 13 +- .../hooks/useNavigationScroll.test.js | 46 ++ src/organisms/navigation/index.stories.js | 113 +++++ src/organisms/navigation/index.usage.mdx | 50 +++ src/organisms/navigation/sortable/arrow.js | 43 +- .../navigation/sortable/arrow.test.js | 52 +++ .../navigation/sortable/index.test.js | 51 +++ src/organisms/navigation/tab/index.js | 37 +- src/organisms/navigation/tab/index.test.js | 121 ++++++ src/organisms/navigation/tab/styled.js | 40 ++ src/organisms/navigation/tab/tabSeparator.js | 4 +- .../navigation/tab/tabSeparator.test.js | 17 + .../navigation/tab/use-styles-tab.js | 29 +- .../navigation/tab/use-styles-tab.test.js | 58 +++ src/organisms/navigation/tabs/index.test.js | 41 ++ src/organisms/news/README.md | 28 -- src/organisms/news/container.js | 12 - src/organisms/news/header.js | 24 - src/organisms/news/index.d.ts | 11 - src/organisms/news/index.js | 73 ---- src/organisms/news/index.stories.js | 27 -- src/organisms/news/item/anchor.js | 10 - src/organisms/news/item/image.js | 8 - src/organisms/news/item/index.js | 37 -- src/organisms/news/useFetchNews.js | 18 - src/organisms/topbar/index.js | 2 +- src/organisms/topbar/index.stories.js | 56 +++ src/organisms/topbar/index.test.js | 38 ++ src/organisms/topbar/index.usage.mdx | 39 ++ src/stories.test.js | 51 +++ src/theme/dark/colors.js | 30 +- src/theme/default/colors.js | 67 +-- src/theme/default/constants.js | 7 + src/theme/index.test.js | 133 +++++- visual/playwright.config.js | 29 ++ visual/serve.js | 33 ++ visual/stories.spec.js | 24 + webpack.config.js | 2 + yarn.lock | 31 ++ 270 files changed, 15376 insertions(+), 1517 deletions(-) create mode 100644 src/components/button/button.usage.mdx create mode 100644 src/components/button/buttonGroup.usage.mdx create mode 100644 src/components/button/iconButton.test.js create mode 100644 src/components/button/segmented.js create mode 100644 src/components/button/useRovingFocus.js create mode 100644 src/components/button/useRovingFocus.test.js create mode 100644 src/components/checkbox/checkbox.usage.mdx create mode 100644 src/components/collapsible/index.usage.mdx create mode 100644 src/components/confirmation-dialog/confirmation-dialog.stories.test.js create mode 100644 src/components/confirmation-dialog/confirmation-dialog.usage.mdx create mode 100644 src/components/copyToClipboard/index.test.js create mode 100644 src/components/copyToClipboard/index.usage.mdx create mode 100644 src/components/drops/container.test.js create mode 100644 src/components/drops/drop/index.usage.mdx create mode 100644 src/components/drops/menu/dropdown.test.js create mode 100644 src/components/drops/menu/dropdownItem.test.js create mode 100644 src/components/drops/menu/index.stories.test.js create mode 100644 src/components/drops/menu/index.usage.mdx create mode 100644 src/components/drops/popover/index.usage.mdx create mode 100644 src/components/drops/tooltip/index.usage.mdx create mode 100644 src/components/drops/tooltip/tooltipInk.js create mode 100644 src/components/drops/tooltip/tooltipInk.test.js create mode 100644 src/components/icon/assets/integrations/gitlab_colored.svg create mode 100644 src/components/icon/assets/speaker.svg create mode 100644 src/components/icon/icon.test.js create mode 100644 src/components/icon/icon.usage.mdx create mode 100644 src/components/input/input.usage.mdx create mode 100644 src/components/input/multiRange/multiRange.usage.mdx create mode 100644 src/components/input/range/range.usage.mdx create mode 100644 src/components/intersection/index.usage.mdx create mode 100644 src/components/modal/index.usage.mdx create mode 100644 src/components/pill/dot.js create mode 100644 src/components/pill/index.usage.mdx create mode 100644 src/components/pill/markCounts.js create mode 100644 src/components/pill/markCounts.test.js create mode 100644 src/components/pill/mixins/look.js create mode 100644 src/components/pill/mixins/segment.js create mode 100644 src/components/pill/mixins/soft.js create mode 100644 src/components/pill/softContrast.test.js create mode 100644 src/components/pill/statusWord.js create mode 100644 src/components/pill/statusWord.test.js create mode 100644 src/components/progressBar/index.usage.mdx create mode 100644 src/components/progressBar/styled.js create mode 100644 src/components/radio-button/index.stories.js create mode 100644 src/components/radio-button/radioButton.usage.mdx create mode 100644 src/components/search/index.stories.js create mode 100644 src/components/search/search.usage.mdx create mode 100644 src/components/select/index.test.js create mode 100644 src/components/select/select.usage.mdx create mode 100644 src/components/sidebar/portaled-sidebar.stories.js create mode 100644 src/components/sidebar/portaled-sidebar.usage.mdx create mode 100644 src/components/table/body/header/headerStyles.test.js create mode 100644 src/components/table/body/rowStyles.test.js create mode 100644 src/components/table/components/pagination.test.js create mode 100644 src/components/table/header/actions/actionSurface.test.js create mode 100644 src/components/table/header/actions/columnVisibility/columnsMenu.test.js create mode 100644 src/components/table/table.usage.mdx create mode 100644 src/components/table/tableStates.test.js create mode 100644 src/components/tabs/tabs.stories.js create mode 100644 src/components/tabs/tabs.stories.test.js create mode 100644 src/components/tabs/tabs.usage.mdx create mode 100644 src/components/templates/box/index.usage.mdx create mode 100644 src/components/templates/flex/index.usage.mdx create mode 100644 src/components/templates/layer/index.usage.mdx create mode 100644 src/components/templates/layer/mixins/elevation.js create mode 100644 src/components/templates/layer/mixins/elevation.test.js create mode 100644 src/components/toggle/toggle.usage.mdx create mode 100644 src/components/typography/fontFamily.test.js create mode 100644 src/components/typography/typography.usage.mdx create mode 100644 src/global-styles.test.js create mode 100644 src/index.exports.test.js create mode 100644 src/mixins/controlSize.js create mode 100644 src/mixins/controlSize.test.js create mode 100644 src/mixins/focusRing.exports.test.js create mode 100644 src/mixins/focusRing.js create mode 100644 src/mixins/focusRing.test.js create mode 100644 src/mixins/segmented.js create mode 100644 src/mixins/segmented.test.js create mode 100644 src/organisms/documentation/dashboard/section.test.js create mode 100644 src/organisms/documentation/index.test.js create mode 100644 src/organisms/documentation/index.usage.mdx create mode 100644 src/organisms/documentation/search/input.test.js create mode 100644 src/organisms/documentation/search/results.test.js create mode 100644 src/organisms/navigation/hooks/useNavigationScroll.test.js create mode 100644 src/organisms/navigation/index.usage.mdx create mode 100644 src/organisms/navigation/sortable/arrow.test.js create mode 100644 src/organisms/navigation/sortable/index.test.js create mode 100644 src/organisms/navigation/tab/index.test.js create mode 100644 src/organisms/navigation/tab/styled.js create mode 100644 src/organisms/navigation/tab/tabSeparator.test.js create mode 100644 src/organisms/navigation/tabs/index.test.js delete mode 100644 src/organisms/news/README.md delete mode 100644 src/organisms/news/container.js delete mode 100644 src/organisms/news/header.js delete mode 100644 src/organisms/news/index.d.ts delete mode 100644 src/organisms/news/index.js delete mode 100644 src/organisms/news/index.stories.js delete mode 100644 src/organisms/news/item/anchor.js delete mode 100644 src/organisms/news/item/image.js delete mode 100644 src/organisms/news/item/index.js delete mode 100644 src/organisms/news/useFetchNews.js create mode 100644 src/organisms/topbar/index.stories.js create mode 100644 src/organisms/topbar/index.test.js create mode 100644 src/organisms/topbar/index.usage.mdx create mode 100644 src/stories.test.js create mode 100644 visual/playwright.config.js create mode 100644 visual/serve.js create mode 100644 visual/stories.spec.js diff --git a/.gitignore b/.gitignore index 5af63cadc..ed67f4040 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ dist coverage storybook-static/ +visual/test-results/ +visual/report/ diff --git a/.storybook/preview.js b/.storybook/preview.js index 93c1072ae..73d69e8ae 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -5,6 +5,8 @@ import { DarkTheme } from "@/theme/dark" import { GlobalStyles } from "@/global-styles" import Flex from "@/components/templates/flex" +const themes = { light: DefaultTheme, dark: DarkTheme } + const preview = { parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -20,7 +22,18 @@ const preview = { decorators: [ (Story, context) => { - const theme = context.globals.theme === "dark" ? DarkTheme : DefaultTheme + const theme = themes[context.globals.theme] || DefaultTheme + + // Docs pages render many stories inline, so a full-viewport frame would give each one a screen of whitespace. + if (context.viewMode === "docs") + return ( + + + + + + + ) return ( @@ -49,7 +62,10 @@ export const globalTypes = { toolbar: { title: "Theme", icon: "circlehollow", - items: ["light", "dark"], + items: [ + { value: "light", title: "Light" }, + { value: "dark", title: "Dark" }, + ], dynamicTitle: true, }, }, diff --git a/jest/config.js b/jest/config.js index 7ee73e260..b837bf140 100644 --- a/jest/config.js +++ b/jest/config.js @@ -28,7 +28,7 @@ module.exports = { }, testPathIgnorePatterns: ["/node_modules/"], transformIgnorePatterns: [ - "/node_modules/(?!(strip-indent|redent|@testing-library/jest-dom)/)", + "/node_modules/(?!(strip-indent|redent|@testing-library/jest-dom|@storybook|storybook)/)", ], reporters: ["default"], cacheDirectory: "/.jest-tmp", diff --git a/package.json b/package.json index b1bc2aacc..c7e80e515 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,10 @@ "prepublishOnly": "yarn clean && yarn build", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "format": "prettier --write \"**/*.{js,mjs}\"" + "format": "prettier --write \"**/*.{js,mjs}\"", + "visual:build": "storybook build -o storybook-static --quiet", + "test:visual": "yarn visual:build && docker run --rm --ipc=host -v \"$PWD\":/work -w /work mcr.microsoft.com/playwright:v1.62.1-noble npx playwright test -c visual/playwright.config.js", + "test:visual:update": "yarn visual:build && docker run --rm --ipc=host -v \"$PWD\":/work -w /work mcr.microsoft.com/playwright:v1.62.1-noble npx playwright test -c visual/playwright.config.js --update-snapshots" }, "keywords": [ "netdata", @@ -43,6 +46,7 @@ "@dnd-kit/sortable": "^10.0.0", "@elastic/react-search-ui": "^1.24.2", "@elastic/search-ui-site-search-connector": "^1.24.2", + "@fontsource/ibm-plex-sans": "^5.3.0", "@prismicio/client": "^7.21.6", "@styled-system/css": "^5.1.5", "@tanstack/react-table": "^8.21.3", @@ -69,6 +73,7 @@ "@babel/plugin-transform-runtime": "^7.29.0", "@babel/preset-env": "^7.29.0", "@babel/preset-react": "^7.28.5", + "@playwright/test": "1.62.1", "@storybook/addon-docs": "10.2.10", "@storybook/addon-links": "10.2.10", "@storybook/addon-webpack5-compiler-babel": "^4.0.0", diff --git a/src/components/button/__snapshots__/button.test.js.snap b/src/components/button/__snapshots__/button.test.js.snap index be1f369cf..fdc19c01b 100644 --- a/src/components/button/__snapshots__/button.test.js.snap +++ b/src/components/button/__snapshots__/button.test.js.snap @@ -6,20 +6,23 @@ exports[`Borderless Button renders 1`] = ` justify-content: center; align-items: center; position: relative; + font-family: inherit; font-weight: 500; - font-size: 12px; + font-size: 13px; + line-height: 16px; + min-height: 28px; white-space: nowrap; word-break: keep-all; cursor: pointer; pointer-events: auto; - padding: 4px 12px; - transition: all 150ms; + padding: 0 12px; + transition: background-color 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out; background-color: rgba(255,255,255,0.0); - color: #00AB44; + color: #007830; border-width: 1px; border-style: solid; border-color: rgba(255,255,255,0.0); - border-radius: 2px; + border-radius: 4px; box-sizing: border-box; text-decoration: none; } @@ -36,20 +39,37 @@ exports[`Borderless Button renders 1`] = ` .c0.c0:hover { border-color: rgba(255,255,255,0.0); background-color: rgba(255,255,255,0.0); - color: #00CD51; + color: #006729; text-decoration: none; } .c0.c0:hover .button-icon { - fill: #00CD51; + fill: #006729; } .c0.c0:active, .c0.c0[aria-expanded="true"] { border-color: rgba(255,255,255,0.0); background-color: rgba(255,255,255,0.0); - color: #00CD51; - box-shadow: inset 0 4px 4px rgba(0,0,0,0.25); + color: #005622; +} + +.c0.c0[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; +} + +.c0.c0[aria-disabled="true"]:hover, +.c0.c0[aria-disabled="true"]:active { + border-color: rgba(255,255,255,0.0); + background-color: rgba(255,255,255,0.0); + color: #007830; + text-decoration: none; +} + +.c0.c0[aria-disabled="true"]:hover .button-icon, +.c0.c0[aria-disabled="true"]:active .button-icon { + fill: #007830; } .c0.c0:focus { @@ -57,14 +77,14 @@ exports[`Borderless Button renders 1`] = ` } .c0.c0:focus-visible { - outline: 2px solid #4A5757; + outline: 2px solid #007830; outline-offset: 2px; } .c0.c0 .button-icon { height: 14px; width: 14px; - fill: #00AB44; + fill: #007830; } .c0.c0 .ntd-spinner { @@ -73,12 +93,18 @@ exports[`Borderless Button renders 1`] = ` stroke-dasharray: 100; stroke-dashoffset: 100; animation: ntd-draw 1s linear infinite; - stroke: #00AB44; + stroke: #007830; width: 24px; } .c0.c0 .path { - stroke: #00AB44; + stroke: #007830; +} + +@media (prefers-reduced-motion:reduce) { + .c0.c0 { + transition: none; + } } @media (prefers-reduced-motion:reduce) { @@ -92,8 +118,8 @@ exports[`Borderless Button renders 1`] = ` class="c0" colors="[object Object]" flavour="borderless" - padding="1,3" - round="0.5" + padding="0,3" + round="1" texttransform="firstLetter" > @@ -108,20 +134,23 @@ exports[`Button states renders loading icon 1`] = ` justify-content: center; align-items: center; position: relative; + font-family: inherit; font-weight: 500; - font-size: 12px; + font-size: 13px; + line-height: 16px; + min-height: 28px; white-space: nowrap; word-break: keep-all; cursor: pointer; pointer-events: auto; - padding: 4px 12px; - transition: all 150ms; - background-color: #00AB44; + padding: 0 12px; + transition: background-color 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out; + background-color: #007830; color: #FFFFFF; border-width: 1px; border-style: solid; - border-color: #00AB44; - border-radius: 2px; + border-color: #007830; + border-radius: 4px; box-sizing: border-box; text-decoration: none; } @@ -136,8 +165,8 @@ exports[`Button states renders loading icon 1`] = ` } .c0.c0:hover { - border-color: #42B861; - background-color: #42B861; + border-color: #006729; + background-color: #006729; color: #FFFFFF; text-decoration: none; } @@ -148,10 +177,27 @@ exports[`Button states renders loading icon 1`] = ` .c0.c0:active, .c0.c0[aria-expanded="true"] { - border-color: #00CD51; - background-color: #00CD51; + border-color: #005622; + background-color: #005622; color: #FFFFFF; - box-shadow: inset 0 4px 4px rgba(0,0,0,0.25); +} + +.c0.c0[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; +} + +.c0.c0[aria-disabled="true"]:hover, +.c0.c0[aria-disabled="true"]:active { + border-color: #007830; + background-color: #007830; + color: #FFFFFF; + text-decoration: none; +} + +.c0.c0[aria-disabled="true"]:hover .button-icon, +.c0.c0[aria-disabled="true"]:active .button-icon { + fill: #FFFFFF; } .c0.c0:focus { @@ -159,7 +205,7 @@ exports[`Button states renders loading icon 1`] = ` } .c0.c0:focus-visible { - outline: 2px solid #4A5757; + outline: 2px solid #007830; outline-offset: 2px; } @@ -197,6 +243,12 @@ exports[`Button states renders loading icon 1`] = ` stroke: #FFFFFF; } +@media (prefers-reduced-motion:reduce) { + .c0.c0 { + transition: none; + } +} + @media (prefers-reduced-motion:reduce) { .c0.c0 .ntd-spinner { animation: none; @@ -214,8 +266,8 @@ exports[`Button states renders loading icon 1`] = ` } + + ) + fireEvent.click(screen.getByRole("button", { name: "show help" })) + return screen.getByTestId("documentation-layer") +} + +describe("Documentation", () => { + it("passes toggle and isOpen to children and opens the layer", () => { + renderOpen() + expect(screen.getByRole("button", { name: "hide help" })).toBeInTheDocument() + expect(screen.getByTestId("documentation-layer")).toBeInTheDocument() + }) + + it("keeps every documented test id in the general view", () => { + renderOpen() + ;[ + "documentation-help-close", + "dashboard-info", + "documentation-link", + "documentation-report-bug-link", + "documentation-community-support-link", + "demo-link", + ].forEach(id => expect(screen.getByTestId(id)).toBeInTheDocument()) + }) + + it("closes from the header and reports it", () => { + const onCloseClick = jest.fn() + renderOpen({ onCloseClick }) + fireEvent.click(screen.getByTestId("documentation-help-close")) + expect(onCloseClick).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId("documentation-layer")).not.toBeInTheDocument() + }) + + it("switches to the dashboard view and back", () => { + renderOpen() + fireEvent.click(screen.getByTestId("dashboard-info")) + expect(screen.getByTestId("dashboard")).toBeInTheDocument() + fireEvent.click(screen.getByTestId("dashboard-back")) + expect(screen.getByTestId("documentation-link")).toBeInTheDocument() + }) + + it("frames the panel as a floating surface with 8px radius and a hairline border", () => { + const layer = renderOpen() + expect(layer).toHaveStyleRule("border-radius", "8px") + expect(layer).toHaveStyleRule("border", `1px solid ${colors.border}`) + const shadow = colors.dropdownShadow + expect(layer).toHaveStyleRule("box-shadow", `0 16px 40px -8px ${shadow},0 2px 6px ${shadow}`) + expect(layer).toHaveStyleRule("padding", "16px") + }) + + it("divides the header with the shared border colour", () => { + renderOpen() + const header = screen.getByTestId("documentation-help-close").parentElement + expect(header).toHaveStyleRule("border-bottom", `1px solid ${colors.border}`) + }) + + it("spends primary green on the docs link only", () => { + renderOpen() + expect(screen.getByTestId("documentation-link")).toHaveStyleRule( + "background-color", + colors.primary, + { modifier: "&&" } + ) + ;[ + "dashboard-info", + "documentation-report-bug-link", + "documentation-community-support-link", + "demo-link", + ].forEach(id => + expect(screen.getByTestId(id)).not.toHaveStyleRule("background-color", colors.primary, { + modifier: "&&", + }) + ) + }) + + it("underlines the inline issue link on hover instead of shifting its hue", () => { + renderOpen() + const link = screen.getByRole("link", { name: "Open an issue" }) + expect(link).toHaveStyleRule("text-decoration", "underline", { modifier: "&&&:hover" }) + expect(link).not.toHaveStyleRule("color", colors.accent, { modifier: "&&&:hover" }) + expect(link).toHaveStyleRule("text-underline-offset", "2px", { modifier: "&&&:hover" }) + }) + + it("separates the agent dashboard section with the shared border colour", () => { + renderOpen() + const separator = screen.getByTestId("dashboard-info").parentElement.parentElement.nextSibling + expect(separator).toHaveStyleRule("background-color", colors.border) + }) +}) diff --git a/src/organisms/documentation/index.usage.mdx b/src/organisms/documentation/index.usage.mdx new file mode 100644 index 000000000..3ad6b4c46 --- /dev/null +++ b/src/organisms/documentation/index.usage.mdx @@ -0,0 +1,57 @@ +import { Meta, Canvas } from "@storybook/addon-docs/blocks" +import * as Stories from "./index.stories" + + + +# Documentation + +A help panel opened from a trigger you provide. It links to the docs, bug reporting, the community and the public demo space, and has a docs search field. + +## When to use + +- Use it for a global "Need help?" entry point in the app. +- Do not use it for contextual help on one field. Use a tooltip instead. + +## Usage + +- Pass a render-prop child. It receives `toggle` and `isOpen` and returns your trigger. +- The panel opens as a layer at the bottom left with a backdrop. + +```jsx + + {(toggle, isOpen) => ( + + + + ) + +describe("Topbar", () => { + it("centres the main child and the actions vertically", () => { + const { container } = renderTopbar() + expect(container.firstChild).toHaveStyleRule("align-items", "center") + expect(container.firstChild).toHaveStyleRule("justify-content", "space-between") + }) + + it("renders the main child before the actions", () => { + renderTopbar() + const title = screen.getByText("Title") + const first = screen.getByRole("button", { name: "One" }) + expect(title.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it("keeps the existing action gaps", () => { + renderTopbar() + const actions = screen.getByRole("button", { name: "One" }).parentElement + expect(actions).toHaveStyleRule("gap", "20px") + }) + + it("widens the action gap when extended", () => { + renderTopbar({ extended: true }) + const actions = screen.getByRole("button", { name: "One" }).parentElement + expect(actions).toHaveStyleRule("gap", "32px") + }) +}) diff --git a/src/organisms/topbar/index.usage.mdx b/src/organisms/topbar/index.usage.mdx new file mode 100644 index 000000000..a32cdc1b4 --- /dev/null +++ b/src/organisms/topbar/index.usage.mdx @@ -0,0 +1,39 @@ +import { Meta, Canvas } from "@storybook/addon-docs/blocks" +import * as Stories from "./index.stories" + + + +# Topbar + +A header row with a title on the left and a group of actions on the right. + +## When to use + +- Use it at the top of a page, panel or room for a heading and its actions. +- Do not use it for tabbed navigation. Use Navigation instead. + +## Props + +- `mainChild`: content on the left, usually a heading. +- `children`: actions on the right, usually buttons. +- `extended`: widens the gap between actions. + + + +## Spacing + +- Default: actions are spaced by 5 units. +- Extended: actions are spaced by 8 units. Use it when the actions are icon buttons that need more air. + + + +## Accessibility + +- Topbar adds no roles. Give icon-only buttons an accessible label. +- Put the heading in `mainChild` as a real heading component. + +## Do and don't + +- Do keep one primary action, placed last. +- Do use borderless neutral buttons for secondary icon actions. +- Don't put long content in `children`. The row does not wrap. diff --git a/src/stories.test.js b/src/stories.test.js new file mode 100644 index 000000000..fcc199205 --- /dev/null +++ b/src/stories.test.js @@ -0,0 +1,51 @@ +import fs from "fs" +import path from "path" +import { composeStories, setProjectAnnotations } from "@storybook/react" +import { useTheme } from "styled-components" +import { render, cleanup } from "testUtilities" +import preview, { globalTypes } from "../.storybook/preview" + +setProjectAnnotations({ ...preview, globalTypes }) + +// jsdom has no IntersectionObserver; an inert one lets lazy stories mount without observing anything. +beforeAll(() => { + if (!window.IntersectionObserver) + window.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +}) + +const collect = dir => + fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) return collect(full) + return /\.stories\.js$/.test(entry.name) ? [full] : [] + }) + +const files = collect(__dirname).map(file => path.relative(__dirname, file)) + +// Storybook is the review surface for the UI kit, so every story must render in both themes. +// The theme travels as a story-level global; a `globals` prop on the composed story is ignored. +describe.each(files)("%s", file => { + const module = require(`./${file}`) + const names = Object.keys(composeStories(module)) + + it.each(names.flatMap(name => [[name, "light"], [name, "dark"]]))("%s renders in %s", (name, theme) => { + const { [name]: Story } = composeStories({ + ...module, + [name]: { ...(typeof module[name] === "function" ? { render: module[name] } : module[name]), globals: { theme } }, + }) + const { container } = render() + expect(container.firstChild).not.toBeNull() + cleanup() + }) +}) + +it("really renders the dark pass with the dark theme", () => { + const Probe = () => {useTheme().colors.mainBackground} + const { Story } = composeStories({ default: { title: "Probe" }, Story: { render: () => , globals: { theme: "dark" } } }) + const { getByTestId } = render() + expect(getByTestId("bg").textContent).toBe(require("@/theme").DarkTheme.colors.mainBackground) +}) diff --git a/src/theme/dark/colors.js b/src/theme/dark/colors.js index 43f791ac0..bf783ba55 100644 --- a/src/theme/dark/colors.js +++ b/src/theme/dark/colors.js @@ -14,7 +14,7 @@ const appColors = { elementBackgroundHover: rawColors.neutral.tuna, mainBackground: rawColors.neutral.grey15, mainBackgroundDisabled: rawColors.neutral.outerSpace, - modalHeaderBackground: rawColors.neutral.grey10, + modalHeaderBackground: rawColors.neutral.grey30, modalTabsBackground: rawColors.neutral.grey20, modalBackground: rawColors.neutral.grey30, modalInfoBackground: rawColors.neutral.grey50, @@ -32,6 +32,8 @@ const appColors = { linkHover: rawColors.green.green120, // Buttons primaryHighlight: rawColors.green.green100, + primaryHover: rawColors.green.green110, + primaryPressed: rawColors.green.green90, secondaryColor: rawColors.green.green110, secondaryHighlight: rawColors.green.green40, neutralHighlight: rawColors.neutral.grey50, @@ -52,6 +54,11 @@ const appColors = { successSemi: rawColors.green.green20, successBackground: rawColors.green.green50, successText: rawColors.green.green100, + // Text on soft (tinted) status pills: one step past the tone so it reaches 4.5:1 on its own fill. + successSoftText: rawColors.green.green100, + warningSoftText: rawColors.yellow.yellow70, + errorSoftText: rawColors.red.red120, + staleSoftText: rawColors.neutral.grey150, warning: rawColors.yellow.yellow60, warningLite: rawColors.yellow.yellow190, @@ -85,9 +92,9 @@ const appColors = { controlFocused: rawColors.neutral.white, selected: rawColors.neutral.grey55, highlight: rawColors.neutral.grey55, - tooltip: rawColors.neutral.outerSpace, - tooltipText: rawColors.neutral.grey150, - tooltipBg: rawColors.neutral.grey10, + tooltip: rawColors.neutral.grey55, + tooltipText: rawColors.neutral.grey185, + tooltipBg: rawColors.neutral.grey55, bright: rawColors.neutral.white, text: rawColors.neutral.grey150, textLite: rawColors.neutral.grey120, @@ -98,7 +105,7 @@ const appColors = { sectionTitle: rawColors.neutral.grey155, sectionDescription: rawColors.neutral.grey150, menuItem: rawColors.neutral.grey140, - placeholder: rawColors.neutral.grey110, + placeholder: rawColors.neutral.grey125, key: rawColors.neutral.iron, panel: rawColors.neutral.limedSpruce, panelBg: rawColors.neutral.grey25, @@ -122,10 +129,13 @@ const appColors = { dateTimePickerChipBg: rawColors.violet.violet20, dateTimePickerChipAccent: rawColors.violet.violet120, // Input colors + // Resting edge for fields: 3:1 against the page so the field is findable without hover. + controlBorder: rawColors.neutral.grey120, + textInfo: rawColors.blue.blue150, inputBg: rawColors.neutral.grey55, - inputBorder: rawColors.neutral.grey65, - inputBorderHover: rawColors.neutral.grey85, - inputBorderFocus: rawColors.neutral.grey85, + inputBorder: rawColors.neutral.grey85, + inputBorderHover: rawColors.neutral.grey100, + inputBorderFocus: rawColors.neutral.grey100, // Badges nodeBadgeBackground: rawColors.neutral.grey90, nodeBadgeBorder: rawColors.neutral.bluebayoux, @@ -143,7 +153,7 @@ const appColors = { idleClear: rawColors.green.green20, // Table - tableRowBgHover: rawColors.neutral.grey25, + tableRowBgHover: rawColors.neutral.grey30, tableRowBg: rawColors.neutral.grey25, tableRowBg2: rawColors.neutral.grey25, tableRowBg2Hover: rawColors.neutral.grey25, @@ -156,7 +166,7 @@ const appColors = { iconColor: rawColors.neutral.grey160, // table-resizer - resizerLine: rawColors.green.vista, + resizerLine: rawColors.green.green100, anomalyText: rawColors.purple.mauve, anomalyTextLite: rawColors.purple.mauveDark, diff --git a/src/theme/default/colors.js b/src/theme/default/colors.js index 21c8b6ded..3afd36f6c 100644 --- a/src/theme/default/colors.js +++ b/src/theme/default/colors.js @@ -1,7 +1,7 @@ import rawColors from "../rawColors" const appColors = { - primary: rawColors.green.green100, + primary: rawColors.green.green70, accent: rawColors.green.chateau, main: rawColors.neutral.limedSpruce, border: rawColors.neutral.grey180, @@ -10,14 +10,14 @@ const appColors = { disabledBackground: rawColors.neutral.grey160, dropdown: rawColors.neutral.white, dropdownShadow: rawColors.shadows.dropdownLight, - elementBackground: rawColors.neutral.blackhaze, + elementBackground: rawColors.neutral.grey195, elementBackgroundHover: rawColors.neutral.white, mainBackground: rawColors.neutral.white, - mainBackgroundDisabled: rawColors.neutral.blackhaze, + mainBackgroundDisabled: rawColors.neutral.grey195, modalHeaderBackground: rawColors.neutral.grey185, modalTabsBackground: rawColors.neutral.grey190, modalBackground: rawColors.neutral.grey195, - modalInfoBackground: rawColors.neutral.blackhaze, + modalInfoBackground: rawColors.neutral.grey195, menuItemSelected: rawColors.neutral.grey180, menuItemHover: rawColors.neutral.grey185, tabActiveBackground: rawColors.transparent.full, @@ -27,11 +27,13 @@ const appColors = { scrollBarThumbHover: rawColors.neutral.grey150, scrollBarBorder: rawColors.neutral.grey180, // Links - link: rawColors.green.green100, - linkHover: rawColors.green.green110, + link: rawColors.green.green70, + linkHover: rawColors.green.green60, // Buttons primaryHighlight: rawColors.green.green110, - secondaryColor: rawColors.green.green100, + primaryHover: rawColors.green.green60, + primaryPressed: rawColors.green.green50, + secondaryColor: rawColors.green.green70, secondaryHighlight: rawColors.green.green190, neutralHighlight: rawColors.neutral.grey180, // Buttons - AI @@ -49,27 +51,32 @@ const appColors = { successLite: rawColors.green.green190, successSemi: rawColors.green.green190, successBackground: rawColors.green.green50, - successText: rawColors.green.green100, - - warning: rawColors.yellow.yellow70, + successText: rawColors.green.green70, + // Text on soft (tinted) status pills: one step past the tone so it reaches 4.5:1 on its own fill. + successSoftText: rawColors.green.green70, + warningSoftText: rawColors.yellow.yellow50, + errorSoftText: rawColors.red.red90, + staleSoftText: rawColors.neutral.grey100, + + warning: rawColors.yellow.yellow50, warningLite: rawColors.yellow.yellow190, warningSemi: rawColors.yellow.yellow190, warningBackground: rawColors.yellow.yellow160, warningBannerBg: rawColors.yellow.yellow160, - warningText: rawColors.yellow.seaBuckthorn, + warningText: rawColors.yellow.yellow40, error: rawColors.red.red100, errorLite: rawColors.red.red190, errorSemi: rawColors.red.red190, errorBackground: rawColors.red.red150, errorBannerBg: rawColors.red.red170, - errorText: rawColors.red.pomegranate, + errorText: rawColors.red.red100, generic: rawColors.neutral.grey165, live: rawColors.green.green100, - stale: rawColors.green.green1000, - staleSemi: rawColors.green.green900, + stale: rawColors.neutral.grey130, + staleSemi: rawColors.neutral.grey120, unseen: rawColors.yellow.yellow900, offline: rawColors.neutral.grey145, metricGap: rawColors.neutral.metricGapLight, @@ -84,9 +91,9 @@ const appColors = { selected: rawColors.neutral.grey180, highlight: rawColors.neutral.grey180, - tooltip: rawColors.neutral.grey180, - tooltipText: rawColors.neutral.grey100, - tooltipBg: rawColors.neutral.grey190, + tooltip: rawColors.neutral.limedSpruce, + tooltipText: rawColors.neutral.grey195, + tooltipBg: rawColors.neutral.limedSpruce, bright: rawColors.neutral.white, text: rawColors.neutral.grey100, textLite: rawColors.neutral.grey120, @@ -96,17 +103,18 @@ const appColors = { sectionHeaderBackground: rawColors.neutral.limedSpruce, sectionTitle: rawColors.neutral.grey100, sectionDescription: rawColors.neutral.grey120, - placeholder: rawColors.neutral.grey140, + placeholder: rawColors.neutral.grey110, key: rawColors.neutral.regentgrey, panel: rawColors.neutral.limedSpruce, panelBg: rawColors.neutral.grey195, - panelBgGrad: `linear-gradient(to right, ${rawColors.neutral.grey195} 0%, ${rawColors.neutral.white} 50%, ${rawColors.neutral.grey195} 100%)`, - mainChartBg: rawColors.neutral.blackhaze, + // Flat on purpose: gradients made panels read as a second surface system. + panelBgGrad: rawColors.neutral.grey195, + mainChartBg: rawColors.neutral.grey195, mainChartHeaderBg: rawColors.neutral.grey190, mainChartBorder: rawColors.neutral.grey185, mainChartTboxHover: rawColors.neutral.grey180, sideBar: rawColors.neutral.grey190, - sideBarMini: rawColors.neutral.grey185, + sideBarMini: rawColors.neutral.grey190, spaceSelected: rawColors.neutral.grey175, spaceIdle: rawColors.neutral.grey195, spaceHovered: rawColors.neutral.grey180, @@ -116,16 +124,19 @@ const appColors = { elevationLevelOne: rawColors.neutral.grey185, // Date picker - datePickerPausedBg: rawColors.neutral.grey170, - datePickerPlayBg: rawColors.green.green180, + datePickerPausedBg: rawColors.neutral.grey185, + datePickerPlayBg: rawColors.neutral.grey190, dateTimePickerChipBg: rawColors.violet.violet190, dateTimePickerChipAccent: rawColors.violet.violet130, // Input colors + // Resting edge for fields: 3:1 against the page so the field is findable without hover. + controlBorder: rawColors.neutral.grey130, + textInfo: rawColors.blue.blue100, inputBg: rawColors.neutral.white, - inputBorder: rawColors.neutral.grey180, - inputBorderHover: rawColors.neutral.grey165, - inputBorderFocus: rawColors.neutral.grey165, + inputBorder: rawColors.neutral.grey130, + inputBorderHover: rawColors.neutral.grey110, + inputBorderFocus: rawColors.neutral.grey110, // Badges nodeBadgeBackground: rawColors.neutral.grey155, @@ -146,7 +157,7 @@ const appColors = { // Table dropdownTable: rawColors.neutral.white, tableRowBg: rawColors.neutral.grey195, - tableRowBgHover: rawColors.neutral.grey180, + tableRowBgHover: rawColors.neutral.grey190, tableRowBg2: rawColors.neutral.grey195, tableRowBg2Hover: rawColors.neutral.grey180, columnHighlight: rawColors.neutral.grey190, @@ -158,7 +169,7 @@ const appColors = { progressBg: rawColors.neutral.chineseWhite, // table-resizer - resizerLine: rawColors.green.vista, + resizerLine: rawColors.green.green100, anomalyText: rawColors.purple.lilac, anomalyTextLite: rawColors.purple.lilacLite, diff --git a/src/theme/default/constants.js b/src/theme/default/constants.js index e31ab4cd9..478c19930 100644 --- a/src/theme/default/constants.js +++ b/src/theme/default/constants.js @@ -4,3 +4,10 @@ export const SIZE_SUB_UNIT = 4 export const SIZE_UNIT = 8 export const GUTTER_HEIGHT = 8 + +// One control ladder (px) shared by buttons, fields, selects and icon buttons, so rows always align. +export const CONTROL_SIZES = { + compact: { height: 24, fontSize: 12 }, + default: { height: 28, fontSize: 13 }, + large: { height: 32, fontSize: 14 }, +} diff --git a/src/theme/index.test.js b/src/theme/index.test.js index 29887b999..eb954103d 100644 --- a/src/theme/index.test.js +++ b/src/theme/index.test.js @@ -12,12 +12,137 @@ describe("theme colors", () => { expect(DarkTheme.colors.mapSearchHighlight).toBe(rawColors.blue.blue150) }) - it("owns a horizontal panel background gradient in both themes", () => { - expect(DefaultTheme.colors.panelBgGrad).toBe( - `linear-gradient(to right, ${rawColors.neutral.grey195} 0%, ${rawColors.neutral.white} 50%, ${rawColors.neutral.grey195} 100%)` - ) + it("keeps panelBgGrad flat in light so legacy panels follow the surface ladder", () => { + expect(DefaultTheme.colors.panelBgGrad).toBe(DefaultTheme.colors.panelBg) expect(DarkTheme.colors.panelBgGrad).toBe( `linear-gradient(to right, ${rawColors.neutral.grey25} 0%, ${rawColors.neutral.grey15} 50%, ${rawColors.neutral.grey25} 100%)` ) }) }) + +describe("visual refresh token values", () => { + it("uses an AA-contrast red for error text in light theme", () => { + expect(DefaultTheme.colors.errorText).toBe(rawColors.red.red100) + }) + + it("uses an AA-contrast amber for warning in light theme", () => { + expect(DefaultTheme.colors.warning).toBe(rawColors.yellow.yellow50) + }) + + it("gives dark input borders a visible edge", () => { + expect(DarkTheme.colors.inputBorder).toBe(rawColors.neutral.grey85) + expect(DarkTheme.colors.inputBorderHover).toBe(rawColors.neutral.grey100) + }) + + it("keeps dark focused input borders distinct from resting borders", () => { + expect(DarkTheme.colors.inputBorderFocus).not.toBe(DarkTheme.colors.inputBorder) + }) + + it("renders tooltips as an inverse surface", () => { + expect(DefaultTheme.colors.tooltip).toBe(rawColors.neutral.limedSpruce) + expect(DefaultTheme.colors.tooltipText).toBe(rawColors.neutral.grey195) + expect(DarkTheme.colors.tooltip).toBe(rawColors.neutral.grey55) + expect(DarkTheme.colors.tooltipText).toBe(rawColors.neutral.grey185) + }) + + it("keeps table row dividers visible under hover", () => { + expect(DefaultTheme.colors.tableRowBgHover).toBe(rawColors.neutral.grey190) + expect(DarkTheme.colors.tableRowBgHover).toBe(rawColors.neutral.grey30) + }) + + it("draws the column resizer in primary green", () => { + expect(DefaultTheme.colors.resizerLine).toBe(rawColors.green.green100) + expect(DarkTheme.colors.resizerLine).toBe(rawColors.green.green100) + }) + + it("lifts the dark modal header above the page", () => { + expect(DarkTheme.colors.modalHeaderBackground).toBe(rawColors.neutral.grey30) + }) + + it("exposes hover and pressed steps for primary actions", () => { + expect(DefaultTheme.colors.primaryHover).toBe(rawColors.green.green60) + expect(DefaultTheme.colors.primaryPressed).toBe(rawColors.green.green50) + expect(DarkTheme.colors.primaryHover).toBe(rawColors.green.green110) + expect(DarkTheme.colors.primaryPressed).toBe(rawColors.green.green90) + }) +}) + +describe("soft pill text keys", () => { + it.each([ + ["successSoftText", rawColors.green.green70, rawColors.green.green100], + ["warningSoftText", rawColors.yellow.yellow50, rawColors.yellow.yellow70], + ["errorSoftText", rawColors.red.red90, rawColors.red.red120], + ["staleSoftText", rawColors.neutral.grey100, rawColors.neutral.grey150], + ])("%s exists in both themes", (key, light, dark) => { + expect(DefaultTheme.colors[key]).toBe(light) + expect(DarkTheme.colors[key]).toBe(dark) + }) +}) + +describe("approved colour proposal", () => { + it("adds controlBorder and textInfo to both themes", () => { + expect(DefaultTheme.colors.controlBorder).toBe(rawColors.neutral.grey130) + expect(DarkTheme.colors.controlBorder).toBe(rawColors.neutral.grey120) + expect(DefaultTheme.colors.textInfo).toBe(rawColors.blue.blue100) + expect(DarkTheme.colors.textInfo).toBe(rawColors.blue.blue150) + }) + + it("darkens the light action green so white labels reach 4.5:1", () => { + expect(DefaultTheme.colors.primary).toBe(rawColors.green.green70) + expect(DefaultTheme.colors.primaryHover).toBe(rawColors.green.green60) + expect(DefaultTheme.colors.primaryPressed).toBe(rawColors.green.green50) + expect(DefaultTheme.colors.secondaryColor).toBe(rawColors.green.green70) + expect(DefaultTheme.colors.link).toBe(rawColors.green.green70) + expect(DefaultTheme.colors.linkHover).toBe(rawColors.green.green60) + expect(DefaultTheme.colors.successText).toBe(rawColors.green.green70) + }) + + it("keeps the status green unchanged", () => { + expect(DefaultTheme.colors.success).toBe(rawColors.green.green100) + }) + + it("raises warning text and placeholder contrast", () => { + expect(DefaultTheme.colors.warningText).toBe(rawColors.yellow.yellow40) + expect(DefaultTheme.colors.placeholder).toBe(rawColors.neutral.grey110) + expect(DarkTheme.colors.placeholder).toBe(rawColors.neutral.grey125) + }) +}) + +describe("tooltip surfaces", () => { + // Apps draw tooltip containers on tooltipBg with tooltipText; both must be the same inverse pair as `tooltip`. + it("paints tooltipBg like tooltip so tooltipText stays readable on it", () => { + expect(DefaultTheme.colors.tooltipBg).toBe(DefaultTheme.colors.tooltip) + }) +}) + +it("paints dark tooltipBg like the drops tooltip", () => { + expect(DarkTheme.colors.tooltipBg).toBe(DarkTheme.colors.tooltip) +}) + +describe("light surface ladder and field edges (audit L1–L4)", () => { + const c = DefaultTheme.colors + const n = rawColors.neutral + it("uses one lightest grey: blackhaze keys sit on grey195", () => { + ;["elementBackground", "mainChartBg", "mainBackgroundDisabled", "modalInfoBackground"].forEach(key => + expect([key, c[key]]).toEqual([key, n.grey195]) + ) + }) + it("puts the sidebar on grey190 with hover one step darker", () => { + expect(c.sideBarMini).toBe(n.grey190) + expect(c.menuItemHover).toBe(n.grey185) + }) + it("keeps the time picker neutral; green stays for status", () => { + expect(c.datePickerPlayBg).toBe(n.grey190) + expect(c.datePickerPausedBg).toBe(n.grey185) + }) + it("gives fields one softer edge that still reaches 3:1, and darkens it on hover", () => { + expect(c.controlBorder).toBe(n.grey130) + expect(c.inputBorder).toBe(n.grey130) + expect(c.inputBorderHover).toBe(n.grey110) + expect(c.inputBorderFocus).toBe(n.grey110) + }) + it("renders stale as a neutral grey", () => { + expect(c.stale).toBe(n.grey130) + expect(c.staleSemi).toBe(n.grey120) + }) +}) diff --git a/visual/playwright.config.js b/visual/playwright.config.js new file mode 100644 index 000000000..503785062 --- /dev/null +++ b/visual/playwright.config.js @@ -0,0 +1,29 @@ +const { defineConfig } = require("@playwright/test") + +const port = 6200 + +// Baselines are only valid when produced in the pinned Playwright image (see `yarn test:visual`): +// fonts and anti-aliasing differ between macOS and Linux. +module.exports = defineConfig({ + testDir: __dirname, + testMatch: /stories\.spec\.js$/, + outputDir: `${__dirname}/test-results`, + snapshotPathTemplate: `${__dirname}/__screenshots__/{arg}{ext}`, + fullyParallel: true, + workers: process.env.CI ? 2 : undefined, + reporter: [["list"], ["html", { outputFolder: `${__dirname}/report`, open: "never" }]], + expect: { + toHaveScreenshot: { maxDiffPixelRatio: 0.001, animations: "disabled", caret: "hide" }, + }, + use: { + baseURL: `http://localhost:${port}`, + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: 1, + }, + webServer: { + command: `node ${__dirname}/serve.js`, + port, + env: { PORT: String(port) }, + reuseExistingServer: false, + }, +}) diff --git a/visual/serve.js b/visual/serve.js new file mode 100644 index 000000000..99d9e0e22 --- /dev/null +++ b/visual/serve.js @@ -0,0 +1,33 @@ +// Dependency-free static server for the built Storybook, so the Playwright container needs nothing extra. +const http = require("http") +const fs = require("fs") +const path = require("path") + +const root = path.resolve(__dirname, "../storybook-static") +const port = Number(process.env.PORT || 6200) + +const types = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + +http + .createServer((req, res) => { + const url = decodeURIComponent(req.url.split("?")[0]) + const file = path.join(root, url.endsWith("/") ? `${url}index.html` : url) + if (!file.startsWith(root) || !fs.existsSync(file)) { + res.writeHead(404) + res.end() + return + } + res.writeHead(200, { "Content-Type": types[path.extname(file)] || "application/octet-stream" }) + fs.createReadStream(file).pipe(res) + }) + .listen(port, () => console.log(`storybook-static on ${port}`)) diff --git a/visual/stories.spec.js b/visual/stories.spec.js new file mode 100644 index 000000000..c98a767dd --- /dev/null +++ b/visual/stories.spec.js @@ -0,0 +1,24 @@ +const fs = require("fs") +const path = require("path") +const { test, expect } = require("@playwright/test") + +const index = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../storybook-static/index.json"), "utf8") +) + +// Stories that depend on live time, randomness or network opt out with the `no-visual` tag. +const stories = Object.values(index.entries).filter( + entry => entry.type === "story" && !entry.tags?.includes("no-visual") +) + +for (const story of stories) { + for (const theme of ["light", "dark"]) { + test(`${story.id} (${theme})`, async ({ page }) => { + await page.goto(`/iframe.html?id=${story.id}&viewMode=story&globals=theme:${theme}`) + await page.waitForSelector("#storybook-root > *") + await page.evaluate(() => document.fonts.ready) + await page.waitForLoadState("networkidle") + await expect(page).toHaveScreenshot(`${story.id}--${theme}.png`, { fullPage: true }) + }) + } +} diff --git a/webpack.config.js b/webpack.config.js index 2843baccf..0601f2f1c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -45,6 +45,8 @@ module.exports = { resolve: { alias: { "@": path.resolve(__dirname, "src"), + // The script-tag bundle cannot inject CSS or emit font files; bundler consumers load the fonts via the cjs/es6 builds. + "@fontsource/ibm-plex-sans": false, }, }, } diff --git a/yarn.lock b/yarn.lock index 97cf73cb5..e2a08bd16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,6 +1503,11 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== +"@fontsource/ibm-plex-sans@^5.3.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz#8099950b404e625f65e7bd833bcb84ffe6bcd40c" + integrity sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w== + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -1867,6 +1872,13 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b" integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== +"@playwright/test@1.62.1": + version "1.62.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.62.1.tgz#68e1aaf6e480e1923936dee6c66fae1921d3a65a" + integrity sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ== + dependencies: + playwright "1.62.1" + "@prismicio/client@^7.21.6": version "7.21.6" resolved "https://registry.yarnpkg.com/@prismicio/client/-/client-7.21.6.tgz#04fabe495e7c382c2efa16a972d5fa3b3d294bc8" @@ -4860,6 +4872,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^2.3.3, fsevents@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -7164,6 +7181,20 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +playwright-core@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785" + integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw== + +playwright@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700" + integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg== + dependencies: + playwright-core "1.62.1" + optionalDependencies: + fsevents "2.3.2" + polished@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" From 12af6556c23b4e208f01579284faaa1c1b10c416 Mon Sep 17 00:00:00 2001 From: novykh Date: Wed, 4 Nov 2026 22:22:05 +0200 Subject: [PATCH 2/4] Fix review findings in IconButton, Tooltip, roving focus, StatusWord and Toggle - Focusable disabled IconButtons cancel activation so they cannot submit or reset a surrounding form, while keeping focus and their tooltip. - Tooltip treats only plain objects with title or description as the structured card; arrays and other React content render as given. - useRovingFocus clamps the active index to the remaining items so one item always stays reachable by Tab. - StatusWord renders its detail node without string coercion. - Toggle forwards aria-* and title from toggleProps to the switch input. --- src/components/button/iconButton.js | 8 ++- src/components/button/iconButton.test.js | 22 +++++++ src/components/button/useRovingFocus.js | 7 +- src/components/button/useRovingFocus.test.js | 27 ++++++++ src/components/drops/tooltip/index.js | 9 ++- src/components/drops/tooltip/index.test.js | 13 ++++ src/components/pill/statusWord.js | 2 +- src/components/pill/statusWord.test.js | 11 ++++ src/components/toggle/toggle.js | 69 +++++++++++++------- src/components/toggle/toggle.test.js | 26 ++++++++ 10 files changed, 164 insertions(+), 30 deletions(-) diff --git a/src/components/button/iconButton.js b/src/components/button/iconButton.js index 1e6af8a88..9a6a57b1d 100644 --- a/src/components/button/iconButton.js +++ b/src/components/button/iconButton.js @@ -9,7 +9,13 @@ const glyphSize = ({ large, compact }) => controlSizeOf({ large, compact }) === "compact" ? "14px" : "16px" // Native disabled drops focus and hover, so the tooltip that explains why becomes unreachable. -const focusableDisabledProps = { disabled: false, "aria-disabled": "true", onClick: undefined } +const preventActivation = event => event.preventDefault() + +const focusableDisabledProps = { + disabled: false, + "aria-disabled": "true", + onClick: preventActivation, +} const IconButton = ({ width, diff --git a/src/components/button/iconButton.test.js b/src/components/button/iconButton.test.js index 6d5119f4a..710d0ca85 100644 --- a/src/components/button/iconButton.test.js +++ b/src/components/button/iconButton.test.js @@ -164,3 +164,25 @@ describe("IconButton", () => { }) }) }) + +describe("focusableWhenDisabled inside a form", () => { + it("does not submit or reset the form when activated", async () => { + const onSubmit = jest.fn(event => event.preventDefault()) + const onReset = jest.fn() + renderWithProviders( +
+ + + + ) + const [submitButton, resetButton] = screen.getAllByRole("button") + await user().click(submitButton) + await user().click(resetButton) + submitButton.focus() + await user().keyboard("{Enter}") + + expect(onSubmit).not.toHaveBeenCalled() + expect(onReset).not.toHaveBeenCalled() + expect(submitButton).toHaveFocus() + }) +}) diff --git a/src/components/button/useRovingFocus.js b/src/components/button/useRovingFocus.js index 74254ca95..c027122cc 100644 --- a/src/components/button/useRovingFocus.js +++ b/src/components/button/useRovingFocus.js @@ -19,10 +19,11 @@ export const useRovingFocus = ({ count, orientation = "horizontal", initialIndex const [activeIndex, setActiveIndex] = useState(initialIndex) const nodes = useRef([]) const keys = keysByOrientation[orientation] || keysByOrientation.horizontal + const reachableIndex = count > 0 ? Math.min(Math.max(activeIndex, 0), count - 1) : activeIndex const getItemProps = useCallback( (index, { onKeyDown, onFocus, ref } = {}) => ({ - tabIndex: index === activeIndex ? 0 : -1, + tabIndex: index === reachableIndex ? 0 : -1, ref: node => { nodes.current[index] = node setRef(ref, node) @@ -40,10 +41,10 @@ export const useRovingFocus = ({ count, orientation = "horizontal", initialIndex nodes.current[next]?.focus() }, }), - [activeIndex, count, keys] + [reachableIndex, count, keys] ) - return { activeIndex, setActiveIndex, getItemProps } + return { activeIndex: reachableIndex, setActiveIndex, getItemProps } } export default useRovingFocus diff --git a/src/components/button/useRovingFocus.test.js b/src/components/button/useRovingFocus.test.js index 92be9d65d..5d625bfac 100644 --- a/src/components/button/useRovingFocus.test.js +++ b/src/components/button/useRovingFocus.test.js @@ -82,3 +82,30 @@ describe("useRovingFocus", () => { expect(button("Settings")).toHaveFocus() }) }) + +describe("when items disappear", () => { + const Shrinking = ({ count }) => { + const { getItemProps, setActiveIndex } = useRovingFocus({ count }) + return ( +
+ {actions.slice(0, count).map(({ icon, label }, index) => ( + + ))} + +
+ ) + } + + it("keeps one remaining item reachable by Tab after the active last item is removed", async () => { + const { rerender } = renderWithProviders() + await user().click(screen.getByText("last")) + expect(screen.getByRole("button", { name: "Settings" })).toHaveAttribute("tabindex", "0") + + rerender() + + const reachable = ["Refresh", "Filter"].filter( + name => screen.getByRole("button", { name }).getAttribute("tabindex") === "0" + ) + expect(reachable).toEqual(["Filter"]) + }) +}) diff --git a/src/components/drops/tooltip/index.js b/src/components/drops/tooltip/index.js index a905e5a87..ad1f38556 100644 --- a/src/components/drops/tooltip/index.js +++ b/src/components/drops/tooltip/index.js @@ -17,10 +17,17 @@ const TooltipText = styled(Text).attrs({ color: "tooltipText" })` line-height: 18px; ` +const isTitleDescription = content => + !!content && + typeof content === "object" && + !Array.isArray(content) && + !React.isValidElement(content) && + ("title" in content || "description" in content) + // Strings and { title, description } get the standard tooltip card; any other node renders as given. const renderBody = content => { if (typeof content === "string") return {content} - if (content && typeof content === "object" && !React.isValidElement(content)) { + if (isTitleDescription(content)) { const { title, description } = content return ( <> diff --git a/src/components/drops/tooltip/index.test.js b/src/components/drops/tooltip/index.test.js index 59f93264a..e84467a46 100644 --- a/src/components/drops/tooltip/index.test.js +++ b/src/components/drops/tooltip/index.test.js @@ -218,3 +218,16 @@ describe("tooltip behaviour", () => { }) const getByTestIdDrop = () => document.querySelector('[data-testid="drop"]') + +describe("tooltip content shapes", () => { + it("renders an array of nodes as given", () => { + const { getByText } = renderWithProviders( + first part
, " second part"]} delay={0}> + tooltip target + + ) + fireEvent.mouseOver(getByText("tooltip target")) + expect(getByText("first part")).toBeInTheDocument() + expect(getByTestIdDrop()).toHaveTextContent("first part second part") + }) +}) diff --git a/src/components/pill/statusWord.js b/src/components/pill/statusWord.js index 6e5dfb9fa..ef02cc155 100644 --- a/src/components/pill/statusWord.js +++ b/src/components/pill/statusWord.js @@ -34,7 +34,7 @@ const StatusWord = ({ {children} - {detail && {`· ${detail}`}} + {detail && · {detail}} ) } diff --git a/src/components/pill/statusWord.test.js b/src/components/pill/statusWord.test.js index 3642c60f7..7982a1294 100644 --- a/src/components/pill/statusWord.test.js +++ b/src/components/pill/statusWord.test.js @@ -59,4 +59,15 @@ describe("StatusWord", () => { expect(screen.getByTestId("node-status")).toBeInTheDocument() expect(screen.getByTestId("node-status-dot")).toBeInTheDocument() }) + + it("renders a React node detail after the separator", () => { + renderWithProviders( + 3 nodes}> + Critical + + ) + const detail = screen.getByTestId("status-word-detail") + expect(detail).toHaveTextContent("· 3 nodes") + expect(detail.querySelector("strong")).toHaveTextContent("3 nodes") + }) }) diff --git a/src/components/toggle/toggle.js b/src/components/toggle/toggle.js index c54187a84..e2655e441 100644 --- a/src/components/toggle/toggle.js +++ b/src/components/toggle/toggle.js @@ -2,6 +2,17 @@ import React from "react" import { Text } from "@/components/typography" import { ToggleContainer, HiddenToggleInput, StyledToggle, StyledLabel, LabelText } from "./styled" +const isAccessibilityProp = key => key.startsWith("aria-") || key === "title" + +const splitAccessibility = toggleProps => + Object.entries(toggleProps).reduce( + (parts, [key, value]) => { + parts[isAccessibilityProp(key) ? "accessibility" : "visual"][key] = value + return parts + }, + { accessibility: {}, visual: {} } + ) + export const Toggle = ({ checked, disabled, @@ -15,27 +26,37 @@ export const Toggle = ({ alignSelf, toggleProps = {}, ...props -}) => ( - - {labelLeft && ( - - {labelLeft} - - )} - - - - {labelRight && ( - - {labelRight} - - )} - -) +}) => { + const { accessibility, visual } = splitAccessibility(toggleProps) + + return ( + + {labelLeft && ( + + {labelLeft} + + )} + + + + {labelRight && ( + + {labelRight} + + )} + + ) +} diff --git a/src/components/toggle/toggle.test.js b/src/components/toggle/toggle.test.js index 642f5bf0e..59945f73f 100644 --- a/src/components/toggle/toggle.test.js +++ b/src/components/toggle/toggle.test.js @@ -248,3 +248,29 @@ describe("Toggle accessibility", () => { expect(getTrack()).toHaveAttribute("aria-hidden", "true") }) }) + +describe("accessibility attributes passed through toggleProps", () => { + it("names the switch input from toggleProps aria attributes", () => { + renderWithProviders( + {}} + toggleProps={{ "aria-label": "Enable Payments team", "aria-describedby": "hint" }} + /> + ) + const toggle = screen.getByRole("switch", { name: "Enable Payments team" }) + expect(toggle).toHaveAttribute("aria-describedby", "hint") + }) + + it("lets direct props win over toggleProps", () => { + renderWithProviders( + {}} + aria-label="Direct" + toggleProps={{ "aria-label": "From toggleProps" }} + /> + ) + expect(screen.getByRole("switch", { name: "Direct" })).toBeInTheDocument() + }) +}) From ded4aa1413d70ee966bb88d6bd3f950ffe6c9585 Mon Sep 17 00:00:00 2001 From: novykh Date: Thu, 5 Nov 2026 00:28:23 +0200 Subject: [PATCH 3/4] Share the visually-hidden recipe and shadow value, keep a zero StatusWord detail - One visuallyHidden mixin for toggle, checkbox, mark counts and copy live region - controlSize falls back to the canonical CONTROL_SIZES - dropdownStyles exports floatingShadowValue so Select no longer parses a css fragment - StatusWord renders detail={0} with its separator - Merge MarkCounts tests that shared one arrangement --- src/components/checkbox/styled.js | 12 ++---------- src/components/copyToClipboard/index.js | 11 ++--------- src/components/drops/dropdownStyles.js | 9 ++++++--- src/components/pill/markCounts.js | 11 ++--------- src/components/pill/markCounts.test.js | 22 +++++----------------- src/components/pill/statusWord.js | 5 ++++- src/components/pill/statusWord.test.js | 19 ++++++++++++++++++- src/components/select/index.js | 14 ++------------ src/components/toggle/styled.js | 12 ++---------- src/mixins/controlSize.js | 9 ++------- src/mixins/visuallyHidden.js | 16 ++++++++++++++++ 11 files changed, 61 insertions(+), 79 deletions(-) create mode 100644 src/mixins/visuallyHidden.js diff --git a/src/components/checkbox/styled.js b/src/components/checkbox/styled.js index bdc1dce92..fdb3764c0 100644 --- a/src/components/checkbox/styled.js +++ b/src/components/checkbox/styled.js @@ -2,20 +2,12 @@ import styled, { css } from "styled-components" import Flex from "@/components/templates/flex" import { getColor } from "@/theme" import { focusRingOutline } from "@/mixins/focusRing" +import visuallyHidden from "@/mixins/visuallyHidden" export const HiddenCheckboxInput = styled.input.attrs({ type: "checkbox", })` - border: 0; - clip: rect(0 0 0 0); - clip-path: inset(50%); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; + ${visuallyHidden} ` const isFilled = props => props["data-state"] === "checked" || props["data-state"] === "mixed" diff --git a/src/components/copyToClipboard/index.js b/src/components/copyToClipboard/index.js index a42092707..432467346 100644 --- a/src/components/copyToClipboard/index.js +++ b/src/components/copyToClipboard/index.js @@ -8,6 +8,7 @@ import { dropdownBorder, dropdownRound, floatingShadow } from "@/components/drop import copyToClipboard from "@/utils/copyToClipboard" import useDropElement from "@/hooks/useDropElement" import { getSizeBy } from "@/theme/utils" +import visuallyHidden from "@/mixins/visuallyHidden" const FEEDBACK_DURATION = 1200 @@ -72,15 +73,7 @@ const FeedbackText = styled(TextSmall)` // Persistent so screen readers announce later text changes; a region inserted with its content is often silent. const LiveRegion = styled(TextSmall).attrs({ role: "status", "aria-live": "polite" })` - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; - border: 0; + ${visuallyHidden} ` const ClickableWrapper = styled.span` diff --git a/src/components/drops/dropdownStyles.js b/src/components/drops/dropdownStyles.js index fd0cc5e3e..ca1a6f4a3 100644 --- a/src/components/drops/dropdownStyles.js +++ b/src/components/drops/dropdownStyles.js @@ -17,10 +17,13 @@ export const withDropdownDefaults = props => // One elevation recipe for every floating layer; colour comes from the theme so dark mode gets a deeper shadow. const shadowColor = getColor("dropdownShadow") +export const floatingShadowValue = theme => { + const color = shadowColor({ theme }) + return `0 4px 12px ${color}, 0 1px 2px ${color}` +} + export const floatingShadow = css` - box-shadow: - 0 4px 12px ${shadowColor}, - 0 1px 2px ${shadowColor}; + box-shadow: ${({ theme }) => floatingShadowValue(theme)}; ` // Same recipe as a filter, so arrowed shapes (tooltip, popover) cast one silhouette instead of a box. diff --git a/src/components/pill/markCounts.js b/src/components/pill/markCounts.js index e97a57a3e..706edcde8 100644 --- a/src/components/pill/markCounts.js +++ b/src/components/pill/markCounts.js @@ -3,6 +3,7 @@ import styled from "styled-components" import { getColor, getSizeBy } from "@/theme" import Dot from "./dot" import { getPillColor } from "./mixins/colors" +import visuallyHidden from "@/mixins/visuallyHidden" const Counts = styled.span` display: inline-flex; @@ -21,15 +22,7 @@ const Count = styled.span` ` const VisuallyHidden = styled.span` - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; - border: 0; + ${visuallyHidden} ` const MarkCounts = ({ counts = [], "data-testid": testId = "mark-counts", ...rest }) => ( diff --git a/src/components/pill/markCounts.test.js b/src/components/pill/markCounts.test.js index 0b4c6ffbb..e9cfcb158 100644 --- a/src/components/pill/markCounts.test.js +++ b/src/components/pill/markCounts.test.js @@ -11,35 +11,23 @@ const counts = [ ] describe("MarkCounts", () => { - it("renders one tone-coloured dot and number per count", () => { + it("renders a tabular, tone-coloured dot, number and screen-reader label per count", () => { renderWithProviders() const items = screen.getAllByTestId("mark-counts-item") + const dots = screen.getAllByTestId("mark-counts-dot") + const label = screen.getByText("critical", { exact: false, selector: "span" }) + expect(items).toHaveLength(3) expect(items[0]).toHaveTextContent("3") expect(items[0]).toHaveStyleRule("color", light.errorSoftText) expect(items[1]).toHaveStyleRule("color", light.warningSoftText) - }) - - it("uses tabular numerals", () => { - renderWithProviders() expect(screen.getByTestId("mark-counts")).toHaveStyleRule( "font-variant-numeric", "tabular-nums" ) - }) - - it("draws filled dots by default and a ring for hollow", () => { - renderWithProviders() - const dots = screen.getAllByTestId("mark-counts-dot") expect(dots[0]).toHaveStyleRule("background-color", light.errorSoftText) expect(dots[2]).toHaveStyleRule("border", `1.5px solid ${light.staleSoftText}`) - }) - - it("gives screen readers the label with each count", () => { - renderWithProviders() - const [first] = screen.getAllByTestId("mark-counts-item") - expect(first).toHaveTextContent("3 critical") - const label = screen.getByText("critical", { exact: false, selector: "span" }) + expect(items[0]).toHaveTextContent("3 critical") expect(label).toHaveStyleRule("position", "absolute") expect(label).toHaveStyleRule("clip", "rect(0 0 0 0)") }) diff --git a/src/components/pill/statusWord.js b/src/components/pill/statusWord.js index ef02cc155..ff50c0fa9 100644 --- a/src/components/pill/statusWord.js +++ b/src/components/pill/statusWord.js @@ -20,6 +20,9 @@ const Detail = styled.span` color: ${getColor("textLite")}; ` +const hasDetail = detail => + detail !== undefined && detail !== null && detail !== false && detail !== "" + const StatusWord = ({ children, "data-testid": testId = "status-word", @@ -34,7 +37,7 @@ const StatusWord = ({ {children} - {detail && · {detail}} + {hasDetail(detail) && · {detail}} ) } diff --git a/src/components/pill/statusWord.test.js b/src/components/pill/statusWord.test.js index 7982a1294..eca9edb57 100644 --- a/src/components/pill/statusWord.test.js +++ b/src/components/pill/statusWord.test.js @@ -13,7 +13,10 @@ describe("StatusWord", () => { expect(word).toHaveStyleRule("font-size", "13px") expect(word).toHaveStyleRule("font-weight", "500") expect(word).toHaveStyleRule("color", light.errorSoftText) - expect(screen.getByTestId("status-word-dot")).toHaveStyleRule("background-color", light.errorSoftText) + expect(screen.getByTestId("status-word-dot")).toHaveStyleRule( + "background-color", + light.errorSoftText + ) expect(screen.getByTestId("status-word-dot")).toHaveAttribute("aria-hidden", "true") }) @@ -46,6 +49,20 @@ describe("StatusWord", () => { expect(screen.queryByTestId("status-word-detail")).not.toBeInTheDocument() }) + it("renders a zero detail after the separator", () => { + renderWithProviders( + + Critical + + ) + expect(screen.getByTestId("status-word-detail")).toHaveTextContent("· 0") + }) + + it("renders no detail for an empty string", () => { + renderWithProviders(Idle) + expect(screen.queryByTestId("status-word-detail")).not.toBeInTheDocument() + }) + it("defaults to the neutral text colour and supports muted", () => { const { unmount } = renderWithProviders(Idle) expect(screen.getByTestId("status-word")).toHaveStyleRule("color", light.text) diff --git a/src/components/select/index.js b/src/components/select/index.js index 6a0754be7..52644de6d 100644 --- a/src/components/select/index.js +++ b/src/components/select/index.js @@ -4,7 +4,7 @@ import ReactSelect, { components as defaultComponents } from "react-select" import Creatable from "react-select/creatable" import { Icon } from "@/components/icon" import { useVirtualizer } from "@tanstack/react-virtual" -import { dropdownRound, floatingShadow } from "@/components/drops/dropdownStyles" +import { dropdownRound, floatingShadowValue } from "@/components/drops/dropdownStyles" import { getRgbColor, getSizeBy } from "@/theme" import { controlHeight, controlFontSize } from "@/mixins/controlSize" @@ -153,16 +153,6 @@ const VirtualizedMenuList = props => { const sizeBy = (theme, multiplier) => getSizeBy(multiplier)({ theme }) -// react-select takes object styles, so the shared css fragment is resolved to its box-shadow value. -const getFloatingShadow = theme => - floatingShadow - .map(rule => (typeof rule === "function" ? rule({ theme }) : rule)) - .join("") - .replace(/^\s*box-shadow:\s*/, "") - .replace(/;\s*$/, "") - .replace(/\s+/g, " ") - .trim() - const indicatorStyles = theme => ({ color: theme.colors.textLite, padding: `0 ${sizeBy(theme, 0.75)}`, @@ -259,7 +249,7 @@ const makeCustomStyles = (theme, { minWidth, ...providedStyles } = {}) => { backgroundColor: theme.colors.dropdown, border: `1px solid ${theme.colors.border}`, borderRadius: dropdownRound * theme.constants.SIZE_SUB_UNIT, - boxShadow: getFloatingShadow(theme), + boxShadow: floatingShadowValue(theme), marginTop: sizeBy(theme, 0.5), overflow: "hidden", zIndex: 100, diff --git a/src/components/toggle/styled.js b/src/components/toggle/styled.js index f33ce3534..ab11d3235 100644 --- a/src/components/toggle/styled.js +++ b/src/components/toggle/styled.js @@ -1,6 +1,7 @@ import styled, { css } from "styled-components" import { getColor, getSizeBy, getSizeUnit } from "@/theme/utils" import { focusRingOutline } from "@/mixins/focusRing" +import visuallyHidden from "@/mixins/visuallyHidden" import margin from "@/mixins/margin" import alignSelf from "@/mixins/alignSelf" @@ -15,16 +16,7 @@ export const ToggleContainer = styled.div` export const HiddenToggleInput = styled.input.attrs({ type: "checkbox", })` - border: 0; - clip: rect(0 0 0 0); - clip-path: inset(50%); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; + ${visuallyHidden} ` export const StyledLabel = styled.label` diff --git a/src/mixins/controlSize.js b/src/mixins/controlSize.js index 4ae7e199c..792635ccc 100644 --- a/src/mixins/controlSize.js +++ b/src/mixins/controlSize.js @@ -1,12 +1,7 @@ import { getOrElse } from "@/theme/utils" +import { CONTROL_SIZES } from "@/theme/default/constants" -const fallback = { - compact: { height: 24, fontSize: 12 }, - default: { height: 28, fontSize: 13 }, - large: { height: 32, fontSize: 14 }, -} - -const getSizes = getOrElse(["constants", "CONTROL_SIZES"], fallback) +const getSizes = getOrElse(["constants", "CONTROL_SIZES"], CONTROL_SIZES) // Buttons use boolean `large`/`compact`; fields use `size`. Both resolve to the same ladder. export const controlSizeOf = ({ size, large, compact } = {}) => { diff --git a/src/mixins/visuallyHidden.js b/src/mixins/visuallyHidden.js new file mode 100644 index 000000000..68aca6c2f --- /dev/null +++ b/src/mixins/visuallyHidden.js @@ -0,0 +1,16 @@ +import { css } from "styled-components" + +const visuallyHidden = css` + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +` + +export default visuallyHidden From 876850e860fad9b4757d9647a21d6e8c0583ddde Mon Sep 17 00:00:00 2001 From: novykh Date: Thu, 5 Nov 2026 09:53:30 +0200 Subject: [PATCH 4/4] v5.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b0b5a3ff1..07e041254 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/netdata-ui", - "version": "5.5.16", + "version": "5.6.0", "description": "netdata UI kit", "main": "dist/index.js", "module": "dist/es6/index.js",