feat(webui): standardize discard and table actions

This commit is contained in:
2026-07-21 20:47:54 +02:00
parent bf0729eb59
commit 41ad057f7e
7 changed files with 151 additions and 32 deletions

View File

@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs | | UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces | | UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors | | UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
@@ -191,9 +195,10 @@ explicitly retains the exception.
Decision: Scheduling requests provide a concrete reference application of the Decision: Scheduling requests provide a concrete reference application of the
universal placement and component rules. universal placement and component rules.
- The `Scheduling requests` header owns one `Add` action. - The persistent left panel stacks `My scheduling requests` and `Scheduling
- Its left panel is used for the creation view only, not as a permanent second requests for me`; it is list context, not a second creation affordance.
creation affordance. - The left panel's `Scheduling requests` header owns one `Add` action. It opens
the shared view/create/edit surface in the right main panel.
- Basic information, Calendar integration, candidate slots, and participants - Basic information, Calendar integration, candidate slots, and participants
use the central `Card` component as four logical sections. use the central `Card` component as four logical sections.
- Candidate slots and participants use the central `DataGrid`, including its - Candidate slots and participants use the central `DataGrid`, including its
@@ -207,6 +212,50 @@ universal placement and component rules.
Equivalent list/create/edit surfaces use the same underlying rules. These are Equivalent list/create/edit surfaces use the same underlying rules. These are
not Scheduling-local component variants. not Scheduling-local component variants.
### DUE-011: Field Help, Discard, And Table Action Contracts
Decision: the central components own these interactions; modules compose them
instead of reproducing their behavior.
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
compositions use `FieldLabel` explicitly when the meaning or limitation is
not self-explanatory.
- `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root.
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
the same shared unsaved-changes dialog. A browser tab/window unload remains a
browser-controlled confirmation because browsers do not permit a custom
modal at that boundary.
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
`disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.
#### FieldLabel Omission Register
Every Core field surface that intentionally does not render `FieldLabel` is
listed here. Module repositories keep an equivalent register in their durable
UI documentation until a central cross-repository audit is available.
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
| --- | --- | --- |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
#### Alert Exception Register
No `window.alert` or global `alert` exception is authorized.
## Implementation Sequence ## Implementation Sequence
| Phase | Scope | Output | | Phase | Scope | Output |
@@ -277,6 +326,14 @@ Every new or changed admin/configuration surface should answer:
- Are logical sections, tabular collections, boolean settings, and repeatable - Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively? structured row per person respectively?
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
recorded with its rationale and accessible-name source?
- Do explicit Discard and dirty navigation use the shared unsaved-changes
registration/dialog rather than a page-local confirmation?
- Does every row retain the table's action set in the same order, disabling
unavailable actions and reserving the same empty-row slots?
- Is feedback rendered with a central dialog/alert component, with no
unauthorized `window.alert` or global `alert` call?
- If automation is involved, can the user see the trigger, system actor, - If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state? observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees? - Are technical details available without being the first thing the user sees?

View File

@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void; registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: UnsavedNavigationAction) => void; requestNavigation: (action: UnsavedNavigationAction) => void;
/**
* Route an explicit Discard button through the same confirmation used for
* dirty navigation. The action runs after either saving or discarding.
*/
requestDiscard: (action: UnsavedNavigationAction) => void;
}; };
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null); const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
setPendingAction(() => action); setPendingAction(() => action);
}, []); }, []);
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
requestNavigation(action);
}, [requestNavigation]);
useEffect(() => { useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) { function onBeforeUnload(event: BeforeUnloadEvent) {
const active = registrationRef.current; const active = registrationRef.current;
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const value = useMemo<UnsavedChangesContextValue>(() => ({ const value = useMemo<UnsavedChangesContextValue>(() => ({
hasUnsavedChanges, hasUnsavedChanges,
registerUnsavedChanges, registerUnsavedChanges,
requestNavigation requestNavigation,
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]); requestDiscard
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
return ( return (
<UnsavedChangesContext.Provider value={value}> <UnsavedChangesContext.Provider value={value}>
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = { const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false, hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined, registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action() requestNavigation: (action) => action(),
requestDiscard: (action) => action()
}; };
export function useUnsavedChanges() { export function useUnsavedChanges() {

View File

@@ -106,6 +106,8 @@ type DataGridProps<T> = {
export type DataGridRowActionsProps = { export type DataGridRowActionsProps = {
disabled?: boolean; disabled?: boolean;
removeDisabled?: boolean; removeDisabled?: boolean;
/** Set to false only when row ordering is not part of this table's action set. */
reorderable?: boolean;
onAddBelow: () => void; onAddBelow: () => void;
onRemove: () => void; onRemove: () => void;
onMoveUp?: () => void; onMoveUp?: () => void;
@@ -858,6 +860,7 @@ export function DataGridPaginationBar({
export function DataGridRowActions({ export function DataGridRowActions({
disabled = false, disabled = false,
removeDisabled = false, removeDisabled = false,
reorderable = true,
onAddBelow, onAddBelow,
onRemove, onRemove,
onMoveUp, onMoveUp,
@@ -879,20 +882,18 @@ export function DataGridRowActions({
disabled, disabled,
onClick: onAddBelow onClick: onAddBelow
}, },
{ reorderable && {
id: "move-up", id: "move-up",
label: moveUpLabel, label: moveUpLabel,
icon: <ArrowUp size={16} aria-hidden="true" />, icon: <ArrowUp size={16} aria-hidden="true" />,
applicable: Boolean(onMoveUp), disabled: disabled || !onMoveUp,
disabled,
onClick: () => onMoveUp?.() onClick: () => onMoveUp?.()
}, },
{ reorderable && {
id: "move-down", id: "move-down",
label: moveDownLabel, label: moveDownLabel,
icon: <ArrowDown size={16} aria-hidden="true" />, icon: <ArrowDown size={16} aria-hidden="true" />,
applicable: Boolean(onMoveDown), disabled: disabled || !onMoveDown,
disabled,
onClick: () => onMoveDown?.() onClick: () => onMoveDown?.()
}, },
{ {
@@ -910,16 +911,18 @@ export function DataGridRowActions({
export function DataGridEmptyAction({ export function DataGridEmptyAction({
disabled = false, disabled = false,
reorderable = true,
onAdd, onAdd,
label = "i18n:govoplan-core.add_first_row.c82c15f2" label = "i18n:govoplan-core.add_first_row.c82c15f2"
}: {disabled?: boolean;onAdd: () => void;label?: string;}) { }: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
return ( return (
<TableActionGroup <TableActionGroup
className="data-grid-row-actions data-grid-empty-row-actions" className="data-grid-row-actions data-grid-empty-row-actions"
minimumSlots={reorderable ? 4 : 2}
actions={[{ actions={[{
id: "add", id: "add",
label, label,

View File

@@ -7,9 +7,14 @@ export type TableActionDefinition = {
label: string; label: string;
icon: ReactNode; icon: ReactNode;
onClick: () => void; onClick: () => void;
/** Set to false when the action does not apply to this row. */ /**
* @deprecated Use `disabled` for a row-level unavailable state and omit the
* action from the array only when it is not part of this table's action set.
* A false value is retained as a disabled, visible action for compatibility.
*/
applicable?: boolean; applicable?: boolean;
disabled?: boolean; disabled?: boolean;
disabledReason?: ReactNode;
variant?: "primary" | "secondary" | "ghost" | "danger"; variant?: "primary" | "secondary" | "ghost" | "danger";
}; };
@@ -17,12 +22,14 @@ export type TableActionGroupProps = {
actions: readonly (TableActionDefinition | false | null | undefined)[]; actions: readonly (TableActionDefinition | false | null | undefined)[];
label?: string; label?: string;
className?: string; className?: string;
/** Reserve trailing action slots so controls keep the same column position. */
minimumSlots?: number;
}; };
function isApplicableAction( function isDefinedAction(
action: TableActionDefinition | false | null | undefined action: TableActionDefinition | false | null | undefined
): action is TableActionDefinition { ): action is TableActionDefinition {
return Boolean(action && action.applicable !== false); return Boolean(action);
} }
export function runTableAction( export function runTableAction(
@@ -36,19 +43,24 @@ export function runTableAction(
/** /**
* The standard row-level action surface for tables and DataGrids. * The standard row-level action surface for tables and DataGrids.
* *
* Callers describe only actions that make sense for the row. An action may be * Callers define the action set for the whole table. An action that cannot be
* disabled when it applies but is temporarily unavailable; actions marked as * used for one row stays visible and disabled; an action that is structurally
* not applicable are omitted entirely. Labels remain available to assistive * irrelevant to the table is omitted from the array. Labels remain available
* technology and as native tooltips while the visible controls stay icon-only. * to assistive technology and as native tooltips while the visible controls
* stay icon-only. `minimumSlots` reserves trailing positions for empty-state
* rows, keeping their first action aligned with ordinary rows.
*/ */
export default function TableActionGroup({ export default function TableActionGroup({
actions, actions,
label = "i18n:govoplan-core.actions.c3cd636a", label = "i18n:govoplan-core.actions.c3cd636a",
className = "" className = "",
minimumSlots = 0
}: TableActionGroupProps) { }: TableActionGroupProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const applicableActions = actions.filter(isApplicableAction); const definedActions = actions.filter(isDefinedAction);
if (applicableActions.length === 0) return null; if (definedActions.length === 0) return null;
const normalizedMinimumSlots = Number.isFinite(minimumSlots) ? Math.max(0, Math.floor(minimumSlots)) : 0;
const placeholderCount = Math.max(0, normalizedMinimumSlots - definedActions.length);
return ( return (
<div <div
@@ -56,7 +68,7 @@ export default function TableActionGroup({
role="group" role="group"
aria-label={translateText(label)} aria-label={translateText(label)}
> >
{applicableActions.map((action) => { {definedActions.map((action) => {
const translatedLabel = translateText(action.label); const translatedLabel = translateText(action.label);
return ( return (
<Button <Button
@@ -66,7 +78,8 @@ export default function TableActionGroup({
className="table-action-button" className="table-action-button"
aria-label={translatedLabel} aria-label={translatedLabel}
title={translatedLabel} title={translatedLabel}
disabled={action.disabled} disabled={action.disabled || action.applicable === false}
disabledReason={action.disabledReason}
onClick={(event) => runTableAction(event, action.onClick)} onClick={(event) => runTableAction(event, action.onClick)}
> >
<span className="table-action-icon" aria-hidden="true"> <span className="table-action-icon" aria-hidden="true">
@@ -75,6 +88,13 @@ export default function TableActionGroup({
</Button> </Button>
); );
})} })}
{Array.from({ length: placeholderCount }, (_unused, index) => (
<span
key={`reserved-action-slot-${index}`}
className="table-action-placeholder"
aria-hidden="true"
/>
))}
</div> </div>
); );
} }

View File

@@ -92,7 +92,15 @@ export default function SettingsPage({
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage(); const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null; const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null; const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]); const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
"mail_servers:read",
"mail_servers:write",
"mail_servers:manage_credentials",
"mail:profile:write_own",
"mail:secret:manage_own",
"admin:policies:read",
"admin:policies:write"
]);
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]); const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const contributedSections = useMemo( const contributedSections = useMemo(
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)), () => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
@@ -319,8 +327,8 @@ export default function SettingsPage({
scopeId={auth.user.id} scopeId={auth.user.id}
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798" profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23" policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
canWriteProfiles={hasScope(auth, "mail_servers:write")} canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} /> canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
} }

View File

@@ -686,6 +686,15 @@
place-items: center; place-items: center;
} }
.table-action-placeholder {
display: block;
flex: 0 0 36px;
width: 36px;
height: 36px;
visibility: hidden;
pointer-events: none;
}
.data-grid-empty-message { .data-grid-empty-message {
color: var(--muted); color: var(--muted);
min-height: 64px; min-height: 64px;

View File

@@ -32,18 +32,23 @@ const emptyAction = renderToStaticMarkup(
); );
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control"); assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form"); assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "empty actions reserve the remaining ordinary row positions");
const contextActions = renderToStaticMarkup( const contextActions = renderToStaticMarkup(
<TableActionGroup <TableActionGroup
actions={[ actions={[
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop }, { id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop }, { id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
{ id: "remove", label: "Remove", icon: <span>R</span>, disabled: true, onClick: noop } { id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
]} ]}
minimumSlots={4}
/> />
); );
assertEqual(buttonCount(contextActions), 2, "table action groups omit actions that do not apply"); assertEqual(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form"); assertEqual(nonSubmittingButtonCount(contextActions), 3, "table action groups do not submit an enclosing form");
assertEqual((contextActions.match(/disabled=""/g) ?? []).length, 2, "unavailable actions are disabled");
assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1, "minimum action slots reserve trailing positions");
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label"); assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip"); assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative"); assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
@@ -58,10 +63,16 @@ assertEqual(propagationStopped, true, "table actions do not trigger clickable ro
assertEqual(actionCalled, true, "table actions still invoke their handler"); assertEqual(actionCalled, true, "table actions still invoke their handler");
const noReorderActions = renderToStaticMarkup( const noReorderActions = renderToStaticMarkup(
<DataGridRowActions onAddBelow={noop} onRemove={noop} /> <DataGridRowActions reorderable={false} onAddBelow={noop} onRemove={noop} />
); );
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply"); assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
const unavailableReorderActions = renderToStaticMarkup(
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
);
assertEqual(buttonCount(unavailableReorderActions), 4, "ordered rows retain unavailable boundary controls");
assertEqual((unavailableReorderActions.match(/disabled=""/g) ?? []).length, 2, "unavailable boundary controls are disabled");
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>); const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button"); assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable"); assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");