This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-9016-3726dd7b1de85c649a3d953cfa4ee17479c89df6
in repository https://gitbox.apache.org/repos/asf/texera.git

commit a46ddc6eb6588491eb72b27bc975f060c4cb1a75
Author: yangzhang75 <[email protected]>
AuthorDate: Mon Oct 19 00:47:40 2026 +0000

    fix(workflow-form): keep the keyboard out of a read-only input card, not 
only the pointer (#9016)
    
    ### What changes were proposed in this PR?
    
    Closes #8943. Part of the Form View feature (#8011); from the #8439
    refinements (item 5).
    
    A read-only input card -- what a viewer without write access sees -- is
    locked with `pointer-events: none`. That is hit testing only. The bound
    formly controls are disabled through `props.disabled`, but the controls
    a custom widget draws of its own are not: the HuggingFace audio and
    image uploaders each render a plain file input that nothing disables. A
    reader could Tab into one inside a locked card, open it with Enter and
    choose a file.
    
    For the audio input that is a real write. The widget POSTs the chosen
    file to `/api/huggingface/upload-audio`, which authorises any signed-in
    user and never asks who may write this workflow, so the file lands in
    the server's temp store. Checked on a workflow shared read-only: two
    presses of Tab reach the uploader inside the locked card, Enter opens
    the file dialog, and the POST comes back 200 with the stored path. The
    image uploader is client-side, so there the reader only makes the card
    display a picture of their choosing -- a read-only card wires no
    write-back and a reader's save is refused, so the operator keeps the
    author's value and a run still runs it.
    
    The card now also keeps the keyboard out, through a small directive on
    it (`texeraReadOnlyCard`, applied as `[texeraReadOnlyCard]="!canEdit"`
    beside the existing `read-only` class):
    
    - Every focusable descendant is taken out of the tab order
    (`tabindex="-1"`, its own value remembered) and marked
    `aria-disabled="true"`, so there is nothing for Tab to reach and
    assistive tech says why rather than going quiet.
    - Every control the browser will refuse outright -- button, input,
    select, textarea -- is also disabled natively, its previous state
    remembered. Out of the tab order is not out of reach: `tabindex="-1"`
    ends only sequential navigation and `aria-disabled` only announces, so a
    reader's assistive tech could still focus a widget's file input and
    activate it, which for the audio one posts the file (Copilot).
    - A card that becomes editable gets its controls back, each with the
    tabindex and the disabled state it had, and only those this directive
    took.
    - A control that leaves the card is let go of on the next pass: a widget
    redraws its own controls while the card lives on, so a set that only
    grew held every detached node for as long as the reader stayed on the
    page (Copilot).
    - Widgets render their own controls after the card is built and redraw
    them as they go, so the locking is re-applied on every change inside the
    card: a control added, one whose own tabindex is written back (ng-zorro
    writes one when it enables a control), and one a widget enables again
    (the audio uploader's `[disabled]="isUploading"`). The observer runs
    outside Angular and sets only what is not already set, so one pass
    settles it.
    
    `inert` is what the step panel uses for its read-only mount, and the
    issue suggested following it; this does not, deliberately. `inert` also
    removes the subtree from the accessibility tree, and a reader's card
    exists to be read -- the values are what a reader runs the workflow with
    -- so that would trade this bug for a worse one. Disabled controls stay
    in the accessibility tree and keep being announced. The directive's doc
    comment says so, next to the code.
    
    Nothing else changes: the pointer lock, the styles, the card's content
    and the author's editing path are untouched, and the directive does
    nothing at all while a card is editable.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8943, which re-files #8758 (closed). Part of #8439 (item 5) and
    the Form View feature #8011. Raised by Copilot on #8437, #8438 and
    #8440.
    
    Three neighbouring things are deliberately left alone. The widgets' own
    controls still do not consult the field's disabled state in their own
    handlers -- fixing that is per-widget work (the HuggingFace uploaders,
    the dataset picker, the resource value selector), and the card-level
    lock covers every widget at once, including ones added later. The upload
    endpoint itself authorises on being signed in and nothing else
    (`@RolesAllowed("REGULAR","ADMIN")`, no workflow-access check), so it
    would still accept a file posted by hand; closing the UI route does not
    close that one, and it is worth its own issue. And the step panel's
    `inert` mount has the same accessibility-tree problem described above;
    changing it is not this issue's scope, and it is worth its own look.
    
    ### How was this PR tested?
    
    Unit tests (vitest). The directive's own spec, on a host shaped like a
    card (a bound input, a widget's Browse button, a link, a span, a control
    with its own `tabindex`): every focusable control in a read-only card is
    out of the tab order; each is marked `aria-disabled` while the card is
    not inert and its text is still there; an editable card is left alone,
    keeping a control's own `tabindex="3"`; a card that becomes editable
    gets that `3` back, the untouched control gets no `tabindex`, and the
    `aria-disabled` marks go; a control a widget adds after the card was
    built is locked; a control whose widget writes its `tabindex` back, or
    enables again, is locked again; a click on a locked control dispatches
    no activation and the controls report disabled (jsdom does not model a
    disabled control refusing focus, so that half is asserted through the
    property the browser acts on); the disabled state is given back on
    unlock; five rounds of a control being added and removed leave the
    tracked set where it started, rather than holding the detached nodes;
    the observer is disconnected when the card goes away. The page's
    rendered spec wires it through the real template: a read-only viewer's
    card, with a button appended the way a widget renders one, has it out of
    the tab order and marked, and the card is not inert.
    
    By hand, on two dev builds of the same base commit, one with this change
    and one without, signed in as a second account the workflow is shared
    read-only with. Before: from the view toggle, two presses of Tab land on
    the uploader inside the locked card, Enter opens the file dialog, and
    choosing a file POSTs it (200). After: the same two presses land on Run,
    the card is skipped, and no request goes out. The mouse reaches nothing
    in either build, and the stored workflow is unchanged in both.
    
    Each guard was deletion-checked and turned a named test red (the
    directive not applied; the tabindex write; the aria-disabled write; the
    native disable; the editable check; the unlock; the original-tabindex
    restore; the original-disabled restore; the release of what has left the
    card; the childList and both attribute halves of the observer; the
    disconnect). eslint, prettier and the production (AOT) build pass; every
    statement, function and branch this PR adds is covered, and the full
    frontend suite passes.
    
    #### Video
    
    **Before**
    
    
    
    
https://github.com/user-attachments/assets/cd1b58ff-6cc4-4958-ad78-dd00bb86f14e
    
    
    
    **After**
    
    
    
https://github.com/user-attachments/assets/8f524126-d370-48f3-bc70-68497791cf5f
    
    
    
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Generated-by: Claude Code (Anthropic). Co-authored with Claude,
    reviewed line by line by the author before submission.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
 .../workflow-form/read-only-card.directive.spec.ts | 217 +++++++++++++++++++++
 .../workflow-form/read-only-card.directive.ts      | 190 ++++++++++++++++++
 .../workflow-form/workflow-form.component.html     |   1 +
 .../workflow-form/workflow-form.component.ts       |   2 +
 .../workflow-form/workflow-form.rendered.spec.ts   |  26 +++
 5 files changed, 436 insertions(+)

diff --git 
a/frontend/src/app/workspace/component/workflow-form/read-only-card.directive.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/read-only-card.directive.spec.ts
new file mode 100644
index 0000000000..30e723dbd6
--- /dev/null
+++ 
b/frontend/src/app/workspace/component/workflow-form/read-only-card.directive.spec.ts
@@ -0,0 +1,217 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component } from "@angular/core";
+import { NgIf } from "@angular/common";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ReadOnlyCardDirective } from "./read-only-card.directive";
+
+/** A card as the Form View builds one: a bound control, and the controls a 
widget draws of its own. */
+@Component({
+  template: `
+    <section [texeraReadOnlyCard]="locked">
+      <input class="bound" />
+      <button class="browse">Browse</button>
+      <a
+        class="help"
+        href="https://example.invalid";
+        >Help</a
+      >
+      <span class="label">Model</span>
+      <div
+        class="custom"
+        tabindex="3">
+        Pick
+      </div>
+      <button
+        class="added"
+        *ngIf="widgetRendered">
+        Added later
+      </button>
+    </section>
+  `,
+  imports: [NgIf, ReadOnlyCardDirective],
+})
+class HostComponent {
+  locked = true;
+  widgetRendered = false;
+}
+
+/**
+ * The read-only card is locked with `pointer-events: none`, which only stops 
the pointer. These
+ * cover the keyboard half: nothing inside a reader's card is in the tab 
order, while the card stays
+ * readable (it is not inert, so its content is still in the accessibility 
tree).
+ */
+describe("ReadOnlyCardDirective", () => {
+  let fixture: ComponentFixture<HostComponent>;
+  const host = () => fixture.nativeElement as HTMLElement;
+  const attr = (selector: string, name: string) => 
host().querySelector(selector)!.getAttribute(name);
+  const tabbable = () =>
+    Array.from(host().querySelectorAll("input, button, a, [tabindex]")).filter(
+      el => el.getAttribute("tabindex") !== "-1"
+    ).length;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({ imports: [HostComponent] 
}).compileComponents();
+    fixture = TestBed.createComponent(HostComponent);
+  });
+
+  it("takes every focusable control in a read-only card out of the tab order", 
() => {
+    fixture.detectChanges();
+
+    expect(tabbable()).toBe(0);
+    expect(attr(".bound", "tabindex")).toBe("-1");
+    expect(attr(".browse", "tabindex")).toBe("-1");
+    expect(attr(".help", "tabindex")).toBe("-1");
+  });
+
+  it("says why, so assistive tech announces the controls as disabled rather 
than hiding them", () => {
+    fixture.detectChanges();
+
+    expect(attr(".browse", "aria-disabled")).toBe("true");
+    // The card is not inert: its content stays in the accessibility tree for 
a reader to read.
+    expect(host().querySelector("section")!.hasAttribute("inert")).toBe(false);
+    expect(host().querySelector(".label")!.textContent).toContain("Model");
+  });
+
+  it("leaves a card alone while it is editable", () => {
+    fixture.componentInstance.locked = false;
+    fixture.detectChanges();
+
+    expect(tabbable()).toBe(4);
+    expect(attr(".bound", "tabindex")).toBeNull();
+    expect(attr(".custom", "tabindex")).toBe("3");
+  });
+
+  it("gives a control its own tabindex back when the card becomes editable", 
() => {
+    fixture.detectChanges();
+    expect(attr(".custom", "tabindex")).toBe("-1");
+
+    fixture.componentInstance.locked = false;
+    fixture.detectChanges();
+
+    expect(attr(".custom", "tabindex")).toBe("3");
+    expect(attr(".bound", "tabindex")).toBeNull();
+    expect(attr(".browse", "aria-disabled")).toBeNull();
+  });
+
+  // A widget renders its own controls after the card is built, and redraws 
them as it goes.
+  it("locks a control a widget adds after the card was built", async () => {
+    fixture.detectChanges();
+
+    fixture.componentInstance.widgetRendered = true;
+    fixture.detectChanges();
+    // The observer runs as a microtask of its own, after the DOM change.
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    expect(attr(".added", "tabindex")).toBe("-1");
+    expect(attr(".added", "aria-disabled")).toBe("true");
+    expect(tabbable()).toBe(0);
+  });
+
+  // ng-zorro and friends write their own tabindex when they enable a control; 
on a read-only card
+  // that would put it back in the tab order behind this directive.
+  it("locks a control again when its widget writes its tabindex back", async 
() => {
+    fixture.detectChanges();
+    const browse = host().querySelector(".browse") as HTMLElement;
+    expect(browse.getAttribute("tabindex")).toBe("-1");
+
+    browse.setAttribute("tabindex", "0");
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    expect(browse.getAttribute("tabindex")).toBe("-1");
+    expect(tabbable()).toBe(0);
+  });
+
+  // Out of the tab order is not out of reach: assistive tech can focus and 
activate a control that
+  // is merely not in the tab order, and for the HuggingFace audio input that 
posts the file to the
+  // server. The browser itself refuses both once the control is disabled 
(Copilot).
+  it("refuses activation on a reader's controls, not only sequential 
navigation", () => {
+    fixture.detectChanges();
+    const upload = host().querySelector(".bound") as HTMLInputElement;
+    const browse = host().querySelector(".browse") as HTMLButtonElement;
+    const activated = vi.fn();
+    browse.addEventListener("click", activated);
+
+    browse.click();
+
+    // Disabled is the mechanism: the browser refuses both the focus and the 
activation, and a
+    // click on one dispatches no event. (jsdom models the second, not the 
first, so the first is
+    // asserted through the property the browser acts on.)
+    expect(upload.disabled).toBe(true);
+    expect(browse.disabled).toBe(true);
+    expect(activated).not.toHaveBeenCalled();
+    // The link is not a form control, so it keeps the tab-order lock and 
nothing else.
+    expect(attr(".help", "tabindex")).toBe("-1");
+  });
+
+  it("gives a control back the disabled state it had when the card becomes 
editable", () => {
+    fixture.detectChanges();
+    expect((host().querySelector(".browse") as 
HTMLButtonElement).disabled).toBe(true);
+
+    fixture.componentInstance.locked = false;
+    fixture.detectChanges();
+
+    expect((host().querySelector(".browse") as 
HTMLButtonElement).disabled).toBe(false);
+    expect((host().querySelector(".bound") as 
HTMLInputElement).disabled).toBe(false);
+  });
+
+  it("locks a control again when its widget enables it", async () => {
+    fixture.detectChanges();
+    const browse = host().querySelector(".browse") as HTMLButtonElement;
+
+    browse.disabled = false;
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    expect(browse.disabled).toBe(true);
+  });
+
+  // A widget redraws its own controls while the card lives on, and a set that 
only grew kept every
+  // detached node alive for as long as the reader stayed on the page 
(Copilot).
+  it("lets go of controls that have left the card, rather than holding them 
forever", async () => {
+    fixture.detectChanges();
+    const directive = 
fixture.debugElement.children[0].injector.get(ReadOnlyCardDirective) as any;
+    const tracked = () => (directive.lockedElements as Set<Element>).size;
+    const start = tracked();
+
+    for (let i = 0; i < 5; i++) {
+      fixture.componentInstance.widgetRendered = true;
+      fixture.detectChanges();
+      await new Promise(resolve => setTimeout(resolve, 0));
+      fixture.componentInstance.widgetRendered = false;
+      fixture.detectChanges();
+      await new Promise(resolve => setTimeout(resolve, 0));
+    }
+
+    expect(tracked()).toBe(start);
+  });
+
+  it("stops watching when the card goes away", () => {
+    fixture.detectChanges();
+    const directive =
+      (fixture.componentInstance as any) &&
+      (fixture.debugElement.children[0].injector.get(ReadOnlyCardDirective) as 
any);
+    const observer = directive.observer as MutationObserver;
+    const disconnect = vi.spyOn(observer, "disconnect");
+
+    fixture.destroy();
+
+    expect(disconnect).toHaveBeenCalled();
+  });
+});
diff --git 
a/frontend/src/app/workspace/component/workflow-form/read-only-card.directive.ts
 
b/frontend/src/app/workspace/component/workflow-form/read-only-card.directive.ts
new file mode 100644
index 0000000000..f38a7b7f8d
--- /dev/null
+++ 
b/frontend/src/app/workspace/component/workflow-form/read-only-card.directive.ts
@@ -0,0 +1,190 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { AfterViewInit, Directive, ElementRef, Input, NgZone, OnChanges, 
OnDestroy } from "@angular/core";
+
+/** What the browser lets a user reach with Tab, before this directive takes 
it out of the order. */
+const FOCUSABLE =
+  "a[href], area[href], button, input, select, textarea, iframe, object, 
embed, summary, " +
+  "audio[controls], video[controls], 
[contenteditable]:not([contenteditable='false']), [tabindex]";
+
+/** Where an element's own tabindex is kept while it is locked, so unlocking 
gives it back. */
+const ORIGINAL_TABINDEX = new WeakMap<Element, string | null>();
+
+/** The same, for the controls this directive disables natively (see 
lockDescendants). */
+const ORIGINAL_DISABLED = new WeakMap<Element, boolean>();
+
+/** A form control the browser itself refuses to focus or activate once 
`disabled` is set. */
+type FormControlElement = HTMLButtonElement | HTMLInputElement | 
HTMLSelectElement | HTMLTextAreaElement;
+const NATIVELY_DISABLEABLE: ReadonlySet<string> = new Set(["BUTTON", "INPUT", 
"SELECT", "TEXTAREA"]);
+const isFormControl = (element: Element): element is FormControlElement => 
NATIVELY_DISABLEABLE.has(element.tagName);
+
+/**
+ * Keeps the keyboard out of a read-only input card, without hiding what it 
shows.
+ *
+ * A read-only card is locked with `pointer-events: none`, which is hit 
testing only: a keyboard user
+ * could Tab into the controls a custom widget draws of its own -- a file 
picker's Browse button, the
+ * HuggingFace file input -- and activate them with Enter or Space, and those 
controls do not consult
+ * the form's disabled state the way the bound formly controls do (#8943). 
This takes every focusable
+ * descendant out of the tab order and marks it `aria-disabled`, so there is 
nothing to reach and
+ * assistive tech says why, and natively disables the ones the browser will 
refuse outright --
+ * `tabindex="-1"` only ends sequential navigation and `aria-disabled` only 
announces, so without
+ * that a reader's assistive tech could still focus a widget's file input and 
activate it.
+ *
+ * `inert` would be shorter, and the step panel uses it for its read-only 
mount, but it also removes
+ * the subtree from the accessibility tree. A reader's card exists to be read 
-- the values are what
+ * a reader runs the workflow with -- so hiding it from a screen reader would 
trade this bug for a
+ * worse one. Disabled controls stay in the accessibility tree and keep being 
announced.
+ *
+ * Widgets render their controls after the card is built and may redraw them 
at any time, so the
+ * locking is re-applied whenever the card's content changes -- a control 
added, or one whose own
+ * tabindex is written back.
+ */
+@Directive({
+  selector: "[texeraReadOnlyCard]",
+})
+export class ReadOnlyCardDirective implements OnChanges, AfterViewInit, 
OnDestroy {
+  /** Whether this card is read-only; the directive does nothing while it is 
false. */
+  @Input("texeraReadOnlyCard") locked = false;
+
+  private observer?: MutationObserver;
+  /** The elements this directive took out of the tab order, so it only gives 
back what it took. */
+  private readonly lockedElements = new Set<Element>();
+
+  constructor(
+    private host: ElementRef<HTMLElement>,
+    private zone: NgZone
+  ) {}
+
+  ngOnChanges(): void {
+    this.apply();
+  }
+
+  ngAfterViewInit(): void {
+    this.apply();
+  }
+
+  ngOnDestroy(): void {
+    this.stopWatching();
+  }
+
+  private apply(): void {
+    if (this.locked) {
+      this.lockDescendants();
+      this.startWatching();
+    } else {
+      this.stopWatching();
+      this.unlockDescendants();
+    }
+  }
+
+  private lockDescendants(): void {
+    this.releaseDetached();
+    for (const element of 
Array.from(this.host.nativeElement.querySelectorAll(FOCUSABLE))) {
+      if (!this.lockedElements.has(element)) {
+        ORIGINAL_TABINDEX.set(element, element.getAttribute("tabindex"));
+        if (isFormControl(element)) {
+          ORIGINAL_DISABLED.set(element, element.disabled);
+        }
+        this.lockedElements.add(element);
+      }
+      // Set only what is not already set: this runs again for every change 
inside the card, and a
+      // write of its own would be one more change. A control already locked 
is skipped, so the
+      // attribute observer below settles after one pass; one that a widget 
put back in the tab
+      // order, or enabled again, is locked again.
+      if (element.getAttribute("tabindex") !== "-1") {
+        element.setAttribute("tabindex", "-1");
+      }
+      if (element.getAttribute("aria-disabled") !== "true") {
+        element.setAttribute("aria-disabled", "true");
+      }
+      // Out of the tab order is not out of reach: `tabindex="-1"` only drops 
sequential navigation,
+      // and `aria-disabled` only announces, so assistive tech could still put 
the focus on a
+      // widget's own file input and activate it -- which for the HuggingFace 
audio input posts the
+      // file to the server (Copilot). A natively disabled control takes no 
focus and fires no
+      // activation, and unlike `inert` it stays in the accessibility tree to 
be read.
+      if (isFormControl(element) && !element.disabled) {
+        element.disabled = true;
+      }
+    }
+  }
+
+  /**
+   * Let go of what has left the card. A widget redraws its own controls while 
the card lives on
+   * (the page keeps a card across rebuilds, by binding id), and a set that 
only ever grew held
+   * every detached node for as long as the reader stayed on the page 
(Copilot). Released rather
+   * than merely dropped, so a control that comes back is remembered from how 
it is then, not from
+   * the locked state this directive left on it.
+   */
+  private releaseDetached(): void {
+    for (const element of Array.from(this.lockedElements)) {
+      if (!this.host.nativeElement.contains(element)) {
+        this.release(element);
+      }
+    }
+  }
+
+  private unlockDescendants(): void {
+    for (const element of Array.from(this.lockedElements)) {
+      this.release(element);
+    }
+  }
+
+  /** Give one element back what it had, and stop tracking it. */
+  private release(element: Element): void {
+    const original = ORIGINAL_TABINDEX.get(element) ?? null;
+    if (original === null) {
+      element.removeAttribute("tabindex");
+    } else {
+      element.setAttribute("tabindex", original);
+    }
+    element.removeAttribute("aria-disabled");
+    if (isFormControl(element)) {
+      element.disabled = ORIGINAL_DISABLED.get(element) ?? false;
+    }
+    this.lockedElements.delete(element);
+  }
+
+  /**
+   * Re-lock whatever a widget adds later. Outside Angular: a widget's own 
redraw is not this card's
+   * change detection to run, and the observer only sets attributes on 
elements it has just seen.
+   */
+  private startWatching(): void {
+    if (this.observer) {
+      return;
+    }
+    this.zone.runOutsideAngular(() => {
+      this.observer = new MutationObserver(() => this.lockDescendants());
+      // Attributes as well as children: a widget that enables a control of 
its own writes its
+      // tabindex back, or re-enables it when its own guard clears (the audio 
uploader's
+      // `[disabled]="isUploading"`), which would otherwise undo this lock 
behind the directive.
+      this.observer.observe(this.host.nativeElement, {
+        childList: true,
+        subtree: true,
+        attributes: true,
+        attributeFilter: ["tabindex", "disabled"],
+      });
+    });
+  }
+
+  private stopWatching(): void {
+    this.observer?.disconnect();
+    this.observer = undefined;
+  }
+}
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
index b9058303cf..e80ade70ba 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
@@ -230,6 +230,7 @@
       <section
         class="card param"
         [class.read-only]="!canEdit"
+        [texeraReadOnlyCard]="!canEdit"
         *ngFor="let r of rendered; let i = index; trackBy: trackByRendered"
         cdkDrag
         [cdkDragDisabled]="!authoring">
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
index 39baf058f6..610488e05a 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -68,6 +68,7 @@ import { VisualizationFrameContentComponent } from 
"../visualization-panel-conte
 import { WorkflowEditorComponent } from 
"../workflow-editor/workflow-editor.component";
 import { MiniMapComponent } from 
"../workflow-editor/mini-map/mini-map.component";
 import { CoeditorUserIconComponent } from 
"../menu/coeditor-user-icon/coeditor-user-icon.component";
+import { ReadOnlyCardDirective } from "./read-only-card.directive";
 import { CoeditorPresenceService } from 
"../../service/workflow-graph/model/coeditor-presence.service";
 import { SAVE_DEBOUNCE_TIME_IN_MS } from "../workspace.component";
 
@@ -167,6 +168,7 @@ interface ResultChoice {
     WorkflowEditorComponent,
     MiniMapComponent,
     CoeditorUserIconComponent,
+    ReadOnlyCardDirective,
   ],
 })
 export class WorkflowFormComponent implements OnInit, OnDestroy {
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
index 100205864a..07b0048102 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
@@ -408,6 +408,32 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
     expect(el(".param.read-only")).not.toBeNull();
   });
 
+  // Pointer blocking is hit testing only; the keyboard half is the directive 
on the same card. Here
+  // it is wired through the real template, with a control of the kind a 
widget draws of its own.
+  it("keeps a read-only viewer's card out of the tab order, without hiding 
what it shows", () => {
+    fixture.detectChanges();
+    finishLoad();
+    const c = fixture.componentInstance;
+    c.canEdit = false;
+    (c as any).parameters = [{ binding: { id: "b1" } }];
+    c.rendered = [
+      { resolved: { binding: { id: "b1" } }, fields: [{ key: "b1" }], form: 
new FormGroup({}), model: {} },
+    ] as any;
+    fixture.detectChanges();
+
+    const card = el(".param.read-only") as HTMLElement;
+    const widgetButton = document.createElement("button");
+    widgetButton.className = "browse";
+    card.querySelector(".param-form")!.appendChild(widgetButton);
+    // The directive watches the card for what a widget adds; MutationObserver 
delivers on a microtask.
+    return Promise.resolve().then(() => {
+      
expect(card.querySelector(".browse")!.getAttribute("tabindex")).toBe("-1");
+      
expect(card.querySelector(".browse")!.getAttribute("aria-disabled")).toBe("true");
+      // Not inert: a reader still has the card's content in the accessibility 
tree.
+      expect(card.hasAttribute("inert")).toBe(false);
+    });
+  });
+
   it("renders the author's instruction card and toggles it", async () => {
     fixture.detectChanges();
     finishLoad();

Reply via email to