@ultimat3/ui 2.0.0 → 3.0.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.
package/src/roving.ts ADDED
@@ -0,0 +1,65 @@
1
+ // The pure rules of a roving-tabindex group: which items it navigates, which one holds the
2
+ // group's single tab stop, and which elements answer arrow keys themselves. Pure so the rules are
3
+ // testable with no renderer — the package's `*-view.ts` convention, applied to keyboard behaviour.
4
+
5
+ /**
6
+ * A roving group queries its OWN items rather than reusing `FOCUSABLE_SELECTOR`, and both
7
+ * selectors end in `:not([disabled])` for the same reason that one does: `focus()` on a disabled
8
+ * control is a NO-OP. A disabled item left in the list pins the reducer on its index forever —
9
+ * every press recomputes the same unreachable index — so everything after it is unreachable by
10
+ * keyboard, permanently.
11
+ */
12
+ export const MENU_ITEM_SELECTOR = '[role="menuitem"]:not([disabled])';
13
+ export const TAB_SELECTOR = '[role="tab"]:not([disabled])';
14
+
15
+ /** The only thing the tab-stop rule needs to know about an item. */
16
+ export interface RovingItem {
17
+ readonly disabled?: boolean | undefined;
18
+ }
19
+
20
+ /**
21
+ * The one index that carries `tabindex="0"`. `selected` is the caller's chosen index, `-1` for a
22
+ * group with no selection; a disabled or out-of-range selection falls back to the first ENABLED
23
+ * item, because a group whose only tab stop is disabled cannot be entered at all. Answers `-1`
24
+ * when every item is disabled — then nothing is tabbable, which is the correct answer.
25
+ */
26
+ export function tabStopIndex(items: readonly RovingItem[], selected = -1): number {
27
+ const chosen = items[selected];
28
+ if (selected >= 0 && chosen !== undefined && chosen.disabled !== true) return selected;
29
+ return items.findIndex((item) => item.disabled !== true);
30
+ }
31
+
32
+ /** Structural on purpose: a real `Element` satisfies it, and a test needs no DOM to build one. */
33
+ export interface ArrowKeyElement {
34
+ readonly tagName: string;
35
+ getAttribute(name: string): string | null;
36
+ }
37
+
38
+ // Input types with no arrow-key behaviour of their own. Everything else — text, search, number,
39
+ // date, range, radio — moves a caret, a value or a selection when an arrow is pressed.
40
+ const ARROW_INERT_INPUT_TYPES: ReadonlySet<string> = new Set([
41
+ 'button',
42
+ 'checkbox',
43
+ 'file',
44
+ 'hidden',
45
+ 'image',
46
+ 'reset',
47
+ 'submit',
48
+ ]);
49
+
50
+ /**
51
+ * Does this element already answer arrow keys itself? A roving group must never `preventDefault()`
52
+ * on one that does. `Toolbar`'s documented purpose is search and filters at the inline start, and
53
+ * swallowing ArrowRight from that `Input` eats the keystroke that was moving the caret.
54
+ */
55
+ export function handlesOwnArrowKeys(element: ArrowKeyElement | null | undefined): boolean {
56
+ if (element === null || element === undefined) return false;
57
+ const tag = element.tagName.toLowerCase();
58
+ if (tag === 'textarea' || tag === 'select') return true;
59
+ if (tag === 'input') {
60
+ const type = element.getAttribute('type') ?? 'text';
61
+ return !ARROW_INERT_INPUT_TYPES.has(type.toLowerCase());
62
+ }
63
+ const editable = element.getAttribute('contenteditable');
64
+ return editable !== null && editable !== 'false';
65
+ }
@@ -127,6 +127,13 @@ table {
127
127
  color: t.role('fg-strong');
128
128
  }
129
129
 
130
+ // Body scroll lock behind a modal surface (Dialog, Drawer). `:modal` matches only a <dialog>
131
+ // opened with showModal(), so a non-modal one still lets the page scroll behind it. One CSS rule
132
+ // rather than JS: a lock a script installs is a lock that leaks when the close path throws.
133
+ html:has(dialog:modal) {
134
+ overflow: hidden;
135
+ }
136
+
130
137
  // Global motion guard. Placed last so it wins over every component rule.
131
138
  @media (prefers-reduced-motion: reduce) {
132
139
  html {