@joewinke/jatui 0.55.0 → 0.57.0

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.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Dialog focus resolution (jatui-fo6o8)
3
+ *
4
+ * Shared rule for every surface that opens an overlay dialog/panel
5
+ * (Modal, Drawer, BottomSheet, Confirm*, InputDialog): on open, focus in
6
+ * this order —
7
+ *
8
+ * 1. an element with [data-autofocus] or the `autofocus` attribute inside
9
+ * the container (a consumer's explicit opt-in);
10
+ * 2. else the first ENABLED, RENDERED text-entry control in the body
11
+ * (not the header close button — buttons never qualify anyway): a
12
+ * text-like <input>, an enabled <textarea>, or a contenteditable
13
+ * element;
14
+ * 3. else the container element itself (today's behaviour — keeps Escape
15
+ * working for read-only / confirm dialogs, see Modal's Escape note).
16
+ *
17
+ * "Rendered" matters: a focus() on an element that is not actually visible
18
+ * (display:none, inside a collapsed <details>, a [hidden]/[inert] ancestor,
19
+ * or visibility:hidden) is a silent no-op that leaves focus on <body>, which
20
+ * breaks the dialog's own Escape handling (Modal owns Escape on the dialog
21
+ * element). Unrendered candidates are skipped, and after focusing the target
22
+ * is verified to actually hold focus — else the fallback/container is
23
+ * focused instead.
24
+ *
25
+ * WAI-ARIA APG dialog pattern: focus the first field the user is expected
26
+ * to fill. `initialFocus` overrides the whole order —
27
+ *
28
+ * - `HTMLElement` / `string` (a CSS selector, resolved inside the
29
+ * container): focus exactly that; falls through to the default order
30
+ * when the selector matches nothing.
31
+ * - `'dialog'`: always focus the container (old behaviour — right for
32
+ * read-only / confirm dialogs).
33
+ * - `'first-input'` (default): the 1→2→3 order above.
34
+ */
35
+ const TEXT_INPUT_TYPES = new Set([
36
+ 'text',
37
+ 'search',
38
+ 'url',
39
+ 'tel',
40
+ 'email',
41
+ 'password',
42
+ 'number',
43
+ '',
44
+ undefined
45
+ ]);
46
+ /** True for an element a user can type into: text-like input, enabled textarea, contenteditable. */
47
+ export function isTextEntryControl(el) {
48
+ if (el instanceof HTMLTextAreaElement) {
49
+ if (el.disabled || el.readOnly)
50
+ return false;
51
+ return true;
52
+ }
53
+ if (el instanceof HTMLInputElement) {
54
+ if (el.disabled || el.readOnly)
55
+ return false;
56
+ return TEXT_INPUT_TYPES.has(el.getAttribute('type') ?? undefined);
57
+ }
58
+ if (el.getAttribute?.('contenteditable') === 'true' || el.isContentEditable)
59
+ return true;
60
+ return false;
61
+ }
62
+ /** True when the element is not actually rendered, so focusing it is a
63
+ * silent no-op. Checks that cannot be evaluated (non-DOM test fakes) are
64
+ * treated as rendered. */
65
+ function isUnrendered(el) {
66
+ if (typeof el.getClientRects === 'function' && el.getClientRects().length === 0)
67
+ return true;
68
+ if (typeof el.closest === 'function' && el.closest('[hidden], [inert]'))
69
+ return true;
70
+ if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
71
+ if (window.getComputedStyle(el).visibility === 'hidden')
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ /** Enabled + attribute-level enabled (not disabled/aria-disabled/hidden). */
77
+ function isEnabled(el) {
78
+ if (el.hasAttribute('disabled'))
79
+ return false;
80
+ if (el.getAttribute('aria-disabled') === 'true')
81
+ return false;
82
+ if (el.hasAttribute('hidden'))
83
+ return false;
84
+ return true;
85
+ }
86
+ /**
87
+ * Resolve the element a dialog should focus when it opens, per the order
88
+ * above. Returns null when nothing matches and there is no fallback.
89
+ */
90
+ export function resolveDialogFocusTarget(container, opts = {}) {
91
+ if (!container)
92
+ return null;
93
+ const { initialFocus: initialFocusProp = 'first-input', fallback } = opts;
94
+ let initialFocus = initialFocusProp;
95
+ // Explicit 'dialog' — the old behaviour, verbatim.
96
+ if (initialFocus === 'dialog')
97
+ return container;
98
+ // Explicit element.
99
+ if (initialFocus instanceof HTMLElement)
100
+ return initialFocus;
101
+ // Explicit selector (any string other than the 'first-input' sentinel),
102
+ // resolved INSIDE the container. A miss falls through
103
+ // to the default first-input order (a consumer selector targeting a
104
+ // conditionally-rendered field shouldn't strand focus on the dialog).
105
+ if (typeof initialFocus === 'string' && initialFocus !== 'first-input') {
106
+ const found = container.querySelector(initialFocus);
107
+ if (found)
108
+ return found;
109
+ initialFocus = 'first-input';
110
+ }
111
+ // null opts out of auto-focus entirely (Drawer/BottomSheet): return only
112
+ // the explicit fallback, never the container.
113
+ if (initialFocus === null)
114
+ return fallback ?? null;
115
+ if (initialFocus !== 'first-input' && initialFocus != null)
116
+ return null;
117
+ // 1. Consumer's explicit autofocus opt-ins.
118
+ const autofocused = container.querySelector('[data-autofocus], [autofocus]:not(input[autofocus][type="hidden"])');
119
+ if (autofocused && isEnabled(autofocused) && !isUnrendered(autofocused))
120
+ return autofocused;
121
+ // 2. First enabled, rendered text-entry control in the body. Buttons
122
+ // (including the header close ✕) never qualify, so the header is safe
123
+ // to include.
124
+ const candidates = container.querySelectorAll('input, textarea, [contenteditable="true"], [contenteditable=""]');
125
+ for (const el of candidates) {
126
+ if (isEnabled(el) && !isUnrendered(el) && isTextEntryControl(el))
127
+ return el;
128
+ }
129
+ // 3. The dialog/panel itself.
130
+ return fallback ?? container;
131
+ }
132
+ /**
133
+ * Focus a resolved target on the next frame (after the open transition has
134
+ * mounted the content). Mirrors the previous `requestAnimationFrame` timing
135
+ * so focus lands on real DOM, not a mounting tree.
136
+ */
137
+ export function focusDialogTarget(container, opts = {}, rq = requestAnimationFrame.bind(window)) {
138
+ rq(() => {
139
+ const target = resolveDialogFocusTarget(container, opts);
140
+ if (!target)
141
+ return;
142
+ target.focus();
143
+ // Verify the focus actually landed — a focus() on an unrendered or
144
+ // otherwise unfocusable element is a silent no-op that leaves focus on
145
+ // <body>, which breaks the dialog's own Escape handling (Modal owns
146
+ // Escape on the dialog element). Fall back to the container.
147
+ if (typeof document !== 'undefined' && document.activeElement !== target) {
148
+ const fallbackEl = opts.fallback ?? container;
149
+ if (fallbackEl)
150
+ fallbackEl.focus();
151
+ }
152
+ });
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",