diff --git a/README.md b/README.md index 57dfa3ab..9359838b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ - **Developing a DiracX-Web extension?** Go straight to the [:page_facing_up: Extension README](https://diracx.diracgrid.org/en/latest/developer/manage_extension/) - **Managing the repository?** Discover tips and tricks in the [:book: Ops Guide](https://diracx.diracgrid.org/en/latest/dev/setup_environment/) -- **Interested in contributing?** Read the [:star: Contributing Document](CONTRIBUTING.md) +- **Interested in contributing?** Read the [:star: Contributing Document](docs/dev/how-to/contribute-to-web.md) diff --git a/packages/diracx-web-components/eslint.config.js b/packages/diracx-web-components/eslint.config.js index d9e5296d..6d049957 100644 --- a/packages/diracx-web-components/eslint.config.js +++ b/packages/diracx-web-components/eslint.config.js @@ -5,6 +5,7 @@ import _import from "eslint-plugin-import"; import typescriptEslint from "@typescript-eslint/eslint-plugin"; import react from "eslint-plugin-react"; import reactHooks from "eslint-plugin-react-hooks"; +import jsxA11y from "eslint-plugin-jsx-a11y"; import tsParser from "@typescript-eslint/parser"; import js from "@eslint/js"; import { FlatCompat } from "@eslint/eslintrc"; @@ -30,6 +31,7 @@ export default [ "prettier", ), ), + jsxA11y.flatConfigs.recommended, { plugins: { import: fixupPluginRules(_import), @@ -79,6 +81,19 @@ export default [ ignoreRestSiblings: true, }, ], + "@typescript-eslint/no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@mui/icons-material", + message: + "Import icons individually: import Icon from '@mui/icons-material/Icon'", + allowTypeImports: true, + }, + ], + }, + ], "no-restricted-properties": [ "error", { diff --git a/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx b/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx index 8bd14ea4..0f7d97e1 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx @@ -7,7 +7,13 @@ import IconButton from "@mui/material/IconButton"; import Menu from "@mui/icons-material/Menu"; import Toolbar from "@mui/material/Toolbar"; import Stack from "@mui/material/Stack"; -import { Typography, useMediaQuery, useTheme } from "@mui/material"; +import { + Typography, + darken, + lighten, + useMediaQuery, + useTheme, +} from "@mui/material"; import { useCurrentApplication } from "../../hooks/application"; import { ProfileButton } from "./ProfileButton"; import { ThemeToggleButton } from "./ThemeToggleButton"; @@ -47,6 +53,19 @@ export default function Dashboard({ const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + // Endpoints for the app title gradient, derived per palette mode. The + // palette mains are tuned for surfaces rather than text and are far too + // light to read against the light-mode app bar (~2:1), so darken them + // there and lighten them on dark; both modes then clear WCAG AA (4.5:1). + const titleGradientStart = + theme.palette.mode === "light" + ? darken(theme.palette.primary.main, 0.4) + : lighten(theme.palette.primary.main, 0.2); + const titleGradientEnd = + theme.palette.mode === "light" + ? darken(theme.palette.secondary.main, 0.4) + : lighten(theme.palette.secondary.main, 0.2); + /** State management for mobile drawer */ const [mobileOpen, setMobileOpen] = useState(false); const handleDrawerToggle = () => { @@ -90,7 +109,6 @@ export default function Dashboard({ }} > {appTitle} diff --git a/packages/diracx-web-components/src/components/DashboardLayout/DrawerItem.tsx b/packages/diracx-web-components/src/components/DashboardLayout/DrawerItem.tsx index 3febf103..2a364ba0 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/DrawerItem.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/DrawerItem.tsx @@ -227,6 +227,7 @@ export default function DrawerItem({ // The rename field only appears in direct response to a user // action (context-menu "Rename"), so moving focus into it is // the expected behavior, not a focus steal. + // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus size="small" /> diff --git a/packages/diracx-web-components/src/components/DashboardLayout/DrawerItemGroup.tsx b/packages/diracx-web-components/src/components/DashboardLayout/DrawerItemGroup.tsx index cf2b8ae6..0d141faa 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/DrawerItemGroup.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/DrawerItemGroup.tsx @@ -149,6 +149,7 @@ export default function DrawerItemGroup({ // The rename field only appears in direct response to a user // action (context-menu "Rename"), so moving focus into it is // the expected behavior, not a focus steal. + // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus size="small" /> diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ProfileButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ProfileButton.tsx index 526ef780..01ca733a 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/ProfileButton.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/ProfileButton.tsx @@ -1,7 +1,6 @@ "use client"; import { useOidc, useOidcAccessToken } from "@axa-fr/react-oidc"; -import Info from "@mui/icons-material/Info"; import Logout from "@mui/icons-material/Logout"; import CorporateFare from "@mui/icons-material/CorporateFare"; import Groups from "@mui/icons-material/Groups"; @@ -179,16 +178,6 @@ export function ProfileButton() { - { - handleClose(); - }} - > - - - - About - { handleClose(); diff --git a/packages/diracx-web-components/src/components/Login/LoginForm.tsx b/packages/diracx-web-components/src/components/Login/LoginForm.tsx index bc7477d9..8671f285 100644 --- a/packages/diracx-web-components/src/components/Login/LoginForm.tsx +++ b/packages/diracx-web-components/src/components/Login/LoginForm.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, use } from "react"; import { + Alert, Box, Typography, FormControl, @@ -10,10 +11,10 @@ import { Select, MenuItem, Button, - Stack, Autocomplete, TextField, SelectChangeEvent, + Skeleton, } from "@mui/material"; import { useOidc } from "@axa-fr/react-oidc"; @@ -115,10 +116,42 @@ export function LoginForm({ }; if (isLoading) { - return
Loading...
; + return ( + + + + + + + + + ); } if (error) { - return
An error occurred while fetching metadata.
; + return ( + An error occurred while fetching metadata. + ); } if (!metadata) { return
No metadata found.
; @@ -207,25 +240,14 @@ export function LoginForm({ ))} - - - - + Login via your Identity Provider + diff --git a/packages/diracx-web-components/src/contexts/ThemeProvider.tsx b/packages/diracx-web-components/src/contexts/ThemeProvider.tsx index 349b11d2..98e521a1 100644 --- a/packages/diracx-web-components/src/contexts/ThemeProvider.tsx +++ b/packages/diracx-web-components/src/contexts/ThemeProvider.tsx @@ -8,11 +8,10 @@ import { lighten, darken, alpha, - PaletteMode, useMediaQuery, } from "@mui/material"; import { cyan, grey, lightGreen } from "@mui/material/colors"; -import { createContext, useMemo, useState } from "react"; +import { createContext, useCallback, useMemo, useState } from "react"; declare module "@mui/material/styles" { interface Palette { @@ -29,13 +28,21 @@ declare module "@mui/material/styles" { } } +/** The two supported theme modes */ +export type ThemeMode = "light" | "dark"; + +/** Type guard narrowing an arbitrary stored string to a valid theme mode */ +function isThemeMode(value: string | null): value is ThemeMode { + return value === "light" || value === "dark"; +} + /** * Theme context type * @property theme - the current theme mode * @property toggleTheme - function to toggle the theme mode */ type ThemeContextType = { - theme: string; + theme: ThemeMode; toggleTheme: () => void; }; @@ -58,49 +65,45 @@ export const ThemeContext = createContext( */ export const ThemeProvider = ({ children }: ThemeProviderProps) => { const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); - const [theme, setTheme] = useState(null); + const [theme, setTheme] = useState(null); const [prevPrefersDark, setPrevPrefersDark] = useState(prefersDarkMode); - // Initialize theme from sessionStorage or system preference + // Initialize theme from sessionStorage or system preference. + // Render is kept pure: storage is only written from the toggle handler, + // where it records an explicit user choice. if (theme === null) { const storedTheme = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("theme") : null; - if (storedTheme) { - setTheme(storedTheme); - } else { - const defaultTheme = prefersDarkMode ? "dark" : "light"; - setTheme(defaultTheme); - if (typeof sessionStorage !== "undefined") { - sessionStorage.setItem("theme", defaultTheme); - } - } + setTheme( + isThemeMode(storedTheme) + ? storedTheme + : prefersDarkMode + ? "dark" + : "light", + ); } - // Update theme when system preference changes + // Follow system preference changes unless the user made an explicit choice if (prevPrefersDark !== prefersDarkMode) { setPrevPrefersDark(prefersDarkMode); const storedTheme = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("theme") : null; - if (!storedTheme) { - const defaultTheme = prefersDarkMode ? "dark" : "light"; - setTheme(defaultTheme); - if (typeof sessionStorage !== "undefined") { - sessionStorage.setItem("theme", defaultTheme); - } + if (!isThemeMode(storedTheme)) { + setTheme(prefersDarkMode ? "dark" : "light"); } } - const toggleTheme = () => { + const toggleTheme = useCallback(() => { setTheme((prevTheme) => { - const newTheme = prevTheme === "light" ? "dark" : "light"; + const newTheme: ThemeMode = prevTheme === "light" ? "dark" : "light"; sessionStorage.setItem("theme", newTheme); return newTheme; }); - }; + }, []); const muiTheme = useMemo(() => { if (theme === null) return createTheme(); @@ -130,10 +133,20 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => { ? lighten(tableRowFirstColor, 0.2) : darken(tableRowSecondColor, 0.8); + // Outlined-input border/label colors for the current palette mode. + // Light mode keeps the historical grey[200] derivatives; dark mode + // mirrors them from grey[800] so borders stay subtle on dark surfaces. + const inputBorder = theme === "light" ? grey[200] : grey[800]; + const inputBorderHover = + theme === "light" ? darken(grey[200], 0.2) : lighten(grey[800], 0.2); + const inputBorderFocused = + theme === "light" ? darken(grey[200], 0.4) : lighten(grey[800], 0.4); + const inputTextFocused = theme === "light" ? "black" : "white"; + // Create a Material-UI theme based on the current mode const muiTheme = createTheme({ palette: { - mode: theme as PaletteMode, + mode: theme, primary: { main: primary, }, @@ -243,7 +256,7 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => { styleOverrides: { root: { "&.Mui-focused": { - color: darken(grey[200], 0.4), + color: inputBorderFocused, }, }, }, @@ -252,14 +265,14 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => { styleOverrides: { root: { "& .MuiOutlinedInput-notchedOutline": { - borderColor: grey[200], + borderColor: inputBorder, }, "&:hover .MuiOutlinedInput-notchedOutline": { - borderColor: darken(grey[200], 0.2), + borderColor: inputBorderHover, }, "&.Mui-focused .MuiOutlinedInput-notchedOutline": { - borderColor: darken(grey[200], 0.4), - color: "black", + borderColor: inputBorderFocused, + color: inputTextFocused, }, }, }, @@ -363,17 +376,20 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => { defaultProps: { size: "small" }, styleOverrides: { root: { - "& .MuiTableRow-root:nth-of-type(odd)": { + // Use data-attribute selectors instead of nth-of-type so that + // virtualized tables (where DOM order ≠ data order) get stable + // alternating row colors. + "& .MuiTableRow-root[data-row-parity='odd'] td": { backgroundColor: muiTheme.palette.tableRow.odd, - "&:hover": { - backgroundColor: darken(muiTheme.palette.tableRow.odd, 0.1), - }, }, - "& .MuiTableRow-root:nth-of-type(even)": { + "& .MuiTableRow-root[data-row-parity='odd']:hover td": { + backgroundColor: darken(muiTheme.palette.tableRow.odd, 0.1), + }, + "& .MuiTableRow-root[data-row-parity='even'] td": { backgroundColor: muiTheme.palette.tableRow.even, - "&:hover": { - backgroundColor: darken(muiTheme.palette.tableRow.even, 0.1), - }, + }, + "& .MuiTableRow-root[data-row-parity='even']:hover td": { + backgroundColor: darken(muiTheme.palette.tableRow.even, 0.1), }, }, }, @@ -383,12 +399,17 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => { return muiTheme; }, [theme]); - if (theme === null) { + const themeContextValue = useMemo( + () => (theme === null ? null : { theme, toggleTheme }), + [theme, toggleTheme], + ); + + if (themeContextValue === null) { return
Loading Theme...
; } return ( - + {children} diff --git a/packages/diracx-web-components/test/Dashboard.test.tsx b/packages/diracx-web-components/test/Dashboard.test.tsx index dfc499aa..00e24c33 100644 --- a/packages/diracx-web-components/test/Dashboard.test.tsx +++ b/packages/diracx-web-components/test/Dashboard.test.tsx @@ -290,8 +290,13 @@ describe("ThemeToggleButton", () => { expect(getByTestId("dark-mode")).toBeInTheDocument(); expect(queryByTestId("light-mode")).not.toBeInTheDocument(); - // Verify sessionStorage was updated to match system preference - expect(sessionStorage.getItem("theme")).toBe("light"); + // The system-derived default is NOT persisted (storage only records an + // explicit user choice, written from the toggle handler) + expect(sessionStorage.getItem("theme")).toBeNull(); + + // An explicit toggle persists the chosen theme + fireEvent.click(getByTestId("theme-toggle-button")); + expect(sessionStorage.getItem("theme")).toBe("dark"); }); it("prioritizes sessionStorage theme over system preference", () => { @@ -348,7 +353,6 @@ describe("ProfileButton", () => { expect(getByText("dirac")).toBeInTheDocument(); expect(getByText("group1")).toBeInTheDocument(); expect(getByText("Properties")).toBeInTheDocument(); - expect(getByText("About")).toBeInTheDocument(); expect(getByText("Logout")).toBeInTheDocument(); // Expand properties diff --git a/packages/diracx-web-components/test/LoginForm.test.tsx b/packages/diracx-web-components/test/LoginForm.test.tsx index a6326070..13bb0f04 100644 --- a/packages/diracx-web-components/test/LoginForm.test.tsx +++ b/packages/diracx-web-components/test/LoginForm.test.tsx @@ -49,9 +49,10 @@ describe("LoginForm", () => { }); it("works for the Loading story", () => { - render(); + const { container } = render(); - expect(screen.getByText("Loading...")).toBeInTheDocument(); + // Skeletons are rendered instead of plain text + expect(container.querySelector(".MuiSkeleton-root")).toBeInTheDocument(); expect(screen.queryByTestId("vo-name")).toBeNull(); }); }); diff --git a/packages/diracx-web/eslint.config.js b/packages/diracx-web/eslint.config.js index 77a5c99d..b92cc20b 100644 --- a/packages/diracx-web/eslint.config.js +++ b/packages/diracx-web/eslint.config.js @@ -40,7 +40,9 @@ export default [ "react/destructuring-assignment": ["error", "always"], // Coding style rules - "comma-dangle": ["error", "always-multiline"], + // Formatting (commas, semicolons, indentation) is owned by Prettier; + // re-enabling the corresponding ESLint rules after extending + // eslint-config-prettier reintroduces the exact conflicts it prevents. "no-unreachable": ["error"], "prefer-const": ["error"], "no-unused-vars": [ @@ -53,10 +55,6 @@ export default [ }, ], "@typescript-eslint/no-explicit-any": ["error"], - semi: ["error", "always"], - - // Identation rules - indent: ["error", 2], "no-restricted-properties": [ "error", diff --git a/packages/extensions/eslint.config.js b/packages/extensions/eslint.config.js index 77a5c99d..b92cc20b 100644 --- a/packages/extensions/eslint.config.js +++ b/packages/extensions/eslint.config.js @@ -40,7 +40,9 @@ export default [ "react/destructuring-assignment": ["error", "always"], // Coding style rules - "comma-dangle": ["error", "always-multiline"], + // Formatting (commas, semicolons, indentation) is owned by Prettier; + // re-enabling the corresponding ESLint rules after extending + // eslint-config-prettier reintroduces the exact conflicts it prevents. "no-unreachable": ["error"], "prefer-const": ["error"], "no-unused-vars": [ @@ -53,10 +55,6 @@ export default [ }, ], "@typescript-eslint/no-explicit-any": ["error"], - semi: ["error", "always"], - - // Identation rules - indent: ["error", 2], "no-restricted-properties": [ "error",