diff --git a/.vscode/settings.json b/.vscode/settings.json index d1b99fd3f373e..f9b18af11a55d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,6 +60,7 @@ "idtoken", "Iflag", "incpatch", + "initialisms", "ipnstate", "isatty", "Jobf", diff --git a/site/src/components/Abbr/Abbr.stories.tsx b/site/src/components/Abbr/Abbr.stories.tsx new file mode 100644 index 0000000000000..b47546dcb05ce --- /dev/null +++ b/site/src/components/Abbr/Abbr.stories.tsx @@ -0,0 +1,74 @@ +import { type PropsWithChildren } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Abbr } from "./Abbr"; + +// Just here to make the abbreviated part more obvious in the component library +const Underline = ({ children }: PropsWithChildren) => ( + {children} +); + +const meta: Meta = { + title: "components/Abbr", + component: Abbr, + decorators: [ + (Story) => ( + <> +

Try the following text out in a screen reader!

+ + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const InlinedShorthand: Story = { + args: { + pronunciation: "shorthand", + children: "ms", + title: "milliseconds", + }, + decorators: [ + (Story) => ( +

+ The physical pain of getting bonked on the head with a cartoon mallet + lasts precisely 593{" "} + + + + . The emotional turmoil and complete embarrassment lasts forever. +

+ ), + ], +}; + +export const Acronym: Story = { + args: { + pronunciation: "acronym", + children: "NASA", + title: "National Aeronautics and Space Administration", + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Initialism: Story = { + args: { + pronunciation: "initialism", + children: "CLI", + title: "Command-Line Interface", + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/site/src/components/Abbr/Abbr.test.tsx b/site/src/components/Abbr/Abbr.test.tsx new file mode 100644 index 0000000000000..58e37287f6011 --- /dev/null +++ b/site/src/components/Abbr/Abbr.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from "@testing-library/react"; +import { Abbr, type Pronunciation } from "./Abbr"; + +type AbbreviationData = { + abbreviation: string; + title: string; + expectedLabel: string; +}; + +type AssertionInput = AbbreviationData & { + pronunciation: Pronunciation; +}; + +function assertAccessibleLabel({ + abbreviation, + title, + expectedLabel, + pronunciation, +}: AssertionInput) { + const { unmount } = render( + + {abbreviation} + , + ); + + screen.getByLabelText(expectedLabel, { selector: "abbr" }); + unmount(); +} + +describe(Abbr.name, () => { + it("Has an aria-label that equals the title if the abbreviation is shorthand", () => { + const sampleShorthands: AbbreviationData[] = [ + { + abbreviation: "ms", + title: "milliseconds", + expectedLabel: "milliseconds", + }, + { + abbreviation: "g", + title: "grams", + expectedLabel: "grams", + }, + ]; + + for (const shorthand of sampleShorthands) { + assertAccessibleLabel({ ...shorthand, pronunciation: "shorthand" }); + } + }); + + it("Has an aria label with title and 'flattened' pronunciation if abbreviation is acronym", () => { + const sampleAcronyms: AbbreviationData[] = [ + { + abbreviation: "NASA", + title: "National Aeronautics and Space Administration", + expectedLabel: "Nasa (National Aeronautics and Space Administration)", + }, + { + abbreviation: "AWOL", + title: "Absent without Official Leave", + expectedLabel: "Awol (Absent without Official Leave)", + }, + { + abbreviation: "YOLO", + title: "You Only Live Once", + expectedLabel: "Yolo (You Only Live Once)", + }, + ]; + + for (const acronym of sampleAcronyms) { + assertAccessibleLabel({ ...acronym, pronunciation: "acronym" }); + } + }); + + it("Has an aria label with title and initialized pronunciation if abbreviation is initialism", () => { + const sampleInitialisms: AbbreviationData[] = [ + { + abbreviation: "FBI", + title: "Federal Bureau of Investigation", + expectedLabel: "F.B.I. (Federal Bureau of Investigation)", + }, + { + abbreviation: "YMCA", + title: "Young Men's Christian Association", + expectedLabel: "Y.M.C.A. (Young Men's Christian Association)", + }, + { + abbreviation: "CLI", + title: "Command-Line Interface", + expectedLabel: "C.L.I. (Command-Line Interface)", + }, + ]; + + for (const initialism of sampleInitialisms) { + assertAccessibleLabel({ ...initialism, pronunciation: "initialism" }); + } + }); +}); diff --git a/site/src/components/Abbr/Abbr.tsx b/site/src/components/Abbr/Abbr.tsx new file mode 100644 index 0000000000000..9fba0618a57cf --- /dev/null +++ b/site/src/components/Abbr/Abbr.tsx @@ -0,0 +1,66 @@ +import { type FC, type HTMLAttributes } from "react"; + +export type Pronunciation = "shorthand" | "acronym" | "initialism"; + +type AbbrProps = HTMLAttributes & { + children: string; + title: string; + pronunciation?: Pronunciation; +}; + +/** + * A more sophisticated version of the native element. + * + * Features: + * - Better type-safety (requiring you to include certain properties) + * - All built-in HTML styling is stripped away by default + * - Better integration with screen readers (like exposing the title prop to + * them), with more options for influencing how they pronounce text + */ +export const Abbr: FC = ({ + children, + title, + pronunciation = "shorthand", + ...delegatedProps +}) => { + return ( + + {children} + + ); +}; + +function getAccessibleLabel( + abbreviation: string, + title: string, + pronunciation: Pronunciation, +): string { + if (pronunciation === "initialism") { + return `${initializeText(abbreviation)} (${title})`; + } + + if (pronunciation === "acronym") { + return `${flattenPronunciation(abbreviation)} (${title})`; + } + + return title; +} + +function initializeText(text: string): string { + return text.trim().toUpperCase().replaceAll(/\B/g, ".") + "."; +} + +function flattenPronunciation(text: string): string { + const trimmed = text.trim(); + return (trimmed[0] ?? "").toUpperCase() + trimmed.slice(1).toLowerCase(); +} diff --git a/site/src/components/Dashboard/Navbar/NavbarView.tsx b/site/src/components/Dashboard/Navbar/NavbarView.tsx index 8b37d41ba1d53..66c7c7fc22f17 100644 --- a/site/src/components/Dashboard/Navbar/NavbarView.tsx +++ b/site/src/components/Dashboard/Navbar/NavbarView.tsx @@ -18,6 +18,8 @@ import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLat import { CoderIcon } from "components/Icons/CoderIcon"; import { usePermissions } from "hooks/usePermissions"; import { UserDropdown } from "./UserDropdown/UserDropdown"; +import { visuallyHidden } from "@mui/utils"; +import { Abbr } from "components/Abbr/Abbr"; export const USERS_LINK = `/users?filter=${encodeURIComponent( "status:active", @@ -214,25 +216,22 @@ const ProxyMenu: FC = ({ proxyContextValue }) => { const isLoadingLatencies = Object.keys(latencies).length === 0; const isLoading = proxyContextValue.isLoading || isLoadingLatencies; const permissions = usePermissions(); + const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => { if (!refetchDate) { // Only show loading if the user manually requested a refetch return false; } - const latency = latencies?.[proxy.id]; // Only show a loading spinner if: - // - A latency exists. This means the latency was fetched at some point, so the - // loader *should* be resolved. + // - A latency exists. This means the latency was fetched at some point, so + // the loader *should* be resolved. // - The proxy is healthy. If it is not, the loader might never resolve. - // - The latency reported is older than the refetch date. This means the latency - // is stale and we should show a loading spinner until the new latency is - // fetched. - if (proxy.healthy && latency && latency.at < refetchDate) { - return true; - } - - return false; + // - The latency reported is older than the refetch date. This means the + // latency is stale and we should show a loading spinner until the new + // latency is fetched. + const latency = latencies[proxy.id]; + return proxy.healthy && latency !== undefined && latency.at < refetchDate; }; if (isLoading) { @@ -257,12 +256,18 @@ const ProxyMenu: FC = ({ proxyContextValue }) => { "& .MuiSvgIcon-root": { fontSize: 14 }, }} > + + Latency for {selectedProxy?.display_name ?? "your region"} + + {selectedProxy ? (
= ({ proxyContextValue }) => { }} />
+ = ({ proxyContextValue }) => { "Select Proxy" )} +
= ({ proxyContextValue }) => { }} >

= ({ proxyContextValue }) => { > Select a region nearest to you

+

= ({ proxyContextValue }) => { }} > Workspace proxies improve terminal and web app connections to - workspaces. This does not apply to CLI connections. A region must be - manually selected, otherwise the default primary region will be - used. + workspaces. This does not apply to{" "} + + CLI + {" "} + connections. A region must be manually selected, otherwise the + default primary region will be used.

+ + {proxyContextValue.proxies ?.sort((a, b) => { const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity; @@ -329,6 +349,9 @@ const ProxyMenu: FC = ({ proxyContextValue }) => { }) .map((proxy) => ( { if (!proxy.healthy) { displayError("Please select a healthy workspace proxy."); @@ -339,9 +362,6 @@ const ProxyMenu: FC = ({ proxyContextValue }) => { proxyContextValue.setProxy(proxy); closeMenu(); }} - key={proxy.id} - selected={proxy.id === selectedProxy?.id} - css={{ fontSize: 14 }} >
= ({ proxyContextValue }) => { }} />
+ {proxy.display_name} + = ({ proxyContextValue }) => {
))} + + {Boolean(permissions.editWorkspaceProxies) && ( = ({ proxyContextValue }) => { Proxy settings )} + { diff --git a/site/src/components/ProxyStatusLatency/ProxyStatusLatency.tsx b/site/src/components/ProxyStatusLatency/ProxyStatusLatency.tsx index 6776cf06e1f55..f139314f05748 100644 --- a/site/src/components/ProxyStatusLatency/ProxyStatusLatency.tsx +++ b/site/src/components/ProxyStatusLatency/ProxyStatusLatency.tsx @@ -4,6 +4,8 @@ import Tooltip from "@mui/material/Tooltip"; import { type FC } from "react"; import { getLatencyColor } from "utils/latency"; import CircularProgress from "@mui/material/CircularProgress"; +import { visuallyHidden } from "@mui/utils"; +import { Abbr } from "components/Abbr/Abbr"; interface ProxyStatusLatencyProps { latency?: number; @@ -33,22 +35,29 @@ export const ProxyStatusLatency: FC = ({ } if (!latency) { + const notAvailableText = "Latency not available"; return ( - - + + <> + {notAvailableText} + + + ); } return ( -
- {latency.toFixed(0)}ms -
+

+ Latency: + {latency.toFixed(0)} + ms +

); };