diff --git a/webui/package.json b/webui/package.json index 28782ae..f018cd2 100644 --- a/webui/package.json +++ b/webui/package.json @@ -28,7 +28,8 @@ "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js", "test:module-permutations": "node scripts/test-module-permutations.mjs", "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js", - "test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js" + "test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js", + "test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js" }, "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", diff --git a/webui/src/components/SelectionList.tsx b/webui/src/components/SelectionList.tsx new file mode 100644 index 0000000..0f0c10c --- /dev/null +++ b/webui/src/components/SelectionList.tsx @@ -0,0 +1,67 @@ +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; +import { usePlatformLanguage } from "../i18n/LanguageContext"; + +export type SelectionListProps = Omit, "children" | "role"> & { + children: ReactNode; + label?: string; +}; + +export type SelectionListItemProps = Omit< + ButtonHTMLAttributes, + "aria-selected" | "children" | "role" +> & { + children: ReactNode; + selected: boolean; +}; + +export default function SelectionList({ + "aria-label": ariaLabel, + children, + className = "", + label, + ...props +}: SelectionListProps) { + const { translateText } = usePlatformLanguage(); + const rootClassName = ["selection-list", className].filter(Boolean).join(" "); + + return ( +
+ {children} +
+ ); +} + +export function SelectionListItem({ + children, + className = "", + disabled = false, + selected, + type = "button", + ...props +}: SelectionListItemProps) { + const itemClassName = [ + "selection-list-item", + selected ? "is-selected" : "", + disabled ? "is-disabled" : "", + className + ].filter(Boolean).join(" "); + + return ( + + ); +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 775e6bc..57eaa5f 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -96,6 +96,8 @@ export { default as PolicyLockedHint } from "./components/PolicyLockedHint"; export type { PolicyLockedHintProps } from "./components/PolicyLockedHint"; export { default as SegmentedControl } from "./components/SegmentedControl"; export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl"; +export { default as SelectionList, SelectionListItem } from "./components/SelectionList"; +export type { SelectionListItemProps, SelectionListProps } from "./components/SelectionList"; export { default as StatusBadge } from "./components/StatusBadge"; export { default as Stepper } from "./components/Stepper"; export { default as ToggleSwitch } from "./components/ToggleSwitch"; diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index a782b2d..4c9823f 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -2525,6 +2525,55 @@ opacity: .55; } +.selection-list { + display: grid; + min-width: 0; +} + +:where(.selection-list-item) { + appearance: none; + box-sizing: border-box; + width: 100%; + min-width: 0; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + padding: 8px 10px; + text-align: left; + transition: background .16s ease, border-color .16s ease, box-shadow .16s ease; +} + +:where(.selection-list-item):hover:not(:disabled) { + background: var(--hover-tint-soft); +} + +:where(.selection-list-item).is-selected { + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); +} + +:where(.selection-list-item):focus-visible { + outline: var(--focus-outline); + outline-offset: -2px; +} + +:where(.selection-list-item)[draggable="true"] { + cursor: grab; +} + +:where(.selection-list-item)[draggable="true"]:active { + cursor: grabbing; +} + +:where(.selection-list-item):disabled, +:where(.selection-list-item).is-disabled { + cursor: not-allowed; + opacity: .55; +} + .theme-preview-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); diff --git a/webui/tests/selection-list.test.tsx b/webui/tests/selection-list.test.tsx new file mode 100644 index 0000000..315ac05 --- /dev/null +++ b/webui/tests/selection-list.test.tsx @@ -0,0 +1,52 @@ +function assert(condition: unknown, message = "assertion failed"): void { + if (!condition) throw new Error(message); +} + +import { renderToStaticMarkup } from "react-dom/server"; +import SelectionList, { SelectionListItem } from "../src/components/SelectionList"; +import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; + +function noop() {} + +const markup = renderToStaticMarkup( + + + + Primary content + Supporting content + + + Disabled content + + + +); + +assert(markup.includes('role="listbox"'), "the root exposes listbox semantics"); +assert(markup.includes('class="selection-list domain-list"'), "root caller classes are preserved"); +assert(markup.includes('data-source="inbox"'), "root native properties pass through"); +assert(markup.includes('aria-label="Notifications"'), "the accessible label is translated"); +assert(markup.includes('role="option"'), "items expose option semantics"); +assert(markup.includes('aria-selected="true"'), "selected state is exposed accessibly"); +assert(markup.includes('class="selection-list-item is-selected domain-row"'), "selected and domain classes are composed"); +assert(markup.includes('draggable="true"'), "native draggable properties pass through"); +assert(markup.includes('data-record-id="record-1"'), "native data properties pass through"); +assert(markup.includes("Primary contentSupporting content"), "React node content is preserved"); +assert(markup.includes('type="button"'), "items default to non-submitting buttons"); +assert(markup.includes('aria-selected="false"'), "unselected state is exposed accessibly"); +assert(markup.includes('aria-disabled="true"'), "disabled state is exposed accessibly"); +assert(markup.includes('disabled=""'), "disabled state uses the native button contract"); + +const nativeLabelMarkup = renderToStaticMarkup( + + Content + +); +assert(nativeLabelMarkup.includes('aria-label="Already translated"'), "native accessible labels pass through unchanged"); diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json index 78a51d5..6e4894b 100644 --- a/webui/tsconfig.component-tests.json +++ b/webui/tsconfig.component-tests.json @@ -23,11 +23,13 @@ "tests/icon-button.test.tsx", "tests/mail-components.test.tsx", "tests/resource-access-explanation.test.tsx", + "tests/selection-list.test.tsx", "src/components/CredentialPanel.tsx", "src/components/email/EmailAddressInput.tsx", "src/components/PasswordField.tsx", "src/components/MessageDisplayPanel.tsx", "src/components/ResourceAccessExplanation.tsx", + "src/components/SelectionList.tsx", "src/components/mail/MailServerSettingsPanel.tsx", "src/components/Button.tsx", "src/components/DismissibleAlert.tsx",