@mindees/atlas 0.30.1 → 0.30.2

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.
package/dist/a11y.d.ts CHANGED
@@ -56,7 +56,7 @@ type Announce = 'polite' | 'assertive';
56
56
  * Imperatively announce `message` to screen readers (programmatic, not tied to a rendered node) — for
57
57
  * results that aren't otherwise voiced ("3 results found", "Saved", validation errors). Writes into a
58
58
  * persistent visually-hidden `aria-live` region (one per politeness), clearing first so the SAME message
59
- * re-announces. SSR/native-safe: a no-op without a DOM.
59
+ * re-announces. Multiple calls in the same frame are queued and joined (none is lost). SSR/native-safe.
60
60
  */
61
61
  declare function announce(message: string, politeness?: Announce): void;
62
62
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"a11y.d.ts","names":[],"sources":["../src/a11y.ts"],"mappings":";;AAUA;;;;AAAgB;AAuBhB;;;KAvBY,IAAA;;UAuBK,SAAA;EACf,QAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;EACA,QAAA;EACA,IAAA;EACA,MAAA;AAAA;;UAee,SAAA;EAER;EAAP,IAAA,GAAO,IAAA;EAcoB;EAZ3B,KAAA;EAYoC;EAVpC,UAAA;EAJO;EAMP,WAAA;EAFA;EAIA,IAAA;EAAA;;;;;EAMA,KAAA,GAAQ,SAAA,UAAmB,SAAA;EAM3B;EAJA,QAAA;EAIQ;EAFR,QAAA;EAUyB;EARzB,QAAA;AAAA;;;;;AAQkD;iBAApC,WAAA,CAAY,IAAA,EAAM,SAAA,GAAY,MAAM;;KAkCxC,QAAA;;AAAQ;AAoBpB;;;;iBAAgB,QAAA,CAAS,OAAA,UAAiB,UAAA,GAAY,QAAmB"}
1
+ {"version":3,"file":"a11y.d.ts","names":[],"sources":["../src/a11y.ts"],"mappings":";;AAUA;;;;AAAgB;AAuBhB;;;KAvBY,IAAA;;UAuBK,SAAA;EACf,QAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;EACA,QAAA;EACA,IAAA;EACA,MAAA;AAAA;;UAee,SAAA;EAER;EAAP,IAAA,GAAO,IAAA;EAcoB;EAZ3B,KAAA;EAYoC;EAVpC,UAAA;EAJO;EAMP,WAAA;EAFA;EAIA,IAAA;EAAA;;;;;EAMA,KAAA,GAAQ,SAAA,UAAmB,SAAA;EAM3B;EAJA,QAAA;EAIQ;EAFR,QAAA;EAUyB;EARzB,QAAA;AAAA;;;;;AAQkD;iBAApC,WAAA,CAAY,IAAA,EAAM,SAAA,GAAY,MAAM;;KAkCxC,QAAA;;AAAQ;AAwBpB;;;;iBAAgB,QAAA,CAAS,OAAA,UAAiB,UAAA,GAAY,QAAmB"}
package/dist/a11y.js CHANGED
@@ -42,11 +42,13 @@ function toA11yProps(a11y) {
42
42
  return out;
43
43
  }
44
44
  const liveRegions = {};
45
+ const pendingMessages = {};
46
+ const flushScheduled = {};
45
47
  /**
46
48
  * Imperatively announce `message` to screen readers (programmatic, not tied to a rendered node) — for
47
49
  * results that aren't otherwise voiced ("3 results found", "Saved", validation errors). Writes into a
48
50
  * persistent visually-hidden `aria-live` region (one per politeness), clearing first so the SAME message
49
- * re-announces. SSR/native-safe: a no-op without a DOM.
51
+ * re-announces. Multiple calls in the same frame are queued and joined (none is lost). SSR/native-safe.
50
52
  */
51
53
  function announce(message, politeness = "polite") {
52
54
  const doc = globalThis.document;
@@ -62,10 +64,20 @@ function announce(message, politeness = "polite") {
62
64
  region = el;
63
65
  liveRegions[politeness] = el;
64
66
  }
67
+ let queue = pendingMessages[politeness];
68
+ if (queue === void 0) {
69
+ queue = [];
70
+ pendingMessages[politeness] = queue;
71
+ }
72
+ queue.push(message);
73
+ if (flushScheduled[politeness]) return;
74
+ flushScheduled[politeness] = true;
65
75
  region.textContent = "";
66
76
  const r = region;
67
77
  (globalThis.requestAnimationFrame ?? ((cb) => setTimeout(cb, 16)))(() => {
68
- r.textContent = message;
78
+ r.textContent = (pendingMessages[politeness] ?? []).join(" ");
79
+ pendingMessages[politeness] = [];
80
+ flushScheduled[politeness] = false;
69
81
  });
70
82
  }
71
83
  //#endregion
package/dist/a11y.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"a11y.js","names":[],"sources":["../src/a11y.ts"],"sourcesContent":["/**\n * Atlas accessibility — a single typed `A11yProps` surface lowered to `role` + `aria-*`\n * attribute props. The DOM backend passes these through verbatim (`setAttribute`), so web a11y\n * is real; native hosts receive them as serialized props (interpretation is a 🔬 research-track\n * host concern — carried, never silently dropped). See `docs/adr/0022-atlas-primitives.md`.\n *\n * @module\n */\n\n/** A WAI-ARIA-ish role, mapped straight to the host `role` attribute on web. */\nexport type Role =\n | 'button'\n | 'link'\n | 'image'\n | 'heading'\n | 'list'\n | 'listitem'\n | 'text'\n | 'textbox'\n | 'checkbox'\n | 'switch'\n | 'radio'\n | 'tab'\n | 'tabpanel'\n | 'dialog'\n | 'alert'\n | 'status'\n | 'separator'\n | 'progressbar'\n | 'none'\n | 'presentation'\n\n/** Accessibility state, lowered to the matching `aria-*` attributes. */\nexport interface A11yState {\n disabled?: boolean\n selected?: boolean\n checked?: boolean\n pressed?: boolean\n expanded?: boolean\n busy?: boolean\n hidden?: boolean\n}\n\n/** The boolean state keys, paired with their `aria-*` attribute. */\nconst STATE_ARIA = [\n ['disabled', 'aria-disabled'],\n ['selected', 'aria-selected'],\n ['checked', 'aria-checked'],\n ['pressed', 'aria-pressed'],\n ['expanded', 'aria-expanded'],\n ['busy', 'aria-busy'],\n ['hidden', 'aria-hidden'],\n] as const\n\n/** The accessibility surface every Atlas primitive accepts. */\nexport interface A11yProps {\n /** ARIA role (web `role`). */\n role?: Role\n /** Accessible name (`aria-label`). */\n label?: string\n /** Id(s) of the element(s) labelling this one (`aria-labelledby`). */\n labelledBy?: string\n /** Id(s) of the element(s) describing this one (`aria-describedby`). */\n describedBy?: string\n /** Live-region politeness (`aria-live`). */\n live?: 'off' | 'polite' | 'assertive'\n /**\n * Accessibility state → `aria-*`. Pass an **accessor** (`() => ({ checked: on() })`) to make the\n * `aria-*` attributes reactive — a static object bakes them once, so a screen reader never hears\n * a toggle change. Reactive keys are those present on the first read (stable shape).\n */\n state?: A11yState | (() => A11yState)\n /** Current value of a range widget (`aria-valuenow`); accessor → reactive. */\n valueNow?: number | (() => number)\n /** Minimum of a range widget (`aria-valuemin`). */\n valueMin?: number\n /** Maximum of a range widget (`aria-valuemax`). */\n valueMax?: number\n}\n\n/**\n * Lower {@link A11yProps} to a host prop bag of `role` + `aria-*` (only defined keys, so omitted\n * props stay omitted). Accessor-valued `state`/`valueNow` lower to **reactive** attribute bindings\n * (the renderer re-applies them via `setProp`), so accessibility tracks state changes.\n */\nexport function toA11yProps(a11y: A11yProps): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n if (a11y.role !== undefined) out.role = a11y.role\n if (a11y.label !== undefined) out['aria-label'] = a11y.label\n if (a11y.labelledBy !== undefined) out['aria-labelledby'] = a11y.labelledBy\n if (a11y.describedBy !== undefined) out['aria-describedby'] = a11y.describedBy\n if (a11y.live !== undefined) out['aria-live'] = a11y.live\n\n const s = a11y.state\n if (typeof s === 'function') {\n const initial = s()\n for (const [key, attr] of STATE_ARIA) {\n if (initial[key] === undefined) continue\n out[attr] = () => {\n const v = s()[key]\n return v === undefined ? undefined : String(v)\n }\n }\n } else if (s) {\n for (const [key, attr] of STATE_ARIA) {\n if (s[key] !== undefined) out[attr] = String(s[key])\n }\n }\n\n if (a11y.valueMin !== undefined) out['aria-valuemin'] = String(a11y.valueMin)\n if (a11y.valueMax !== undefined) out['aria-valuemax'] = String(a11y.valueMax)\n const vn = a11y.valueNow\n if (typeof vn === 'function') out['aria-valuenow'] = () => String(vn())\n else if (vn !== undefined) out['aria-valuenow'] = String(vn)\n\n return out\n}\n\n/** Politeness for {@link announce} — `'polite'` waits for a pause; `'assertive'` interrupts. */\nexport type Announce = 'polite' | 'assertive'\n\n// One persistent visually-hidden live region per politeness, reused across calls (created lazily).\nconst liveRegions: Partial<Record<Announce, { textContent: string }>> = {}\n\ninterface DocLike {\n createElement(tag: string): {\n setAttribute(name: string, value: string): void\n style: { cssText: string }\n textContent: string\n }\n body: { appendChild(node: unknown): void } | null\n}\n\n/**\n * Imperatively announce `message` to screen readers (programmatic, not tied to a rendered node) — for\n * results that aren't otherwise voiced (\"3 results found\", \"Saved\", validation errors). Writes into a\n * persistent visually-hidden `aria-live` region (one per politeness), clearing first so the SAME message\n * re-announces. SSR/native-safe: a no-op without a DOM.\n */\nexport function announce(message: string, politeness: Announce = 'polite'): void {\n const doc = (globalThis as unknown as { document?: DocLike }).document\n if (!doc || typeof doc.createElement !== 'function' || !doc.body) return\n let region = liveRegions[politeness]\n if (!region) {\n const el = doc.createElement('div')\n el.setAttribute('aria-live', politeness)\n el.setAttribute('aria-atomic', 'true')\n el.setAttribute('role', politeness === 'assertive' ? 'alert' : 'status')\n // Visually hidden but still announced (the standard sr-only clip pattern).\n el.style.cssText =\n 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0'\n doc.body.appendChild(el)\n region = el\n liveRegions[politeness] = el\n }\n // Clear then set on the next tick so a repeated identical message is still re-announced.\n region.textContent = ''\n const r = region\n const schedule =\n (globalThis as { requestAnimationFrame?: (cb: () => void) => void }).requestAnimationFrame ??\n ((cb: () => void) => setTimeout(cb, 16))\n schedule(() => {\n r.textContent = message\n })\n}\n"],"mappings":";;AA4CA,MAAM,aAAa;CACjB,CAAC,YAAY,eAAe;CAC5B,CAAC,YAAY,eAAe;CAC5B,CAAC,WAAW,cAAc;CAC1B,CAAC,WAAW,cAAc;CAC1B,CAAC,YAAY,eAAe;CAC5B,CAAC,QAAQ,WAAW;CACpB,CAAC,UAAU,aAAa;AAC1B;;;;;;AAiCA,SAAgB,YAAY,MAA0C;CACpE,MAAM,MAA+B,CAAC;CACtC,IAAI,KAAK,SAAS,KAAA,GAAW,IAAI,OAAO,KAAK;CAC7C,IAAI,KAAK,UAAU,KAAA,GAAW,IAAI,gBAAgB,KAAK;CACvD,IAAI,KAAK,eAAe,KAAA,GAAW,IAAI,qBAAqB,KAAK;CACjE,IAAI,KAAK,gBAAgB,KAAA,GAAW,IAAI,sBAAsB,KAAK;CACnE,IAAI,KAAK,SAAS,KAAA,GAAW,IAAI,eAAe,KAAK;CAErD,MAAM,IAAI,KAAK;CACf,IAAI,OAAO,MAAM,YAAY;EAC3B,MAAM,UAAU,EAAE;EAClB,KAAK,MAAM,CAAC,KAAK,SAAS,YAAY;GACpC,IAAI,QAAQ,SAAS,KAAA,GAAW;GAChC,IAAI,cAAc;IAChB,MAAM,IAAI,EAAE,EAAE;IACd,OAAO,MAAM,KAAA,IAAY,KAAA,IAAY,OAAO,CAAC;GAC/C;EACF;CACF,OAAO,IAAI;OACJ,MAAM,CAAC,KAAK,SAAS,YACxB,IAAI,EAAE,SAAS,KAAA,GAAW,IAAI,QAAQ,OAAO,EAAE,IAAI;CAAA;CAIvD,IAAI,KAAK,aAAa,KAAA,GAAW,IAAI,mBAAmB,OAAO,KAAK,QAAQ;CAC5E,IAAI,KAAK,aAAa,KAAA,GAAW,IAAI,mBAAmB,OAAO,KAAK,QAAQ;CAC5E,MAAM,KAAK,KAAK;CAChB,IAAI,OAAO,OAAO,YAAY,IAAI,yBAAyB,OAAO,GAAG,CAAC;MACjE,IAAI,OAAO,KAAA,GAAW,IAAI,mBAAmB,OAAO,EAAE;CAE3D,OAAO;AACT;AAMA,MAAM,cAAkE,CAAC;;;;;;;AAiBzE,SAAgB,SAAS,SAAiB,aAAuB,UAAgB;CAC/E,MAAM,MAAO,WAAiD;CAC9D,IAAI,CAAC,OAAO,OAAO,IAAI,kBAAkB,cAAc,CAAC,IAAI,MAAM;CAClE,IAAI,SAAS,YAAY;CACzB,IAAI,CAAC,QAAQ;EACX,MAAM,KAAK,IAAI,cAAc,KAAK;EAClC,GAAG,aAAa,aAAa,UAAU;EACvC,GAAG,aAAa,eAAe,MAAM;EACrC,GAAG,aAAa,QAAQ,eAAe,cAAc,UAAU,QAAQ;EAEvE,GAAG,MAAM,UACP;EACF,IAAI,KAAK,YAAY,EAAE;EACvB,SAAS;EACT,YAAY,cAAc;CAC5B;CAEA,OAAO,cAAc;CACrB,MAAM,IAAI;CAIV,CAFG,WAAoE,2BACnE,OAAmB,WAAW,IAAI,EAAE,UACzB;EACb,EAAE,cAAc;CAClB,CAAC;AACH"}
1
+ {"version":3,"file":"a11y.js","names":[],"sources":["../src/a11y.ts"],"sourcesContent":["/**\n * Atlas accessibility — a single typed `A11yProps` surface lowered to `role` + `aria-*`\n * attribute props. The DOM backend passes these through verbatim (`setAttribute`), so web a11y\n * is real; native hosts receive them as serialized props (interpretation is a 🔬 research-track\n * host concern — carried, never silently dropped). See `docs/adr/0022-atlas-primitives.md`.\n *\n * @module\n */\n\n/** A WAI-ARIA-ish role, mapped straight to the host `role` attribute on web. */\nexport type Role =\n | 'button'\n | 'link'\n | 'image'\n | 'heading'\n | 'list'\n | 'listitem'\n | 'text'\n | 'textbox'\n | 'checkbox'\n | 'switch'\n | 'radio'\n | 'tab'\n | 'tabpanel'\n | 'dialog'\n | 'alert'\n | 'status'\n | 'separator'\n | 'progressbar'\n | 'none'\n | 'presentation'\n\n/** Accessibility state, lowered to the matching `aria-*` attributes. */\nexport interface A11yState {\n disabled?: boolean\n selected?: boolean\n checked?: boolean\n pressed?: boolean\n expanded?: boolean\n busy?: boolean\n hidden?: boolean\n}\n\n/** The boolean state keys, paired with their `aria-*` attribute. */\nconst STATE_ARIA = [\n ['disabled', 'aria-disabled'],\n ['selected', 'aria-selected'],\n ['checked', 'aria-checked'],\n ['pressed', 'aria-pressed'],\n ['expanded', 'aria-expanded'],\n ['busy', 'aria-busy'],\n ['hidden', 'aria-hidden'],\n] as const\n\n/** The accessibility surface every Atlas primitive accepts. */\nexport interface A11yProps {\n /** ARIA role (web `role`). */\n role?: Role\n /** Accessible name (`aria-label`). */\n label?: string\n /** Id(s) of the element(s) labelling this one (`aria-labelledby`). */\n labelledBy?: string\n /** Id(s) of the element(s) describing this one (`aria-describedby`). */\n describedBy?: string\n /** Live-region politeness (`aria-live`). */\n live?: 'off' | 'polite' | 'assertive'\n /**\n * Accessibility state → `aria-*`. Pass an **accessor** (`() => ({ checked: on() })`) to make the\n * `aria-*` attributes reactive — a static object bakes them once, so a screen reader never hears\n * a toggle change. Reactive keys are those present on the first read (stable shape).\n */\n state?: A11yState | (() => A11yState)\n /** Current value of a range widget (`aria-valuenow`); accessor → reactive. */\n valueNow?: number | (() => number)\n /** Minimum of a range widget (`aria-valuemin`). */\n valueMin?: number\n /** Maximum of a range widget (`aria-valuemax`). */\n valueMax?: number\n}\n\n/**\n * Lower {@link A11yProps} to a host prop bag of `role` + `aria-*` (only defined keys, so omitted\n * props stay omitted). Accessor-valued `state`/`valueNow` lower to **reactive** attribute bindings\n * (the renderer re-applies them via `setProp`), so accessibility tracks state changes.\n */\nexport function toA11yProps(a11y: A11yProps): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n if (a11y.role !== undefined) out.role = a11y.role\n if (a11y.label !== undefined) out['aria-label'] = a11y.label\n if (a11y.labelledBy !== undefined) out['aria-labelledby'] = a11y.labelledBy\n if (a11y.describedBy !== undefined) out['aria-describedby'] = a11y.describedBy\n if (a11y.live !== undefined) out['aria-live'] = a11y.live\n\n const s = a11y.state\n if (typeof s === 'function') {\n const initial = s()\n for (const [key, attr] of STATE_ARIA) {\n if (initial[key] === undefined) continue\n out[attr] = () => {\n const v = s()[key]\n return v === undefined ? undefined : String(v)\n }\n }\n } else if (s) {\n for (const [key, attr] of STATE_ARIA) {\n if (s[key] !== undefined) out[attr] = String(s[key])\n }\n }\n\n if (a11y.valueMin !== undefined) out['aria-valuemin'] = String(a11y.valueMin)\n if (a11y.valueMax !== undefined) out['aria-valuemax'] = String(a11y.valueMax)\n const vn = a11y.valueNow\n if (typeof vn === 'function') out['aria-valuenow'] = () => String(vn())\n else if (vn !== undefined) out['aria-valuenow'] = String(vn)\n\n return out\n}\n\n/** Politeness for {@link announce} — `'polite'` waits for a pause; `'assertive'` interrupts. */\nexport type Announce = 'polite' | 'assertive'\n\n// One persistent visually-hidden live region per politeness, reused across calls (created lazily).\nconst liveRegions: Partial<Record<Announce, { textContent: string }>> = {}\n// Messages queued within the current frame per politeness — joined into ONE flush so two same-frame\n// announce() calls both speak (a single region can hold one string; last-write-wins would drop the first).\nconst pendingMessages: Partial<Record<Announce, string[]>> = {}\nconst flushScheduled: Partial<Record<Announce, boolean>> = {}\n\ninterface DocLike {\n createElement(tag: string): {\n setAttribute(name: string, value: string): void\n style: { cssText: string }\n textContent: string\n }\n body: { appendChild(node: unknown): void } | null\n}\n\n/**\n * Imperatively announce `message` to screen readers (programmatic, not tied to a rendered node) — for\n * results that aren't otherwise voiced (\"3 results found\", \"Saved\", validation errors). Writes into a\n * persistent visually-hidden `aria-live` region (one per politeness), clearing first so the SAME message\n * re-announces. Multiple calls in the same frame are queued and joined (none is lost). SSR/native-safe.\n */\nexport function announce(message: string, politeness: Announce = 'polite'): void {\n const doc = (globalThis as unknown as { document?: DocLike }).document\n if (!doc || typeof doc.createElement !== 'function' || !doc.body) return\n let region = liveRegions[politeness]\n if (!region) {\n const el = doc.createElement('div')\n el.setAttribute('aria-live', politeness)\n el.setAttribute('aria-atomic', 'true')\n el.setAttribute('role', politeness === 'assertive' ? 'alert' : 'status')\n // Visually hidden but still announced (the standard sr-only clip pattern).\n el.style.cssText =\n 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0'\n doc.body.appendChild(el)\n region = el\n liveRegions[politeness] = el\n }\n let queue = pendingMessages[politeness]\n if (queue === undefined) {\n queue = []\n pendingMessages[politeness] = queue\n }\n queue.push(message)\n if (flushScheduled[politeness]) return // a flush is already queued this frame; messages will join\n flushScheduled[politeness] = true\n // Clear now so a repeated identical message still re-announces; set the joined batch on the next tick.\n region.textContent = ''\n const r = region\n const schedule =\n (globalThis as { requestAnimationFrame?: (cb: () => void) => void }).requestAnimationFrame ??\n ((cb: () => void) => setTimeout(cb, 16))\n schedule(() => {\n r.textContent = (pendingMessages[politeness] ?? []).join(' ')\n pendingMessages[politeness] = []\n flushScheduled[politeness] = false\n })\n}\n"],"mappings":";;AA4CA,MAAM,aAAa;CACjB,CAAC,YAAY,eAAe;CAC5B,CAAC,YAAY,eAAe;CAC5B,CAAC,WAAW,cAAc;CAC1B,CAAC,WAAW,cAAc;CAC1B,CAAC,YAAY,eAAe;CAC5B,CAAC,QAAQ,WAAW;CACpB,CAAC,UAAU,aAAa;AAC1B;;;;;;AAiCA,SAAgB,YAAY,MAA0C;CACpE,MAAM,MAA+B,CAAC;CACtC,IAAI,KAAK,SAAS,KAAA,GAAW,IAAI,OAAO,KAAK;CAC7C,IAAI,KAAK,UAAU,KAAA,GAAW,IAAI,gBAAgB,KAAK;CACvD,IAAI,KAAK,eAAe,KAAA,GAAW,IAAI,qBAAqB,KAAK;CACjE,IAAI,KAAK,gBAAgB,KAAA,GAAW,IAAI,sBAAsB,KAAK;CACnE,IAAI,KAAK,SAAS,KAAA,GAAW,IAAI,eAAe,KAAK;CAErD,MAAM,IAAI,KAAK;CACf,IAAI,OAAO,MAAM,YAAY;EAC3B,MAAM,UAAU,EAAE;EAClB,KAAK,MAAM,CAAC,KAAK,SAAS,YAAY;GACpC,IAAI,QAAQ,SAAS,KAAA,GAAW;GAChC,IAAI,cAAc;IAChB,MAAM,IAAI,EAAE,EAAE;IACd,OAAO,MAAM,KAAA,IAAY,KAAA,IAAY,OAAO,CAAC;GAC/C;EACF;CACF,OAAO,IAAI;OACJ,MAAM,CAAC,KAAK,SAAS,YACxB,IAAI,EAAE,SAAS,KAAA,GAAW,IAAI,QAAQ,OAAO,EAAE,IAAI;CAAA;CAIvD,IAAI,KAAK,aAAa,KAAA,GAAW,IAAI,mBAAmB,OAAO,KAAK,QAAQ;CAC5E,IAAI,KAAK,aAAa,KAAA,GAAW,IAAI,mBAAmB,OAAO,KAAK,QAAQ;CAC5E,MAAM,KAAK,KAAK;CAChB,IAAI,OAAO,OAAO,YAAY,IAAI,yBAAyB,OAAO,GAAG,CAAC;MACjE,IAAI,OAAO,KAAA,GAAW,IAAI,mBAAmB,OAAO,EAAE;CAE3D,OAAO;AACT;AAMA,MAAM,cAAkE,CAAC;AAGzE,MAAM,kBAAuD,CAAC;AAC9D,MAAM,iBAAqD,CAAC;;;;;;;AAiB5D,SAAgB,SAAS,SAAiB,aAAuB,UAAgB;CAC/E,MAAM,MAAO,WAAiD;CAC9D,IAAI,CAAC,OAAO,OAAO,IAAI,kBAAkB,cAAc,CAAC,IAAI,MAAM;CAClE,IAAI,SAAS,YAAY;CACzB,IAAI,CAAC,QAAQ;EACX,MAAM,KAAK,IAAI,cAAc,KAAK;EAClC,GAAG,aAAa,aAAa,UAAU;EACvC,GAAG,aAAa,eAAe,MAAM;EACrC,GAAG,aAAa,QAAQ,eAAe,cAAc,UAAU,QAAQ;EAEvE,GAAG,MAAM,UACP;EACF,IAAI,KAAK,YAAY,EAAE;EACvB,SAAS;EACT,YAAY,cAAc;CAC5B;CACA,IAAI,QAAQ,gBAAgB;CAC5B,IAAI,UAAU,KAAA,GAAW;EACvB,QAAQ,CAAC;EACT,gBAAgB,cAAc;CAChC;CACA,MAAM,KAAK,OAAO;CAClB,IAAI,eAAe,aAAa;CAChC,eAAe,cAAc;CAE7B,OAAO,cAAc;CACrB,MAAM,IAAI;CAIV,CAFG,WAAoE,2BACnE,OAAmB,WAAW,IAAI,EAAE,UACzB;EACb,EAAE,eAAe,gBAAgB,eAAe,CAAC,GAAG,KAAK,GAAG;EAC5D,gBAAgB,cAAc,CAAC;EAC/B,eAAe,cAAc;CAC/B,CAAC;AACH"}
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ import { Maturity, NotImplementedError, PackageInfo, notImplemented } from "@min
19
19
  /** The npm package name. */
20
20
  declare const name = "@mindees/atlas";
21
21
  /** The package version. All `@mindees/*` packages share one locked version line. */
22
- declare const VERSION = "0.30.1";
22
+ declare const VERSION = "0.30.2";
23
23
  /** Current maturity of this package. See the repository `STATUS.md`. */
24
24
  declare const maturity: Maturity;
25
25
  /**
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { NotImplementedError, notImplemented } from "@mindees/core";
18
18
  /** The npm package name. */
19
19
  const name = "@mindees/atlas";
20
20
  /** The package version. All `@mindees/*` packages share one locked version line. */
21
- const VERSION = "0.30.1";
21
+ const VERSION = "0.30.2";
22
22
  /** Current maturity of this package. See the repository `STATUS.md`. */
23
23
  const maturity = "experimental";
24
24
  /**
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * `@mindees/atlas` (Atlas) — accessible, signals-native UI primitives. Function components\n * over `@mindees/core`'s `createElement` that return renderer-agnostic `MindeesNode` trees:\n * web rendering is real via the Helix DOM backend; native is a labeled 🔬 research track (the\n * same serializable tree, interpreted by a native host later). A curated cross-platform\n * `StyleObject`, typed accessibility, and design-token theming (`useTheme`/`tokens`, on the main entry).\n * The virtualized recycling `List` is on the `@mindees/atlas/list` subpath.\n *\n * @module\n */\n\nimport type { Maturity, PackageInfo } from '@mindees/core'\nimport { NotImplementedError, notImplemented } from '@mindees/core'\n\n/** The npm package name. */\nexport const name = '@mindees/atlas'\n\n/** The package version. All `@mindees/*` packages share one locked version line. */\nexport const VERSION = '0.30.1'\n\n/** Current maturity of this package. See the repository `STATUS.md`. */\nexport const maturity: Maturity = 'experimental'\n\n/**\n * Static identity + maturity metadata for this package. Frozen so the\n * self-reported identity tooling introspects cannot be mutated at runtime,\n * matching the `readonly` fields of {@link PackageInfo}.\n */\nexport const info: PackageInfo = Object.freeze({ name, version: VERSION, maturity })\n\nexport {\n type A11yProps,\n type A11yState,\n type Announce,\n announce,\n type Role,\n toA11yProps,\n} from './a11y'\nexport {\n Accordion,\n type AccordionProps,\n type AccordionSection,\n ActivityIndicator,\n type ActivityIndicatorProps,\n Avatar,\n type AvatarProps,\n Badge,\n type BadgeProps,\n Card,\n type CardProps,\n Checkbox,\n type CheckboxProps,\n Chip,\n type ChipProps,\n Divider,\n type DividerProps,\n KeyboardAvoidingView,\n type KeyboardAvoidingViewProps,\n ProgressBar,\n type ProgressBarProps,\n RadioGroup,\n type RadioGroupProps,\n type RadioOption,\n SafeAreaView,\n type SafeAreaViewProps,\n type Segment,\n SegmentedControl,\n type SegmentedControlProps,\n Skeleton,\n type SkeletonProps,\n Stepper,\n type StepperProps,\n Switch,\n type SwitchProps,\n type TabItem,\n Tabs,\n type TabsProps,\n} from './components'\nexport {\n type ColorScheme,\n getEnvironment,\n type KeyboardState,\n type PlatformEnvironment,\n type SafeAreaInsets,\n setEnvironment,\n useColorScheme,\n useKeyboard,\n useReducedMotion,\n useSafeAreaInsets,\n useWindowDimensions,\n type WindowDimensions,\n} from './environment'\nexport { ErrorBoundary, type ErrorBoundaryProps } from './error-boundary'\nexport { type Field, type FormApi, type UseFormOptions, useForm } from './form'\nexport { type AttachableGesture, GestureView, type GestureViewProps } from './gesture'\nexport {\n type AsyncState,\n type Counter,\n type PersistentSignalOptions,\n type SignalStorage,\n type Toggle,\n useAsync,\n useCounter,\n useDebounce,\n useInterval,\n usePersistentSignal,\n usePrevious,\n useReducer,\n useTimeout,\n useToggle,\n} from './hooks'\nexport { type BaseProps, type Reactive, resolveStyle, toHostProps } from './host'\nexport { animateTo, motion } from './motion'\nexport {\n FocusScope,\n type FocusScopeProps,\n Modal,\n type ModalProps,\n Toast,\n type ToastProps,\n} from './overlay'\nexport {\n Button,\n type ButtonProps,\n Column,\n Image,\n type ImageProps,\n type InteractionState,\n Pressable,\n type PressableProps,\n Row,\n ScrollView,\n type ScrollViewProps,\n Spacer,\n type SpacerProps,\n Stack,\n type StackProps,\n Text,\n TextInput,\n type TextInputProps,\n type TextProps,\n usePressable,\n View,\n type ViewProps,\n} from './primitives'\nexport { Show, type ShowProps } from './show'\nexport { flattenStyle, type StyleInput, type StyleObject, type StyleValue } from './style'\nexport {\n duration,\n easing,\n fontSize,\n fontWeight,\n getTheme,\n lineHeight,\n palette,\n radius,\n space,\n type Theme,\n type ThemeColors,\n tokens,\n useTheme,\n} from './tokens'\nexport { connectWebEnvironment, type WebEnvWindow } from './web-environment'\nexport type { Maturity, PackageInfo }\nexport { NotImplementedError, notImplemented }\n"],"mappings":";;;;;;;;;;;;;;;;;;AAeA,MAAa,OAAO;;AAGpB,MAAa,UAAU;;AAGvB,MAAa,WAAqB;;;;;;AAOlC,MAAa,OAAoB,OAAO,OAAO;CAAE;CAAM,SAAS;CAAS;AAAS,CAAC"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * `@mindees/atlas` (Atlas) — accessible, signals-native UI primitives. Function components\n * over `@mindees/core`'s `createElement` that return renderer-agnostic `MindeesNode` trees:\n * web rendering is real via the Helix DOM backend; native is a labeled 🔬 research track (the\n * same serializable tree, interpreted by a native host later). A curated cross-platform\n * `StyleObject`, typed accessibility, and design-token theming (`useTheme`/`tokens`, on the main entry).\n * The virtualized recycling `List` is on the `@mindees/atlas/list` subpath.\n *\n * @module\n */\n\nimport type { Maturity, PackageInfo } from '@mindees/core'\nimport { NotImplementedError, notImplemented } from '@mindees/core'\n\n/** The npm package name. */\nexport const name = '@mindees/atlas'\n\n/** The package version. All `@mindees/*` packages share one locked version line. */\nexport const VERSION = '0.30.2'\n\n/** Current maturity of this package. See the repository `STATUS.md`. */\nexport const maturity: Maturity = 'experimental'\n\n/**\n * Static identity + maturity metadata for this package. Frozen so the\n * self-reported identity tooling introspects cannot be mutated at runtime,\n * matching the `readonly` fields of {@link PackageInfo}.\n */\nexport const info: PackageInfo = Object.freeze({ name, version: VERSION, maturity })\n\nexport {\n type A11yProps,\n type A11yState,\n type Announce,\n announce,\n type Role,\n toA11yProps,\n} from './a11y'\nexport {\n Accordion,\n type AccordionProps,\n type AccordionSection,\n ActivityIndicator,\n type ActivityIndicatorProps,\n Avatar,\n type AvatarProps,\n Badge,\n type BadgeProps,\n Card,\n type CardProps,\n Checkbox,\n type CheckboxProps,\n Chip,\n type ChipProps,\n Divider,\n type DividerProps,\n KeyboardAvoidingView,\n type KeyboardAvoidingViewProps,\n ProgressBar,\n type ProgressBarProps,\n RadioGroup,\n type RadioGroupProps,\n type RadioOption,\n SafeAreaView,\n type SafeAreaViewProps,\n type Segment,\n SegmentedControl,\n type SegmentedControlProps,\n Skeleton,\n type SkeletonProps,\n Stepper,\n type StepperProps,\n Switch,\n type SwitchProps,\n type TabItem,\n Tabs,\n type TabsProps,\n} from './components'\nexport {\n type ColorScheme,\n getEnvironment,\n type KeyboardState,\n type PlatformEnvironment,\n type SafeAreaInsets,\n setEnvironment,\n useColorScheme,\n useKeyboard,\n useReducedMotion,\n useSafeAreaInsets,\n useWindowDimensions,\n type WindowDimensions,\n} from './environment'\nexport { ErrorBoundary, type ErrorBoundaryProps } from './error-boundary'\nexport { type Field, type FormApi, type UseFormOptions, useForm } from './form'\nexport { type AttachableGesture, GestureView, type GestureViewProps } from './gesture'\nexport {\n type AsyncState,\n type Counter,\n type PersistentSignalOptions,\n type SignalStorage,\n type Toggle,\n useAsync,\n useCounter,\n useDebounce,\n useInterval,\n usePersistentSignal,\n usePrevious,\n useReducer,\n useTimeout,\n useToggle,\n} from './hooks'\nexport { type BaseProps, type Reactive, resolveStyle, toHostProps } from './host'\nexport { animateTo, motion } from './motion'\nexport {\n FocusScope,\n type FocusScopeProps,\n Modal,\n type ModalProps,\n Toast,\n type ToastProps,\n} from './overlay'\nexport {\n Button,\n type ButtonProps,\n Column,\n Image,\n type ImageProps,\n type InteractionState,\n Pressable,\n type PressableProps,\n Row,\n ScrollView,\n type ScrollViewProps,\n Spacer,\n type SpacerProps,\n Stack,\n type StackProps,\n Text,\n TextInput,\n type TextInputProps,\n type TextProps,\n usePressable,\n View,\n type ViewProps,\n} from './primitives'\nexport { Show, type ShowProps } from './show'\nexport { flattenStyle, type StyleInput, type StyleObject, type StyleValue } from './style'\nexport {\n duration,\n easing,\n fontSize,\n fontWeight,\n getTheme,\n lineHeight,\n palette,\n radius,\n space,\n type Theme,\n type ThemeColors,\n tokens,\n useTheme,\n} from './tokens'\nexport { connectWebEnvironment, type WebEnvWindow } from './web-environment'\nexport type { Maturity, PackageInfo }\nexport { NotImplementedError, notImplemented }\n"],"mappings":";;;;;;;;;;;;;;;;;;AAeA,MAAa,OAAO;;AAGpB,MAAa,UAAU;;AAGvB,MAAa,WAAqB;;;;;;AAOlC,MAAa,OAAoB,OAAO,OAAO;CAAE;CAAM,SAAS;CAAS;AAAS,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"overlay.d.ts","names":[],"sources":["../src/overlay.ts"],"mappings":";;;;;;;UAqCiB,eAAA;EAAA,SACN,QAAA,GAAW,WAAA;EAYX;EAAA,SAVA,SAAA;EAUiB;EAAA,SARjB,YAAA;EAQ2B;EAAA,SAN3B,QAAA;EAsFV;EAAA,SApFU,IAAA,GAAO,IAAA;EAkBO;EAAA,SAhBd,KAAA;EAqFM;EAAA,SAnFN,KAAA,GAAQ,QAAA,CAAS,UAAA;AAAA;;cAcf,UAAA,EAAY,SAAS,CAAC,eAAA;;UAqElB,UAAA,SAAmB,SAAA;EAAA;EAAA,SAEzB,OAAA,EAAS,QAAA;EAFyB;EAAA,SAIlC,cAAA;EAAA,SACA,QAAA,GAAW,WAAA;EAHF;EAAA,SAKT,eAAA;EAFA;EAAA,SAIA,QAAA,GAAW,QAAA,CAAS,UAAA;EAFpB;EAAA,SAIA,KAAA;AAAA;;cAIE,KAAA,EAAO,SAAS,CAAC,UAAA;;UA0Db,UAAA;EA1DJ;EAAA,SA4DF,OAAA,EAAS,QAAA;;WAET,OAAA,GAAU,WAAA;EAAA,SACV,QAAA,GAAW,WAAA;EALL;EAAA,SAON,QAAA;EAAA,SACA,SAAA;EANS;EAAA,SAQT,QAAA;EALW;EAAA,SAOX,IAAA,GAAO,IAAA;EAAI;EAAA,SAEX,KAAA;AAAA;;;;;cAOE,KAAA,EAAO,SAAS,CAAC,UAAA"}
1
+ {"version":3,"file":"overlay.d.ts","names":[],"sources":["../src/overlay.ts"],"mappings":";;;;;;;UAqCiB,eAAA;EAAA,SACN,QAAA,GAAW,WAAA;EAYX;EAAA,SAVA,SAAA;EAUiB;EAAA,SARjB,YAAA;EAQ2B;EAAA,SAN3B,QAAA;EAwHV;EAAA,SAtHU,IAAA,GAAO,IAAA;EAiDO;EAAA,SA/Cd,KAAA;EAuHM;EAAA,SArHN,KAAA,GAAQ,QAAA,CAAS,UAAA;AAAA;;cA6Cf,UAAA,EAAY,SAAS,CAAC,eAAA;;UAwElB,UAAA,SAAmB,SAAA;EAAA;EAAA,SAEzB,OAAA,EAAS,QAAA;EAFyB;EAAA,SAIlC,cAAA;EAAA,SACA,QAAA,GAAW,WAAA;EAHF;EAAA,SAKT,eAAA;EAFA;EAAA,SAIA,QAAA,GAAW,QAAA,CAAS,UAAA;EAFpB;EAAA,SAIA,KAAA;AAAA;;cAIE,KAAA,EAAO,SAAS,CAAC,UAAA;;UA0Db,UAAA;EA1DJ;EAAA,SA4DF,OAAA,EAAS,QAAA;;WAET,OAAA,GAAU,WAAA;EAAA,SACV,QAAA,GAAW,WAAA;EALL;EAAA,SAON,QAAA;EAAA,SACA,SAAA;EANS;EAAA,SAQT,QAAA;EALW;EAAA,SAOX,IAAA,GAAO,IAAA;EAAI;EAAA,SAEX,KAAA;AAAA;;;;;cAOE,KAAA,EAAO,SAAS,CAAC,UAAA"}
package/dist/overlay.js CHANGED
@@ -11,10 +11,10 @@ import { createElement, effect, onCleanup, portal } from "@mindees/core";
11
11
  * the previously-focused element, auto-focuses its container, and restores focus on unmount —
12
12
  * DOM-feature-detected, so it no-ops on native/headless (the dialog markup + a11y still serialize).
13
13
  *
14
- * v1 scope: web is fully interactive (scrim dismiss, Escape, focus capture/restore). Native is
15
- * declarative `role="dialog"` + `aria-modal` are carried to the host; a true focus trap +
16
- * tab-cycling and back-button handling are a host follow-up (see the portal-modal ADR). Tab
17
- * cycling within the scope is also deferred (needs a descendant query).
14
+ * v1 scope: web is fully interactive (scrim dismiss, Escape, focus capture/restore, AND a real Tab focus
15
+ * trap that cycles within the scope and skips hidden focusables WCAG 2.4.3). Native is declarative —
16
+ * `role="dialog"` + `aria-modal` are carried to the host; native focus management + back-button handling
17
+ * remain a host follow-up (see the portal-modal ADR).
18
18
  *
19
19
  * @module
20
20
  */
@@ -23,6 +23,22 @@ function toAccessor(value, fallback) {
23
23
  }
24
24
  /** Tabbable descendants of a focus scope (the standard focusable set, minus `tabindex="-1"`). */
25
25
  const FOCUSABLE_SELECTOR = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])";
26
+ /**
27
+ * Whether `el` (a focusable in `scope`) is actually tabbable. The CSS selector can't see visibility, so a
28
+ * `display:none`/`hidden`/`aria-hidden` subtree (e.g. a tab navigator's inactive, kept-alive panels) would
29
+ * otherwise become a false trap boundary and let Tab escape. Walk inline hidden flags up to the scope
30
+ * (deterministic, layout-free), then — in real browsers — exclude anything with no box.
31
+ */
32
+ function isTabbable(el, scope) {
33
+ let n = el;
34
+ while (n && n !== scope.parentElement) {
35
+ if (n.style?.display === "none" || n.hidden === true || n.getAttribute?.("aria-hidden") === "true") return false;
36
+ if (n === scope) break;
37
+ n = n.parentElement ?? null;
38
+ }
39
+ if (typeof el.getClientRects === "function" && el.getClientRects().length === 0) return false;
40
+ return true;
41
+ }
26
42
  /** A focus-scoped, `aria-modal` container. Captures + restores focus AND traps Tab on web; declarative elsewhere. */
27
43
  const FocusScope = (props) => {
28
44
  let restore = null;
@@ -40,7 +56,7 @@ const FocusScope = (props) => {
40
56
  const trapTab = (e) => {
41
57
  const node = scopeNode;
42
58
  if (typeof document === "undefined" || !node?.querySelectorAll) return;
43
- const all = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR));
59
+ const all = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => isTabbable(el, node));
44
60
  if (all.length === 0) {
45
61
  e.preventDefault?.();
46
62
  node.focus?.();
@@ -1 +1 @@
1
- {"version":3,"file":"overlay.js","names":["doc"],"sources":["../src/overlay.ts"],"sourcesContent":["/**\n * Atlas overlays — `Modal` + `FocusScope`, built on core's `portal`.\n *\n * `Modal` gates a portal by a reactive `visible`: on open it relocates a scrim + a focus-scoped\n * dialog to the renderer's overlay layer (above the tree); on close the gating region re-runs,\n * firing the portal's cleanup (unmount) and the FocusScope's focus-restore. `FocusScope` captures\n * the previously-focused element, auto-focuses its container, and restores focus on unmount —\n * DOM-feature-detected, so it no-ops on native/headless (the dialog markup + a11y still serialize).\n *\n * v1 scope: web is fully interactive (scrim dismiss, Escape, focus capture/restore). Native is\n * declarative — `role=\"dialog\"` + `aria-modal` are carried to the host; a true focus trap +\n * tab-cycling and back-button handling are a host follow-up (see the portal-modal ADR). Tab\n * cycling within the scope is also deferred (needs a descendant query).\n *\n * @module\n */\n\nimport {\n type Component,\n createElement,\n effect,\n type MindeesNode,\n onCleanup,\n portal,\n} from '@mindees/core'\nimport type { A11yProps, Role } from './a11y'\nimport type { Reactive } from './host'\nimport { Pressable, Text, View } from './primitives'\nimport { flattenStyle, type StyleInput } from './style'\n\nfunction toAccessor<T>(value: Reactive<T>, fallback: T): () => T {\n return typeof value === 'function'\n ? (value as () => T)\n : () => (value === undefined ? fallback : value)\n}\n\n/** Props for {@link FocusScope}. */\nexport interface FocusScopeProps {\n readonly children?: MindeesNode\n /** Focus the scope container on mount (default true). */\n readonly autoFocus?: boolean\n /** Restore focus to the previously-focused element on unmount (default true). */\n readonly restoreFocus?: boolean\n /** Called when Escape is pressed inside the scope. */\n readonly onEscape?: () => void\n /** Dialog role (default `'dialog'`). */\n readonly role?: Role\n /** Accessible name (`aria-label`). */\n readonly label?: string\n /** Extra style on the scope container. */\n readonly style?: Reactive<StyleInput>\n}\n\n/** Tabbable descendants of a focus scope (the standard focusable set, minus `tabindex=\"-1\"`). */\nconst FOCUSABLE_SELECTOR =\n 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])'\n\ninterface FocusableNode {\n focus?: () => void\n querySelectorAll?: (selector: string) => ArrayLike<{ focus?: () => void }>\n isConnected?: boolean\n}\n\n/** A focus-scoped, `aria-modal` container. Captures + restores focus AND traps Tab on web; declarative elsewhere. */\nexport const FocusScope: Component<FocusScopeProps> = (props) => {\n let restore: (() => void) | null = null\n let scopeNode: FocusableNode | null = null\n const captureAndFocus = (host: unknown): void => {\n if (typeof document === 'undefined') return // native/headless: declarative only\n const node = host as FocusableNode | null\n scopeNode = node\n const doc = document as unknown as { activeElement?: { focus?: () => void } | null }\n const previous = doc.activeElement ?? null\n if (props.restoreFocus !== false && previous && typeof previous.focus === 'function') {\n restore = () => previous.focus?.()\n }\n if (props.autoFocus !== false && node && typeof node.focus === 'function') {\n // Defer: `ref` fires before the portal subtree is connected to the document, so a synchronous\n // focus() would no-op. By the microtask the subtree is mounted; skip if it closed first.\n queueMicrotask(() => {\n if (node.isConnected) node.focus?.()\n })\n }\n }\n // Trap Tab within the scope (WCAG 2.4.3): wrap focus from the last tabbable to the first (and back on\n // Shift+Tab), so keyboard focus can't escape an open modal. No-op if the scope has no tabbable children.\n const trapTab = (e: { shiftKey?: boolean; preventDefault?: () => void }): void => {\n const node = scopeNode\n if (typeof document === 'undefined' || !node?.querySelectorAll) return\n const all = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR))\n if (all.length === 0) {\n e.preventDefault?.()\n node.focus?.() // nothing tabbable inside → keep focus on the dialog itself\n return\n }\n const first = all[0]\n const last = all[all.length - 1]\n const active = (document as unknown as { activeElement?: unknown }).activeElement\n if (e.shiftKey && active === first) {\n e.preventDefault?.()\n last?.focus?.()\n } else if (!e.shiftKey && active === last) {\n e.preventDefault?.()\n first?.focus?.()\n }\n }\n onCleanup(() => restore?.())\n\n const callerStyle = props.style\n const style: Reactive<StyleInput> = () =>\n flattenStyle([\n { position: 'relative' },\n typeof callerStyle === 'function' ? callerStyle() : (callerStyle ?? {}),\n ])\n\n const hostProps: Record<string, unknown> = {\n role: props.role ?? 'dialog',\n 'aria-modal': 'true',\n tabindex: -1,\n ref: captureAndFocus,\n style,\n }\n if (props.label !== undefined) hostProps['aria-label'] = props.label\n hostProps.onKeyDown = (e: unknown) => {\n const ev = e as { key?: string; shiftKey?: boolean; preventDefault?: () => void }\n if (ev.key === 'Escape') props.onEscape?.()\n else if (ev.key === 'Tab') trapTab(ev)\n }\n // A RAW host `view` (not the curated View primitive) so ref/tabindex/onKeyDown/aria-modal pass through.\n return createElement('view', hostProps, props.children)\n}\n\n/** Props for {@link Modal}. */\nexport interface ModalProps extends A11yProps {\n /** Whether the modal is open (static or reactive). */\n readonly visible: Reactive<boolean>\n /** Requested close (scrim press or Escape). */\n readonly onRequestClose?: () => void\n readonly children?: MindeesNode\n /** Close when the scrim is pressed (default true). */\n readonly closeOnBackdrop?: boolean\n /** Extra style merged into the scrim. */\n readonly backdrop?: Reactive<StyleInput>\n /** Explicit overlay host target (else the backend's overlay layer). */\n readonly mount?: unknown\n}\n\n/** A portal-backed modal dialog: a dismissable scrim + a focus-scoped dialog above the tree. */\nexport const Modal: Component<ModalProps> = (props) => {\n const isVisible = toAccessor(props.visible, false)\n return () => {\n if (!isVisible()) return null\n\n const scrimStyle = (): StyleInput =>\n flattenStyle([\n {\n position: 'absolute',\n top: 0,\n right: 0,\n bottom: 0,\n left: 0,\n backgroundColor: 'rgba(0,0,0,0.5)',\n },\n typeof props.backdrop === 'function' ? props.backdrop() : (props.backdrop ?? {}),\n ])\n const scrim = createElement(Pressable, {\n style: scrimStyle,\n label: 'Close',\n ...(props.closeOnBackdrop !== false && props.onRequestClose\n ? { onPress: props.onRequestClose }\n : {}),\n })\n\n const dialog = createElement(\n FocusScope,\n {\n role: props.role ?? 'dialog',\n ...(props.onRequestClose ? { onEscape: props.onRequestClose } : {}),\n ...(props.label !== undefined ? { label: props.label } : {}),\n },\n props.children,\n )\n\n // A full-screen flex-center container holds the scrim (behind) + the dialog (on top, centered).\n const container = createElement(\n View,\n {\n style: () => ({\n position: 'fixed',\n top: 0,\n right: 0,\n bottom: 0,\n left: 0,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n }),\n },\n scrim,\n dialog,\n )\n return portal(container, props.mount !== undefined ? { mount: props.mount } : undefined)\n }\n}\n\n/** Props for {@link Toast}. */\nexport interface ToastProps {\n /** Whether the toast is shown (static or reactive). */\n readonly visible: Reactive<boolean>\n /** Convenience message (string or node); or pass `children`. */\n readonly message?: MindeesNode\n readonly children?: MindeesNode\n /** Auto-dismiss after this many ms (calls `onDismiss`). `0`/omitted → stays until hidden. */\n readonly duration?: number\n readonly onDismiss?: () => void\n /** Anchor edge (default `bottom`). */\n readonly position?: 'top' | 'bottom'\n /** a11y role (default `status`; use `alert` for errors). */\n readonly role?: Role\n /** Explicit overlay host target (else the backend's overlay layer). */\n readonly mount?: unknown\n}\n\n/**\n * A portal-backed transient notification (Snackbar). Controlled by `visible`; optionally auto-dismisses\n * after `duration` ms. Anchored bottom (or top) via the overlay layer — RN ships none built-in.\n */\nexport const Toast: Component<ToastProps> = (props) => {\n const isVisible = toAccessor(props.visible, false)\n\n // Auto-dismiss: (re)arm a timer whenever the toast is shown; clear it on hide/unmount/re-run.\n effect(() => {\n if (!isVisible()) return\n const ms = props.duration\n if (ms && ms > 0 && typeof setTimeout === 'function' && props.onDismiss) {\n const id = setTimeout(() => props.onDismiss?.(), ms)\n onCleanup(() => clearTimeout(id))\n }\n })\n\n return () => {\n if (!isVisible()) return null\n const atTop = props.position === 'top'\n const bubble = createElement(\n View,\n {\n role: props.role ?? 'status',\n style: () => ({\n backgroundColor: '#1f2430',\n borderRadius: 12,\n paddingTop: 12,\n paddingBottom: 12,\n paddingLeft: 16,\n paddingRight: 16,\n maxWidth: 480,\n }),\n },\n typeof props.message === 'string'\n ? createElement(Text, { style: () => ({ color: '#ffffff' }) }, props.message)\n : (props.children ?? props.message),\n )\n const container = createElement(\n View,\n {\n style: () => ({\n position: 'fixed',\n left: 0,\n right: 0,\n [atTop ? 'top' : 'bottom']: 0,\n display: 'flex',\n flexDirection: 'row',\n justifyContent: 'center',\n padding: 16,\n }),\n },\n bubble,\n )\n return portal(container, props.mount !== undefined ? { mount: props.mount } : undefined)\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"overlay.js","names":["doc"],"sources":["../src/overlay.ts"],"sourcesContent":["/**\n * Atlas overlays — `Modal` + `FocusScope`, built on core's `portal`.\n *\n * `Modal` gates a portal by a reactive `visible`: on open it relocates a scrim + a focus-scoped\n * dialog to the renderer's overlay layer (above the tree); on close the gating region re-runs,\n * firing the portal's cleanup (unmount) and the FocusScope's focus-restore. `FocusScope` captures\n * the previously-focused element, auto-focuses its container, and restores focus on unmount —\n * DOM-feature-detected, so it no-ops on native/headless (the dialog markup + a11y still serialize).\n *\n * v1 scope: web is fully interactive (scrim dismiss, Escape, focus capture/restore, AND a real Tab focus\n * trap that cycles within the scope and skips hidden focusables — WCAG 2.4.3). Native is declarative —\n * `role=\"dialog\"` + `aria-modal` are carried to the host; native focus management + back-button handling\n * remain a host follow-up (see the portal-modal ADR).\n *\n * @module\n */\n\nimport {\n type Component,\n createElement,\n effect,\n type MindeesNode,\n onCleanup,\n portal,\n} from '@mindees/core'\nimport type { A11yProps, Role } from './a11y'\nimport type { Reactive } from './host'\nimport { Pressable, Text, View } from './primitives'\nimport { flattenStyle, type StyleInput } from './style'\n\nfunction toAccessor<T>(value: Reactive<T>, fallback: T): () => T {\n return typeof value === 'function'\n ? (value as () => T)\n : () => (value === undefined ? fallback : value)\n}\n\n/** Props for {@link FocusScope}. */\nexport interface FocusScopeProps {\n readonly children?: MindeesNode\n /** Focus the scope container on mount (default true). */\n readonly autoFocus?: boolean\n /** Restore focus to the previously-focused element on unmount (default true). */\n readonly restoreFocus?: boolean\n /** Called when Escape is pressed inside the scope. */\n readonly onEscape?: () => void\n /** Dialog role (default `'dialog'`). */\n readonly role?: Role\n /** Accessible name (`aria-label`). */\n readonly label?: string\n /** Extra style on the scope container. */\n readonly style?: Reactive<StyleInput>\n}\n\n/** Tabbable descendants of a focus scope (the standard focusable set, minus `tabindex=\"-1\"`). */\nconst FOCUSABLE_SELECTOR =\n 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])'\n\ninterface TabbableEl {\n focus?: () => void\n style?: { display?: string }\n hidden?: boolean\n getAttribute?: (name: string) => string | null\n parentElement?: TabbableEl | null\n getClientRects?: () => { length: number }\n}\ninterface FocusableNode extends TabbableEl {\n querySelectorAll?: (selector: string) => ArrayLike<TabbableEl>\n isConnected?: boolean\n}\n\n/**\n * Whether `el` (a focusable in `scope`) is actually tabbable. The CSS selector can't see visibility, so a\n * `display:none`/`hidden`/`aria-hidden` subtree (e.g. a tab navigator's inactive, kept-alive panels) would\n * otherwise become a false trap boundary and let Tab escape. Walk inline hidden flags up to the scope\n * (deterministic, layout-free), then — in real browsers — exclude anything with no box.\n */\nfunction isTabbable(el: TabbableEl, scope: FocusableNode): boolean {\n let n: TabbableEl | null = el\n while (n && n !== scope.parentElement) {\n if (\n n.style?.display === 'none' ||\n n.hidden === true ||\n n.getAttribute?.('aria-hidden') === 'true'\n ) {\n return false\n }\n if (n === scope) break\n n = n.parentElement ?? null\n }\n // happy-dom has no layout (returns rects for everything), so this only tightens the real-browser result.\n if (typeof el.getClientRects === 'function' && el.getClientRects().length === 0) return false\n return true\n}\n\n/** A focus-scoped, `aria-modal` container. Captures + restores focus AND traps Tab on web; declarative elsewhere. */\nexport const FocusScope: Component<FocusScopeProps> = (props) => {\n let restore: (() => void) | null = null\n let scopeNode: FocusableNode | null = null\n const captureAndFocus = (host: unknown): void => {\n if (typeof document === 'undefined') return // native/headless: declarative only\n const node = host as FocusableNode | null\n scopeNode = node\n const doc = document as unknown as { activeElement?: { focus?: () => void } | null }\n const previous = doc.activeElement ?? null\n if (props.restoreFocus !== false && previous && typeof previous.focus === 'function') {\n restore = () => previous.focus?.()\n }\n if (props.autoFocus !== false && node && typeof node.focus === 'function') {\n // Defer: `ref` fires before the portal subtree is connected to the document, so a synchronous\n // focus() would no-op. By the microtask the subtree is mounted; skip if it closed first.\n queueMicrotask(() => {\n if (node.isConnected) node.focus?.()\n })\n }\n }\n // Trap Tab within the scope (WCAG 2.4.3): wrap focus from the last tabbable to the first (and back on\n // Shift+Tab), so keyboard focus can't escape an open modal. No-op if the scope has no tabbable children.\n const trapTab = (e: { shiftKey?: boolean; preventDefault?: () => void }): void => {\n const node = scopeNode\n if (typeof document === 'undefined' || !node?.querySelectorAll) return\n // Only VISIBLE focusables are real Tab boundaries — a hidden one would let Tab escape the trap.\n const all = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) =>\n isTabbable(el, node),\n )\n if (all.length === 0) {\n e.preventDefault?.()\n node.focus?.() // nothing tabbable inside → keep focus on the dialog itself\n return\n }\n const first = all[0]\n const last = all[all.length - 1]\n const active = (document as unknown as { activeElement?: unknown }).activeElement\n if (e.shiftKey && active === first) {\n e.preventDefault?.()\n last?.focus?.()\n } else if (!e.shiftKey && active === last) {\n e.preventDefault?.()\n first?.focus?.()\n }\n }\n onCleanup(() => restore?.())\n\n const callerStyle = props.style\n const style: Reactive<StyleInput> = () =>\n flattenStyle([\n { position: 'relative' },\n typeof callerStyle === 'function' ? callerStyle() : (callerStyle ?? {}),\n ])\n\n const hostProps: Record<string, unknown> = {\n role: props.role ?? 'dialog',\n 'aria-modal': 'true',\n tabindex: -1,\n ref: captureAndFocus,\n style,\n }\n if (props.label !== undefined) hostProps['aria-label'] = props.label\n hostProps.onKeyDown = (e: unknown) => {\n const ev = e as { key?: string; shiftKey?: boolean; preventDefault?: () => void }\n if (ev.key === 'Escape') props.onEscape?.()\n else if (ev.key === 'Tab') trapTab(ev)\n }\n // A RAW host `view` (not the curated View primitive) so ref/tabindex/onKeyDown/aria-modal pass through.\n return createElement('view', hostProps, props.children)\n}\n\n/** Props for {@link Modal}. */\nexport interface ModalProps extends A11yProps {\n /** Whether the modal is open (static or reactive). */\n readonly visible: Reactive<boolean>\n /** Requested close (scrim press or Escape). */\n readonly onRequestClose?: () => void\n readonly children?: MindeesNode\n /** Close when the scrim is pressed (default true). */\n readonly closeOnBackdrop?: boolean\n /** Extra style merged into the scrim. */\n readonly backdrop?: Reactive<StyleInput>\n /** Explicit overlay host target (else the backend's overlay layer). */\n readonly mount?: unknown\n}\n\n/** A portal-backed modal dialog: a dismissable scrim + a focus-scoped dialog above the tree. */\nexport const Modal: Component<ModalProps> = (props) => {\n const isVisible = toAccessor(props.visible, false)\n return () => {\n if (!isVisible()) return null\n\n const scrimStyle = (): StyleInput =>\n flattenStyle([\n {\n position: 'absolute',\n top: 0,\n right: 0,\n bottom: 0,\n left: 0,\n backgroundColor: 'rgba(0,0,0,0.5)',\n },\n typeof props.backdrop === 'function' ? props.backdrop() : (props.backdrop ?? {}),\n ])\n const scrim = createElement(Pressable, {\n style: scrimStyle,\n label: 'Close',\n ...(props.closeOnBackdrop !== false && props.onRequestClose\n ? { onPress: props.onRequestClose }\n : {}),\n })\n\n const dialog = createElement(\n FocusScope,\n {\n role: props.role ?? 'dialog',\n ...(props.onRequestClose ? { onEscape: props.onRequestClose } : {}),\n ...(props.label !== undefined ? { label: props.label } : {}),\n },\n props.children,\n )\n\n // A full-screen flex-center container holds the scrim (behind) + the dialog (on top, centered).\n const container = createElement(\n View,\n {\n style: () => ({\n position: 'fixed',\n top: 0,\n right: 0,\n bottom: 0,\n left: 0,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n }),\n },\n scrim,\n dialog,\n )\n return portal(container, props.mount !== undefined ? { mount: props.mount } : undefined)\n }\n}\n\n/** Props for {@link Toast}. */\nexport interface ToastProps {\n /** Whether the toast is shown (static or reactive). */\n readonly visible: Reactive<boolean>\n /** Convenience message (string or node); or pass `children`. */\n readonly message?: MindeesNode\n readonly children?: MindeesNode\n /** Auto-dismiss after this many ms (calls `onDismiss`). `0`/omitted → stays until hidden. */\n readonly duration?: number\n readonly onDismiss?: () => void\n /** Anchor edge (default `bottom`). */\n readonly position?: 'top' | 'bottom'\n /** a11y role (default `status`; use `alert` for errors). */\n readonly role?: Role\n /** Explicit overlay host target (else the backend's overlay layer). */\n readonly mount?: unknown\n}\n\n/**\n * A portal-backed transient notification (Snackbar). Controlled by `visible`; optionally auto-dismisses\n * after `duration` ms. Anchored bottom (or top) via the overlay layer — RN ships none built-in.\n */\nexport const Toast: Component<ToastProps> = (props) => {\n const isVisible = toAccessor(props.visible, false)\n\n // Auto-dismiss: (re)arm a timer whenever the toast is shown; clear it on hide/unmount/re-run.\n effect(() => {\n if (!isVisible()) return\n const ms = props.duration\n if (ms && ms > 0 && typeof setTimeout === 'function' && props.onDismiss) {\n const id = setTimeout(() => props.onDismiss?.(), ms)\n onCleanup(() => clearTimeout(id))\n }\n })\n\n return () => {\n if (!isVisible()) return null\n const atTop = props.position === 'top'\n const bubble = createElement(\n View,\n {\n role: props.role ?? 'status',\n style: () => ({\n backgroundColor: '#1f2430',\n borderRadius: 12,\n paddingTop: 12,\n paddingBottom: 12,\n paddingLeft: 16,\n paddingRight: 16,\n maxWidth: 480,\n }),\n },\n typeof props.message === 'string'\n ? createElement(Text, { style: () => ({ color: '#ffffff' }) }, props.message)\n : (props.children ?? props.message),\n )\n const container = createElement(\n View,\n {\n style: () => ({\n position: 'fixed',\n left: 0,\n right: 0,\n [atTop ? 'top' : 'bottom']: 0,\n display: 'flex',\n flexDirection: 'row',\n justifyContent: 'center',\n padding: 16,\n }),\n },\n bubble,\n )\n return portal(container, props.mount !== undefined ? { mount: props.mount } : undefined)\n }\n}\n"],"mappings":"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"}
package/dist/tab.js CHANGED
@@ -37,7 +37,7 @@ function createTabNavigator(router, options) {
37
37
  return () => {
38
38
  const activeIndex = () => {
39
39
  const path = router.location().pathname;
40
- let best = 0;
40
+ let best = -1;
41
41
  let bestLen = -1;
42
42
  tabs.forEach((t, i) => {
43
43
  if ((path === t.path || path.startsWith(`${t.path}/`)) && t.path.length > bestLen) {
package/dist/tab.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tab.js","names":[],"sources":["../src/tab.ts"],"sourcesContent":["/**\n * Atlas `createTabNavigator` — tab navigation over the Quantum router.\n *\n * Each tab owns a route path. The active tab is DERIVED from the current URL (longest matching tab path),\n * so deep-links and back/forward Just Work; tapping a tab navigates. Every tab's screen stays MOUNTED, so\n * its state (scroll position, form input, in-flight data) is preserved across switches — only visibility\n * toggles. Full ARIA `tablist`/`tab`/`tabpanel` semantics (an inactive panel is `display:none`, which also\n * removes it from the a11y tree and tab order). No new core/router surface.\n *\n * v1 scope: web is real; screens mount eagerly and keep state (lazy mounting is a follow-up). Native\n * carries the same markup (interpretation is a host concern).\n *\n * @module\n */\n\nimport { type Component, createElement, effect, signal } from '@mindees/core'\nimport type { Router } from '@mindees/router'\nimport type { Reactive } from './host'\nimport { Pressable, Text, View } from './primitives'\nimport { flattenStyle, type StyleInput } from './style'\n\n/** One tab in a {@link createTabNavigator}. */\nexport interface TabDef {\n /** The route path this tab activates + navigates to (e.g. `/home`). */\n readonly path: string\n /** Tab-bar label (also the tab's accessible name). */\n readonly label: string\n /** The screen component shown when this tab is active. */\n readonly component: Component\n}\n\n/** Options for {@link createTabNavigator}. */\nexport interface TabNavigatorOptions {\n readonly tabs: readonly TabDef[]\n /** Tab-bar edge (default `'bottom'`). */\n readonly tabBarPosition?: 'top' | 'bottom'\n /** Extra style merged into the tab bar. */\n readonly tabBarStyle?: Reactive<StyleInput>\n}\n\nconst styleFn = (extra: Reactive<StyleInput> | undefined, base: StyleInput): (() => StyleInput) => {\n return () => flattenStyle([base, typeof extra === 'function' ? extra() : (extra ?? {})])\n}\n\n/**\n * Create a tab navigator {@link Component} bound to `router`. Render it via `createElement` (so the\n * renderer owns its reactive scope and disposes it on unmount).\n *\n * @example\n * const Tabs = createTabNavigator(router, {\n * tabs: [\n * { path: '/home', label: 'Home', component: Home },\n * { path: '/settings', label: 'Settings', component: Settings },\n * ],\n * })\n */\nexport function createTabNavigator(\n router: Router,\n options: TabNavigatorOptions,\n): Component<Record<string, never>> {\n const tabs = options.tabs\n const position = options.tabBarPosition ?? 'bottom'\n\n return () => {\n // Active tab = the LONGEST tab path that prefixes the current pathname (deep-link + nested-route aware).\n const activeIndex = (): number => {\n const path = router.location().pathname\n let best = 0\n let bestLen = -1\n tabs.forEach((t, i) => {\n if ((path === t.path || path.startsWith(`${t.path}/`)) && t.path.length > bestLen) {\n best = i\n bestLen = t.path.length\n }\n })\n return best\n }\n\n // Lazy + keep-alive (RN parity): a tab's screen mounts on its FIRST activation and stays mounted\n // thereafter, so an unvisited tab's loaders/effects never run, and a visited tab keeps its state.\n const visited = tabs.map(() => signal(false))\n effect(() => {\n visited[activeIndex()]?.set(true)\n })\n\n // One panel per tab; only the active one is shown (`display:none` also drops inactive panels from the\n // a11y tree + tab order). The screen is mounted lazily (once visited) and never unmounted.\n const panels = tabs.map((t, i) =>\n createElement(\n View,\n {\n role: 'tabpanel',\n style: () => ({\n display: activeIndex() === i ? 'flex' : 'none',\n flexDirection: 'column',\n flex: 1,\n minHeight: 0,\n }),\n },\n () => (visited[i]?.() ? createElement(t.component, {}) : null),\n ),\n )\n const panelArea = createElement(\n View,\n { style: () => ({ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }) },\n ...panels,\n )\n\n const bar = createElement(\n View,\n {\n role: 'tablist',\n style: styleFn(options.tabBarStyle, { display: 'flex', flexDirection: 'row' }),\n },\n ...tabs.map((t, i) =>\n createElement(\n Pressable,\n {\n role: 'tab',\n label: t.label,\n state: () => ({ selected: activeIndex() === i }),\n style: () => ({\n flex: 1,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n padding: 12,\n }),\n onPress: () => {\n if (activeIndex() !== i) router.navigate(t.path)\n },\n },\n createElement(Text, {}, t.label),\n ),\n ),\n )\n\n return createElement(\n View,\n { style: () => ({ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }) },\n ...(position === 'top' ? [bar, panelArea] : [panelArea, bar]),\n )\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAwCA,MAAM,WAAW,OAAyC,SAAyC;CACjG,aAAa,aAAa,CAAC,MAAM,OAAO,UAAU,aAAa,MAAM,IAAK,SAAS,CAAC,CAAE,CAAC;AACzF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,QACA,SACkC;CAClC,MAAM,OAAO,QAAQ;CACrB,MAAM,WAAW,QAAQ,kBAAkB;CAE3C,aAAa;EAEX,MAAM,oBAA4B;GAChC,MAAM,OAAO,OAAO,SAAS,EAAE;GAC/B,IAAI,OAAO;GACX,IAAI,UAAU;GACd,KAAK,SAAS,GAAG,MAAM;IACrB,KAAK,SAAS,EAAE,QAAQ,KAAK,WAAW,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,SAAS,SAAS;KACjF,OAAO;KACP,UAAU,EAAE,KAAK;IACnB;GACF,CAAC;GACD,OAAO;EACT;EAIA,MAAM,UAAU,KAAK,UAAU,OAAO,KAAK,CAAC;EAC5C,aAAa;GACX,QAAQ,YAAY,IAAI,IAAI,IAAI;EAClC,CAAC;EAmBD,MAAM,YAAY,cAChB,MACA,EAAE,cAAc;GAAE,SAAS;GAAQ,eAAe;GAAU,MAAM;GAAG,WAAW;EAAE,GAAG,GACrF,GAlBa,KAAK,KAAK,GAAG,MAC1B,cACE,MACA;GACE,MAAM;GACN,cAAc;IACZ,SAAS,YAAY,MAAM,IAAI,SAAS;IACxC,eAAe;IACf,MAAM;IACN,WAAW;GACb;EACF,SACO,QAAQ,KAAK,IAAI,cAAc,EAAE,WAAW,CAAC,CAAC,IAAI,IAC3D,CAKQ,CACV;EAEA,MAAM,MAAM,cACV,MACA;GACE,MAAM;GACN,OAAO,QAAQ,QAAQ,aAAa;IAAE,SAAS;IAAQ,eAAe;GAAM,CAAC;EAC/E,GACA,GAAG,KAAK,KAAK,GAAG,MACd,cACE,WACA;GACE,MAAM;GACN,OAAO,EAAE;GACT,cAAc,EAAE,UAAU,YAAY,MAAM,EAAE;GAC9C,cAAc;IACZ,MAAM;IACN,SAAS;IACT,YAAY;IACZ,gBAAgB;IAChB,SAAS;GACX;GACA,eAAe;IACb,IAAI,YAAY,MAAM,GAAG,OAAO,SAAS,EAAE,IAAI;GACjD;EACF,GACA,cAAc,MAAM,CAAC,GAAG,EAAE,KAAK,CACjC,CACF,CACF;EAEA,OAAO,cACL,MACA,EAAE,cAAc;GAAE,SAAS;GAAQ,eAAe;GAAU,MAAM;GAAG,WAAW;EAAE,GAAG,GACrF,GAAI,aAAa,QAAQ,CAAC,KAAK,SAAS,IAAI,CAAC,WAAW,GAAG,CAC7D;CACF;AACF"}
1
+ {"version":3,"file":"tab.js","names":[],"sources":["../src/tab.ts"],"sourcesContent":["/**\n * Atlas `createTabNavigator` — tab navigation over the Quantum router.\n *\n * Each tab owns a route path. The active tab is DERIVED from the current URL (longest matching tab path),\n * so deep-links and back/forward Just Work; tapping a tab navigates. Every tab's screen stays MOUNTED, so\n * its state (scroll position, form input, in-flight data) is preserved across switches — only visibility\n * toggles. Full ARIA `tablist`/`tab`/`tabpanel` semantics (an inactive panel is `display:none`, which also\n * removes it from the a11y tree and tab order). No new core/router surface.\n *\n * v1 scope: web is real; screens mount eagerly and keep state (lazy mounting is a follow-up). Native\n * carries the same markup (interpretation is a host concern).\n *\n * @module\n */\n\nimport { type Component, createElement, effect, signal } from '@mindees/core'\nimport type { Router } from '@mindees/router'\nimport type { Reactive } from './host'\nimport { Pressable, Text, View } from './primitives'\nimport { flattenStyle, type StyleInput } from './style'\n\n/** One tab in a {@link createTabNavigator}. */\nexport interface TabDef {\n /** The route path this tab activates + navigates to (e.g. `/home`). */\n readonly path: string\n /** Tab-bar label (also the tab's accessible name). */\n readonly label: string\n /** The screen component shown when this tab is active. */\n readonly component: Component\n}\n\n/** Options for {@link createTabNavigator}. */\nexport interface TabNavigatorOptions {\n readonly tabs: readonly TabDef[]\n /** Tab-bar edge (default `'bottom'`). */\n readonly tabBarPosition?: 'top' | 'bottom'\n /** Extra style merged into the tab bar. */\n readonly tabBarStyle?: Reactive<StyleInput>\n}\n\nconst styleFn = (extra: Reactive<StyleInput> | undefined, base: StyleInput): (() => StyleInput) => {\n return () => flattenStyle([base, typeof extra === 'function' ? extra() : (extra ?? {})])\n}\n\n/**\n * Create a tab navigator {@link Component} bound to `router`. Render it via `createElement` (so the\n * renderer owns its reactive scope and disposes it on unmount).\n *\n * @example\n * const Tabs = createTabNavigator(router, {\n * tabs: [\n * { path: '/home', label: 'Home', component: Home },\n * { path: '/settings', label: 'Settings', component: Settings },\n * ],\n * })\n */\nexport function createTabNavigator(\n router: Router,\n options: TabNavigatorOptions,\n): Component<Record<string, never>> {\n const tabs = options.tabs\n const position = options.tabBarPosition ?? 'bottom'\n\n return () => {\n // Active tab = the LONGEST tab path that prefixes the current pathname (deep-link + nested-route aware).\n // Returns -1 when the URL belongs to NO tab — better to select/show nothing than a misleading tab 0.\n const activeIndex = (): number => {\n const path = router.location().pathname\n let best = -1\n let bestLen = -1\n tabs.forEach((t, i) => {\n if ((path === t.path || path.startsWith(`${t.path}/`)) && t.path.length > bestLen) {\n best = i\n bestLen = t.path.length\n }\n })\n return best\n }\n\n // Lazy + keep-alive (RN parity): a tab's screen mounts on its FIRST activation and stays mounted\n // thereafter, so an unvisited tab's loaders/effects never run, and a visited tab keeps its state.\n const visited = tabs.map(() => signal(false))\n effect(() => {\n visited[activeIndex()]?.set(true)\n })\n\n // One panel per tab; only the active one is shown (`display:none` also drops inactive panels from the\n // a11y tree + tab order). The screen is mounted lazily (once visited) and never unmounted.\n const panels = tabs.map((t, i) =>\n createElement(\n View,\n {\n role: 'tabpanel',\n style: () => ({\n display: activeIndex() === i ? 'flex' : 'none',\n flexDirection: 'column',\n flex: 1,\n minHeight: 0,\n }),\n },\n () => (visited[i]?.() ? createElement(t.component, {}) : null),\n ),\n )\n const panelArea = createElement(\n View,\n { style: () => ({ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }) },\n ...panels,\n )\n\n const bar = createElement(\n View,\n {\n role: 'tablist',\n style: styleFn(options.tabBarStyle, { display: 'flex', flexDirection: 'row' }),\n },\n ...tabs.map((t, i) =>\n createElement(\n Pressable,\n {\n role: 'tab',\n label: t.label,\n state: () => ({ selected: activeIndex() === i }),\n style: () => ({\n flex: 1,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n padding: 12,\n }),\n onPress: () => {\n if (activeIndex() !== i) router.navigate(t.path)\n },\n },\n createElement(Text, {}, t.label),\n ),\n ),\n )\n\n return createElement(\n View,\n { style: () => ({ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }) },\n ...(position === 'top' ? [bar, panelArea] : [panelArea, bar]),\n )\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAwCA,MAAM,WAAW,OAAyC,SAAyC;CACjG,aAAa,aAAa,CAAC,MAAM,OAAO,UAAU,aAAa,MAAM,IAAK,SAAS,CAAC,CAAE,CAAC;AACzF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,QACA,SACkC;CAClC,MAAM,OAAO,QAAQ;CACrB,MAAM,WAAW,QAAQ,kBAAkB;CAE3C,aAAa;EAGX,MAAM,oBAA4B;GAChC,MAAM,OAAO,OAAO,SAAS,EAAE;GAC/B,IAAI,OAAO;GACX,IAAI,UAAU;GACd,KAAK,SAAS,GAAG,MAAM;IACrB,KAAK,SAAS,EAAE,QAAQ,KAAK,WAAW,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,SAAS,SAAS;KACjF,OAAO;KACP,UAAU,EAAE,KAAK;IACnB;GACF,CAAC;GACD,OAAO;EACT;EAIA,MAAM,UAAU,KAAK,UAAU,OAAO,KAAK,CAAC;EAC5C,aAAa;GACX,QAAQ,YAAY,IAAI,IAAI,IAAI;EAClC,CAAC;EAmBD,MAAM,YAAY,cAChB,MACA,EAAE,cAAc;GAAE,SAAS;GAAQ,eAAe;GAAU,MAAM;GAAG,WAAW;EAAE,GAAG,GACrF,GAlBa,KAAK,KAAK,GAAG,MAC1B,cACE,MACA;GACE,MAAM;GACN,cAAc;IACZ,SAAS,YAAY,MAAM,IAAI,SAAS;IACxC,eAAe;IACf,MAAM;IACN,WAAW;GACb;EACF,SACO,QAAQ,KAAK,IAAI,cAAc,EAAE,WAAW,CAAC,CAAC,IAAI,IAC3D,CAKQ,CACV;EAEA,MAAM,MAAM,cACV,MACA;GACE,MAAM;GACN,OAAO,QAAQ,QAAQ,aAAa;IAAE,SAAS;IAAQ,eAAe;GAAM,CAAC;EAC/E,GACA,GAAG,KAAK,KAAK,GAAG,MACd,cACE,WACA;GACE,MAAM;GACN,OAAO,EAAE;GACT,cAAc,EAAE,UAAU,YAAY,MAAM,EAAE;GAC9C,cAAc;IACZ,MAAM;IACN,SAAS;IACT,YAAY;IACZ,gBAAgB;IAChB,SAAS;GACX;GACA,eAAe;IACb,IAAI,YAAY,MAAM,GAAG,OAAO,SAAS,EAAE,IAAI;GACjD;EACF,GACA,cAAc,MAAM,CAAC,GAAG,EAAE,KAAK,CACjC,CACF,CACF;EAEA,OAAO,cACL,MACA,EAAE,cAAc;GAAE,SAAS;GAAQ,eAAe;GAAU,MAAM;GAAG,WAAW;EAAE,GAAG,GACrF,GAAI,aAAa,QAAQ,CAAC,KAAK,SAAS,IAAI,CAAC,WAAW,GAAG,CAC7D;CACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindees/atlas",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "description": "MindeesNative Atlas - accessible, signals-native UI primitives + a virtualized recycling list. Renderer-agnostic (web real, native research track).",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -42,12 +42,12 @@
42
42
  "directory": "packages/atlas"
43
43
  },
44
44
  "dependencies": {
45
- "@mindees/core": "0.30.1",
46
- "@mindees/router": "0.30.1"
45
+ "@mindees/core": "0.30.2",
46
+ "@mindees/router": "0.30.2"
47
47
  },
48
48
  "devDependencies": {
49
49
  "happy-dom": "20.9.0",
50
- "@mindees/renderer": "0.30.1"
50
+ "@mindees/renderer": "0.30.2"
51
51
  },
52
52
  "scripts": {
53
53
  "build": "tsdown",