mnfst 0.5.123 → 0.5.125

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.
@@ -74,62 +74,79 @@ function initializeTabsPlugin() {
74
74
  }
75
75
  }
76
76
 
77
- // Ensure x-data exists
78
- if (!commonParent.hasAttribute('x-data')) {
79
- commonParent.setAttribute('x-data', '{}');
80
- }
77
+ // Default tab value (first panel's id)
78
+ const defaultValue = panels.length > 0 ? panels[0].id : 'a';
79
+
80
+ // When Alpine has already initialized the scope (content injected at
81
+ // runtime, e.g. by the markdown plugin), x-data attribute edits are
82
+ // never re-read — write the property into the live scope instead.
83
+ const scopeHost = commonParent.hasAttribute('x-data') ? commonParent : commonParent.closest('[x-data]');
84
+ const liveData = scopeHost && scopeHost._x_dataStack && window.Alpine && typeof window.Alpine.$data === 'function'
85
+ ? window.Alpine.$data(scopeHost)
86
+ : null;
87
+
88
+ if (liveData) {
89
+ if (!(tabProp in liveData)) liveData[tabProp] = defaultValue;
90
+ } else {
91
+ // Ensure x-data exists
92
+ if (!commonParent.hasAttribute('x-data')) {
93
+ commonParent.setAttribute('x-data', '{}');
94
+ }
81
95
 
82
- // Set up x-data with default value
83
- const existingXData = commonParent.getAttribute('x-data') || '{}';
84
- let newXData = existingXData;
85
-
86
- // Check if the tab property already exists
87
- // Escape special regex characters in tabProp
88
- const escapedTabProp = tabProp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
89
- const propertyRegex = new RegExp(`${escapedTabProp}\\s*:\\s*'[^']*'`, 'g');
90
- if (!propertyRegex.test(newXData)) {
91
- // Add the tab property with default value (first panel's id)
92
- const defaultValue = panels.length > 0 ? panels[0].id : 'a';
93
- const tabProperty = `${tabProp}: '${defaultValue}'`;
94
-
95
- if (newXData === '{}') {
96
- newXData = `{ ${tabProperty} }`;
97
- } else {
98
- const lastBraceIndex = newXData.lastIndexOf('}');
99
- if (lastBraceIndex > 0) {
100
- const beforeBrace = newXData.substring(0, lastBraceIndex);
101
- const afterBrace = newXData.substring(lastBraceIndex);
102
- const separator = beforeBrace.trim().endsWith(',') ? '' : ', ';
103
- newXData = beforeBrace + separator + tabProperty + afterBrace;
96
+ // Set up x-data with default value
97
+ const existingXData = commonParent.getAttribute('x-data') || '{}';
98
+ let newXData = existingXData;
99
+
100
+ // Check if the tab property already exists
101
+ // Escape special regex characters in tabProp
102
+ const escapedTabProp = tabProp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
103
+ const propertyRegex = new RegExp(`${escapedTabProp}\\s*:\\s*'[^']*'`, 'g');
104
+ if (!propertyRegex.test(newXData)) {
105
+ const tabProperty = `${tabProp}: '${defaultValue}'`;
106
+
107
+ if (newXData === '{}') {
108
+ newXData = `{ ${tabProperty} }`;
109
+ } else {
110
+ const lastBraceIndex = newXData.lastIndexOf('}');
111
+ if (lastBraceIndex > 0) {
112
+ const beforeBrace = newXData.substring(0, lastBraceIndex);
113
+ const afterBrace = newXData.substring(lastBraceIndex);
114
+ const separator = beforeBrace.trim().endsWith(',') ? '' : ', ';
115
+ newXData = beforeBrace + separator + tabProperty + afterBrace;
116
+ }
104
117
  }
105
- }
106
118
 
107
- if (newXData !== existingXData) {
108
- commonParent.setAttribute('x-data', newXData);
119
+ if (newXData !== existingXData) {
120
+ commonParent.setAttribute('x-data', newXData);
121
+ }
109
122
  }
110
123
  }
111
124
 
112
125
  // ----- Accessibility wiring (WAI-ARIA Tabs pattern) -----
113
126
  //
114
- // Per the ARIA APG, a tabs widget needs:
115
- // - role="tablist" on the tab container (parent of buttons)
116
- // - role="tab" + aria-selected + aria-controls + tabindex on each button
117
- // - role="tabpanel" + aria-labelledby + tabindex="0" on each panel
118
- // - arrow-key navigation between tabs (with roving tabindex)
119
- //
120
- // We compute the tab-container element as the closest common ancestor of
121
- // the relevant buttons (often a <nav> or <div>). Each button is assigned
122
- // a stable id if it doesn't have one, so panels can reference it.
127
+ // The full APG tabs pattern (role="tab"/"tabpanel", aria-controls,
128
+ // roving tabindex, arrow-key navigation) is applied only when the
129
+ // author wraps the buttons in an element with role="tablist"
130
+ // those roles are only valid inside one, and inferring a container
131
+ // risks claiming the wrong element. Without a tablist, buttons stay
132
+ // plain buttons and panels plain regions; :aria-selected still
133
+ // syncs as the selected-state hook.
134
+ const tablists = new Set();
135
+ relevantButtons.forEach((b) => {
136
+ const t = b.closest('[role=tablist]');
137
+ if (t) tablists.add(t);
138
+ });
139
+ const wireAria = tablists.size > 0;
123
140
 
124
- // Assign ids where missing.
141
+ // Assign ids where missing (needed for aria-labelledby/controls).
125
142
  const buttonIdByTabValue = {};
126
143
  relevantButtons.forEach((button, i) => {
127
144
  const tabValue = button.getAttribute('x-tab');
128
145
  if (!tabValue) return;
129
- if (!button.id) {
146
+ if (!button.id && wireAria) {
130
147
  button.id = `mnfst-tab-${sanitizedPanelSet || 'g'}-${tabValue.replace(/[^a-zA-Z0-9_-]/g, '-')}-${i}`;
131
148
  }
132
- buttonIdByTabValue[tabValue] = button.id;
149
+ if (button.id) buttonIdByTabValue[tabValue] = button.id;
133
150
  });
134
151
 
135
152
  // Process panels for this group - add x-show + a11y attributes
@@ -142,12 +159,14 @@ function initializeTabsPlugin() {
142
159
  if (!panel.element.id) panel.element.id = panel.id;
143
160
 
144
161
  // ARIA: role + label + focusable
145
- panel.element.setAttribute('role', 'tabpanel');
146
- if (!panel.element.hasAttribute('tabindex')) {
147
- panel.element.setAttribute('tabindex', '0');
162
+ if (wireAria) {
163
+ panel.element.setAttribute('role', 'tabpanel');
164
+ if (!panel.element.hasAttribute('tabindex')) {
165
+ panel.element.setAttribute('tabindex', '0');
166
+ }
167
+ const labelledBy = buttonIdByTabValue[panel.id];
168
+ if (labelledBy) panel.element.setAttribute('aria-labelledby', labelledBy);
148
169
  }
149
- const labelledBy = buttonIdByTabValue[panel.id];
150
- if (labelledBy) panel.element.setAttribute('aria-labelledby', labelledBy);
151
170
 
152
171
  // Remove x-tabpanel attribute since we've converted it
153
172
  panel.element.removeAttribute('x-tabpanel');
@@ -163,56 +182,50 @@ function initializeTabsPlugin() {
163
182
  const clickHandler = `${tabProp} = '${tabValue}'`;
164
183
  button.setAttribute('x-on:click', clickHandler);
165
184
 
166
- // ARIA: role, selection state (reactive via :aria-selected), controls
167
- button.setAttribute('role', 'tab');
185
+ // Selection state (reactive via :aria-selected) — always synced;
186
+ // it doubles as the documented selected-state styling hook
168
187
  button.setAttribute(':aria-selected', `String(${tabProp} === '${tabValue}')`);
169
- // Roving tabindex: -1 when not active so arrow keys, not Tab, move between tabs.
170
- button.setAttribute(':tabindex', `${tabProp} === '${tabValue}' ? '0' : '-1'`);
171
- const panel = panels.find((p) => p.id === tabValue);
172
- if (panel && panel.element.id) {
173
- button.setAttribute('aria-controls', panel.element.id);
188
+
189
+ // ARIA: role, roving tabindex, controls only inside an author tablist
190
+ if (button.closest('[role=tablist]')) {
191
+ button.setAttribute('role', 'tab');
192
+ // Roving tabindex: -1 when not active so arrow keys, not Tab, move between tabs.
193
+ button.setAttribute(':tabindex', `${tabProp} === '${tabValue}' ? '0' : '-1'`);
194
+ const panel = panels.find((p) => p.id === tabValue);
195
+ if (panel && panel.element.id) {
196
+ button.setAttribute('aria-controls', panel.element.id);
197
+ }
174
198
  }
175
199
 
176
200
  // Remove x-tab attribute since we've converted it
177
201
  button.removeAttribute('x-tab');
178
202
  });
179
203
 
180
- // Find the tablist container closest common ancestor of all relevant
181
- // buttons. If they share a direct parent that's the tablist; otherwise
182
- // walk up until one wraps them all. Set role="tablist" + a keydown
183
- // handler that walks the focusable tabs on Left/Right/Home/End.
184
- if (relevantButtons.length > 0) {
185
- let tablistEl = relevantButtons[0].parentElement;
186
- while (tablistEl && tablistEl !== document.body) {
187
- if (relevantButtons.every((b) => tablistEl.contains(b))) break;
188
- tablistEl = tablistEl.parentElement;
204
+ // Arrow-key navigation (Left/Right/Home/End) on each author tablist.
205
+ tablists.forEach((tablistEl) => {
206
+ if (!tablistEl.__mnfstTabsKeydown) {
207
+ tablistEl.__mnfstTabsKeydown = (e) => {
208
+ const target = e.target;
209
+ if (!target || target.getAttribute('role') !== 'tab') return;
210
+ const tabs = Array.from(tablistEl.querySelectorAll('[role="tab"]'));
211
+ const idx = tabs.indexOf(target);
212
+ if (idx === -1) return;
213
+ let nextIdx = null;
214
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') nextIdx = (idx + 1) % tabs.length;
215
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') nextIdx = (idx - 1 + tabs.length) % tabs.length;
216
+ else if (e.key === 'Home') nextIdx = 0;
217
+ else if (e.key === 'End') nextIdx = tabs.length - 1;
218
+ if (nextIdx == null) return;
219
+ e.preventDefault();
220
+ // Automatic activation: focusing a tab selects it. This matches
221
+ // the most common APG variant and Manifest's existing click-to-
222
+ // activate semantics.
223
+ tabs[nextIdx].focus();
224
+ tabs[nextIdx].click();
225
+ };
226
+ tablistEl.addEventListener('keydown', tablistEl.__mnfstTabsKeydown);
189
227
  }
190
- if (tablistEl) {
191
- tablistEl.setAttribute('role', 'tablist');
192
- if (!tablistEl.__mnfstTabsKeydown) {
193
- tablistEl.__mnfstTabsKeydown = (e) => {
194
- const target = e.target;
195
- if (!target || target.getAttribute('role') !== 'tab') return;
196
- const tabs = Array.from(tablistEl.querySelectorAll('[role="tab"]'));
197
- const idx = tabs.indexOf(target);
198
- if (idx === -1) return;
199
- let nextIdx = null;
200
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') nextIdx = (idx + 1) % tabs.length;
201
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') nextIdx = (idx - 1 + tabs.length) % tabs.length;
202
- else if (e.key === 'Home') nextIdx = 0;
203
- else if (e.key === 'End') nextIdx = tabs.length - 1;
204
- if (nextIdx == null) return;
205
- e.preventDefault();
206
- // Automatic activation: focusing a tab selects it. This matches
207
- // the most common APG variant and Manifest's existing click-to-
208
- // activate semantics.
209
- tabs[nextIdx].focus();
210
- tabs[nextIdx].click();
211
- };
212
- tablistEl.addEventListener('keydown', tablistEl.__mnfstTabsKeydown);
213
- }
214
- }
215
- }
228
+ })
216
229
 
217
230
  // Ensure Alpine processes the updated x-data and x-show attributes
218
231
  if (window.Alpine && typeof window.Alpine.initTree === 'function') {
@@ -42,6 +42,7 @@ function initializeTooltipPlugin() {
42
42
  const TOOLTIP_CHAIN_GRACE_MS = 250;
43
43
  let _lastTooltipHideTime = 0;
44
44
  const markTooltipHidden = () => { _lastTooltipHideTime = Date.now(); };
45
+ const clearChainWindow = () => { _lastTooltipHideTime = 0; };
45
46
  const isInChainWindow = () => (Date.now() - _lastTooltipHideTime) < TOOLTIP_CHAIN_GRACE_MS;
46
47
 
47
48
  // ---- Singletons per host ----
@@ -148,7 +149,10 @@ function initializeTooltipPlugin() {
148
149
  trigger._tooltipAnchorName = `--tooltip-trigger-${code}`;
149
150
  }
150
151
  const anchorName = trigger._tooltipAnchorName;
151
- trigger.style.setProperty('anchor-name', anchorName);
152
+ // Compose with --co-anchor so stylesheet anchors (e.g. the tablist
153
+ // slider's --selected-tab on the selected tab) survive this inline
154
+ // override — the var re-resolves reactively as selection changes.
155
+ trigger.style.setProperty('anchor-name', `${anchorName}, var(--co-anchor, --no-anchor)`);
152
156
  void trigger.offsetHeight; // reflow so anchor-name registers
153
157
  s.el.style.setProperty('position-anchor', anchorName);
154
158
 
@@ -173,7 +177,9 @@ function initializeTooltipPlugin() {
173
177
 
174
178
  // Hide the singleton that's currently showing (if any), regardless of host.
175
179
  function hideAnySingleton() {
180
+ let wasOpen = false;
176
181
  document.querySelectorAll('.tooltip[popover="hint"]:popover-open').forEach(el => {
182
+ wasOpen = true;
177
183
  try { el.hidePopover(); } catch {}
178
184
  });
179
185
  // Restore each tooltip's prior aria-describedby on the trigger it had been
@@ -186,7 +192,9 @@ function initializeTooltipPlugin() {
186
192
  else el.removeAttribute('aria-describedby');
187
193
  el._tooltipPriorDescribedBy = undefined;
188
194
  });
189
- markTooltipHidden();
195
+ // Only arm the chain window when something was actually open — marking
196
+ // unconditionally let a plain click fast-track the next focus show.
197
+ if (wasOpen) markTooltipHidden();
190
198
  }
191
199
 
192
200
  // ---- Directive ----
@@ -283,7 +291,10 @@ function initializeTooltipPlugin() {
283
291
 
284
292
  // Keyboard / focus interactions — WCAG 2.1 SC 1.4.13 requires tooltip
285
293
  // content to be accessible to keyboard users via focus, not hover only.
286
- el.addEventListener('focus', requestShow);
294
+ // Gated on :focus-visible so mouse-click focus doesn't flash the tooltip.
295
+ el.addEventListener('focus', () => {
296
+ if (el.matches(':focus-visible')) requestShow();
297
+ });
287
298
  el.addEventListener('blur', requestHide);
288
299
 
289
300
  // Mousedown/click: always hide immediately; scheduleAnchorRestore so the
@@ -292,6 +303,10 @@ function initializeTooltipPlugin() {
292
303
  const hideAndScheduleRestore = () => {
293
304
  cancelPendingShow();
294
305
  hideAnySingleton();
306
+ // A click is a deliberate dismissal — clear the chain window so a
307
+ // synthetic re-hover (e.g. content shifting under the cursor after
308
+ // navigation) waits the full delay instead of showing instantly.
309
+ clearChainWindow();
295
310
  scheduleAnchorRestore(el);
296
311
  };
297
312
  el.addEventListener('mousedown', hideAndScheduleRestore);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst",
3
- "version": "0.5.123",
3
+ "version": "0.5.125",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",