@pie-players/pie-tool-answer-eliminator 0.3.59 → 0.3.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,11 +30,24 @@ export interface ChoiceAdapter {
30
30
  * Get human-readable label (for screen readers)
31
31
  */
32
32
  getChoiceLabel(choice: HTMLElement): string;
33
+ /**
34
+ * Check if the choice is currently selected by the student
35
+ * (radio/checkbox checked). Optional: adapters that support hiding the
36
+ * eliminate control on a selected choice implement this.
37
+ */
38
+ isSelected?(choice: HTMLElement): boolean;
33
39
  /**
34
40
  * Check if choice can be eliminated
35
41
  * (not already selected, not in evaluate mode, etc.)
36
42
  */
37
43
  canEliminate(choice: HTMLElement): boolean;
44
+ /**
45
+ * Enable or disable the student's ability to select this choice.
46
+ * A struck-through choice is made non-selectable (grayed out) so a
47
+ * student cannot pick an answer they have eliminated. Optional: only
48
+ * adapters backed by a real input (radio/checkbox) implement this.
49
+ */
50
+ setSelectable?(choice: HTMLElement, selectable: boolean): void;
38
51
  /**
39
52
  * Get the container element to attach elimination button
40
53
  */
@@ -14,6 +14,8 @@ export declare class EBSRAdapter implements ChoiceAdapter {
14
14
  createChoiceRange(choice: HTMLElement): Range | null;
15
15
  getChoiceId(choice: HTMLElement): string;
16
16
  getChoiceLabel(choice: HTMLElement): string;
17
+ isSelected(choice: HTMLElement): boolean;
17
18
  canEliminate(choice: HTMLElement): boolean;
19
+ setSelectable(choice: HTMLElement, selectable: boolean): void;
18
20
  getButtonContainer(choice: HTMLElement): HTMLElement | null;
19
21
  }
@@ -59,9 +59,15 @@ export class EBSRAdapter {
59
59
  getChoiceLabel(choice) {
60
60
  return this.mcAdapter.getChoiceLabel(choice);
61
61
  }
62
+ isSelected(choice) {
63
+ return this.mcAdapter.isSelected(choice);
64
+ }
62
65
  canEliminate(choice) {
63
66
  return this.mcAdapter.canEliminate(choice);
64
67
  }
68
+ setSelectable(choice, selectable) {
69
+ this.mcAdapter.setSelectable(choice, selectable);
70
+ }
65
71
  getButtonContainer(choice) {
66
72
  return this.mcAdapter.getButtonContainer(choice);
67
73
  }
@@ -13,12 +13,16 @@ export declare class MultipleChoiceAdapter implements ChoiceAdapter {
13
13
  private static readonly ROOT_HOOK_ATTR;
14
14
  private static readonly FEEDBACK_HOOK_ATTR;
15
15
  private static readonly CHOICE_SELECTOR;
16
+ private static readonly MUI_BUTTON_SELECTOR;
17
+ private static readonly MUI_DISABLED_CLASS;
16
18
  canHandle(element: HTMLElement): boolean;
17
19
  findChoices(root: HTMLElement): HTMLElement[];
18
20
  createChoiceRange(choice: HTMLElement): Range | null;
19
21
  getChoiceId(choice: HTMLElement): string;
20
22
  getChoiceLabel(choice: HTMLElement): string;
23
+ isSelected(choice: HTMLElement): boolean;
21
24
  canEliminate(choice: HTMLElement): boolean;
25
+ setSelectable(choice: HTMLElement, selectable: boolean): void;
22
26
  getButtonContainer(choice: HTMLElement): HTMLElement | null;
23
27
  private generateFallbackId;
24
28
  private annotateChoice;
@@ -12,6 +12,11 @@ export class MultipleChoiceAdapter {
12
12
  static ROOT_HOOK_ATTR = "data-pie-answer-eliminator-root";
13
13
  static FEEDBACK_HOOK_ATTR = "data-pie-answer-eliminator-feedback-tick";
14
14
  static CHOICE_SELECTOR = `[${MultipleChoiceAdapter.CHOICE_HOOK_ATTR}="true"], .corespring-checkbox, .corespring-radio-button`;
15
+ // Material-UI radio/checkbox controls gray out via the `Mui-disabled`
16
+ // class on the button root; toggling the native `disabled` alone doesn't
17
+ // restyle them.
18
+ static MUI_BUTTON_SELECTOR = ".MuiButtonBase-root, .MuiRadio-root, .MuiCheckbox-root";
19
+ static MUI_DISABLED_CLASS = "Mui-disabled";
15
20
  canHandle(element) {
16
21
  return (element.tagName.toLowerCase() === "multiple-choice" ||
17
22
  element.classList.contains("multiple-choice"));
@@ -47,13 +52,21 @@ export class MultipleChoiceAdapter {
47
52
  const label = this.resolveLabelElement(choice);
48
53
  return label?.textContent?.trim() || "Unlabeled choice";
49
54
  }
55
+ isSelected(choice) {
56
+ const input = choice.querySelector('input[type="checkbox"], input[type="radio"]');
57
+ if (!input)
58
+ return false;
59
+ // Prefer the live `checked` property (works for radio and checkbox);
60
+ // fall back to the aria-checked attribute for custom/ARIA widgets.
61
+ return input.checked || input.getAttribute("aria-checked") === "true";
62
+ }
50
63
  canEliminate(choice) {
51
64
  const input = choice.querySelector('input[type="checkbox"], input[type="radio"]');
52
65
  if (!input)
53
66
  return false;
54
67
  // Can't eliminate if:
55
68
  // 1. Already selected (checked)
56
- if (input.getAttribute("aria-checked") === "true")
69
+ if (this.isSelected(choice))
57
70
  return false;
58
71
  // 2. Disabled
59
72
  if (input.disabled)
@@ -65,6 +78,26 @@ export class MultipleChoiceAdapter {
65
78
  return false;
66
79
  return true;
67
80
  }
81
+ setSelectable(choice, selectable) {
82
+ const input = choice.querySelector('input[type="checkbox"], input[type="radio"]');
83
+ if (!input)
84
+ return;
85
+ // Native `disabled` makes the control non-selectable and grays it out;
86
+ // aria-disabled keeps the state exposed to assistive tech.
87
+ input.disabled = !selectable;
88
+ if (selectable) {
89
+ input.removeAttribute("aria-disabled");
90
+ }
91
+ else {
92
+ input.setAttribute("aria-disabled", "true");
93
+ }
94
+ // If the input lives inside a Material-UI control, mirror MUI's own
95
+ // disabled styling by toggling `Mui-disabled` on the button root.
96
+ const muiButton = input.closest(MultipleChoiceAdapter.MUI_BUTTON_SELECTOR);
97
+ if (muiButton) {
98
+ muiButton.classList.toggle(MultipleChoiceAdapter.MUI_DISABLED_CLASS, !selectable);
99
+ }
100
+ }
68
101
  getButtonContainer(choice) {
69
102
  // Return the choice-input container
70
103
  return choice;
@@ -14,6 +14,11 @@ export declare class AnswerEliminatorCore {
14
14
  private choiceAdapters;
15
15
  private buttonAlignment;
16
16
  private shouldRestoreState;
17
+ private active;
18
+ private questionRoot;
19
+ private selectionChangeHandler;
20
+ private selectionObserver;
21
+ private selectionRefreshFrame;
17
22
  private storeIntegration;
18
23
  constructor(strategyType?: "strikethrough" | "mask" | "gray", buttonAlignment?: "left" | "right" | "inline");
19
24
  private createStrategy;
@@ -63,6 +68,56 @@ export declare class AnswerEliminatorCore {
63
68
  * Restore state from ElementToolStateStore
64
69
  */
65
70
  private restoreState;
71
+ /**
72
+ * Re-enable selection for every currently-tracked struck choice.
73
+ * Must run before the element/adapter maps are cleared, otherwise the
74
+ * inputs would be left disabled after the tool is turned off.
75
+ */
76
+ private restoreAllSelectable;
77
+ /**
78
+ * Show or hide a choice's strikethrough button, preserving the visible
79
+ * `display` value chosen by the alignment configuration.
80
+ */
81
+ private setButtonHidden;
82
+ /**
83
+ * The single rule for whether a choice's eliminate button should be hidden:
84
+ * - A struck-through choice always keeps its button (so it can be undone),
85
+ * even when the feature is toggled off.
86
+ * - When the feature is off, a non-struck choice hides its button.
87
+ * - When the feature is on, a non-struck choice hides its button only while
88
+ * it is selected (a selected answer must not be eliminable).
89
+ */
90
+ private shouldHideButton;
91
+ /**
92
+ * Re-apply the visibility rule to a single choice's button.
93
+ */
94
+ private updateButtonVisibility;
95
+ /**
96
+ * Re-evaluate button visibility for every tracked choice. Driven by live
97
+ * selection changes and by toggling the feature on/off.
98
+ */
99
+ private refreshSelectionState;
100
+ /**
101
+ * Schedule a selection refresh on the next frame. Deliberately does NOT
102
+ * cancel-and-reschedule: a burst of mutations coalesces into one pending
103
+ * refresh, and mutations in later frames each get their own refresh, so a
104
+ * multi-render commit settles instead of being starved by continuous
105
+ * churn (e.g. ripple animations).
106
+ */
107
+ private scheduleSelectionRefresh;
108
+ /**
109
+ * Track live selection changes on the question root so button visibility
110
+ * follows the selection even when no full re-render occurs.
111
+ *
112
+ * Controlled widgets (PIE multiple-choice) update the input's `checked`
113
+ * *property* on their own render — invisible to a MutationObserver and
114
+ * later than the native `change` event — so we observe every DOM change
115
+ * the widget makes (childList + attributes) and re-read on the next frame,
116
+ * by which point the property has settled. Mutations caused by our own
117
+ * buttons are ignored so the refresh can't loop.
118
+ */
119
+ private attachSelectionListener;
120
+ private detachSelectionListener;
66
121
  /**
67
122
  * Cleanup buttons from previous element
68
123
  */
@@ -80,9 +135,13 @@ export declare class AnswerEliminatorCore {
80
135
  */
81
136
  disableStateRestoration(): void;
82
137
  /**
83
- * Cleanup when tool is turned off (but don't destroy strategy)
84
- * Hides elimination buttons AND clears all visual eliminations
85
- * Note: State is preserved in localStorage for when tool is turned back on
138
+ * Turn the feature off at the question level.
139
+ *
140
+ * Struck-through choices stay struck: their strikethrough, their disabled
141
+ * (non-selectable) input, and a visible/usable eliminate button all remain
142
+ * so the student can still undo them. Only the eliminate buttons for
143
+ * choices that are NOT struck through are hidden. Nothing is destroyed, so
144
+ * toggling the feature back on simply re-reveals the hidden buttons.
86
145
  */
87
146
  cleanup(): void;
88
147
  /**