@junoput01/junoui 0.7.0 → 0.8.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,95 @@
1
+ // ════════════════════════════════════════════════════════════════════════
2
+ // junoui — the tappable surfaces, declared once
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // THE source of truth for "this is one of junoui's own tappable components".
5
+ // scripts/bundle-css.mjs generates the touch-default rules from it, so the
6
+ // class names exist in exactly one place and a member cannot be misspelled
7
+ // into silence.
8
+ //
9
+ // WHY THIS FILE EXISTS. base.css used to carry two hand-maintained `:where()`
10
+ // lists, and both had drifted:
11
+ //
12
+ // - from the CLASSES: `.juno-seg__option` (the shipped class is
13
+ // `.juno-seg__opt`) and `.juno-list__item` (it is `.juno-list__row`) sat
14
+ // in them. `:where()` matched nothing, the rule still parsed, every other
15
+ // member kept working — so every segmented control and every grouped list
16
+ // row in every consumer kept the ~300ms double-tap delay. Invisible to
17
+ // lint, to the build and to a screenshot (20260826-024).
18
+ // - from EACH OTHER: the tap-highlight list was a strict subset of the
19
+ // touch-action one, missing __overflow, __opt, chip and toggle-btn, with
20
+ // nothing recording whether that was a decision.
21
+ //
22
+ // ONE SET, NOT TWO — the open question from docs/conformance-kit.md, decided
23
+ // on the rules' own rationales rather than by merging them for tidiness. The
24
+ // tap-highlight rule exists so a UA square "never flashes past a rounded
25
+ // control on tap"; every name the shorter list omitted is a rounded tappable
26
+ // (`.juno-chip` and `.juno-pillbar__overflow` are 999px pills,
27
+ // `.juno-seg__opt` and `.juno-toggle-btn` carry radius-3). The omission has no
28
+ // stated reason and the rationale covers them, so it was an omission and not a
29
+ // decision. Both properties answer the same question — "is this one of ours,
30
+ // and is it tapped?" — and now read the same answer.
31
+ //
32
+ // Adding a component here is the whole opt-in: state the class once, get both
33
+ // defaults. test/classes.test.mjs asserts every member is a class some
34
+ // component file actually defines, so a typo fails the build rather than
35
+ // going quiet.
36
+ // ════════════════════════════════════════════════════════════════════════
37
+
38
+ export const TOUCH_SURFACES = [
39
+ 'juno-btn',
40
+ 'juno-chip',
41
+ 'juno-dock__item',
42
+ 'juno-list__row',
43
+ 'juno-menu__item',
44
+ 'juno-pillbar__item',
45
+ 'juno-pillbar__overflow',
46
+ 'juno-seg__opt',
47
+ 'juno-tabs__tab',
48
+ 'juno-toggle-btn',
49
+ 'juno-tree__row',
50
+ 'juno-gizmo__mark',
51
+ 'juno-gizmo__center',
52
+ 'juno-swatch--button',
53
+ 'juno-palette__option',
54
+ ];
55
+
56
+ /** The `:where()` selector list, formatted for the bundle. */
57
+ export function whereList(indent = '') {
58
+ return `:where(\n${TOUCH_SURFACES.map((c) => `${indent} .${c}`).join(',\n')}\n${indent})`;
59
+ }
60
+
61
+ /** The generated touch-default layer.
62
+ *
63
+ * Two rules, one member list. `touch-action` is NOT inside the coarse block:
64
+ * a hybrid device (touch laptop, iPad with a trackpad) reports a fine primary
65
+ * pointer while still taking touch input, and the property is inert on a
66
+ * mouse anyway. The tap highlight only exists on touch, so it is.
67
+ *
68
+ * `:where()` contributes zero specificity by design — these are defaults a
69
+ * component or a consumer overrides by simply declaring the property. */
70
+ export function touchDefaultsCss() {
71
+ return `/* GENERATED from src/css/touch-surfaces.mjs — do not edit here.
72
+ *
73
+ * Tappable primitives opt out of double-tap-to-zoom. A browser that still
74
+ * recognises that gesture has to WAIT after the first tap to see whether a
75
+ * second one is coming, which reads as a late, mushy tap on exactly the
76
+ * surfaces a phone UI is built from. \`manipulation\` keeps panning and
77
+ * pinch-zoom (so the page stays zoomable — never \`none\` here, that would be an
78
+ * a11y regression) and drops only the double-tap.
79
+ * Community convention — no primary Apple/WebKit source names it; see
80
+ * docs/ios-conformance.md. Named components only, so a consumer's own elements
81
+ * are untouched. See 20260803-038. */
82
+ ${whereList()} {
83
+ touch-action: manipulation;
84
+ }
85
+
86
+ /* And on touch, kill the UA tap-highlight square so it never flashes past a
87
+ * rounded control. Consumers were adding this by hand per component; it is a
88
+ * first-class touch default. See 20260802-020. */
89
+ @media (pointer: coarse) {
90
+ ${whereList(' ')} {
91
+ -webkit-tap-highlight-color: transparent;
92
+ }
93
+ }
94
+ `;
95
+ }
@@ -0,0 +1,144 @@
1
+ // ════════════════════════════════════════════════════════════════════════
2
+ // junoui/gizmo — bearing language and keyboard traversal for .juno-gizmo
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // Two things a stylesheet cannot do, and both are the reason the component
5
+ // belongs upstream rather than in each app:
6
+ //
7
+ // 1. SAYING THE BEARING. "N" is a letter, not an accessible name, and
8
+ // "37deg" is a number a screen-reader user has to convert. `bearingLabel`
9
+ // turns an angle into the words a person would use.
10
+ // 2. ONE FOCUS STOP. The gizmo is a composite widget: Tab reaches it once,
11
+ // arrow keys move between marks (wrapping, because a compass ring wraps),
12
+ // Enter activates. Eight separate tab stops for eight compass points is
13
+ // the thing apps ship and the thing that makes the widget unusable by
14
+ // keyboard.
15
+ //
16
+ // Stateless, like junoui/tree: the app owns the camera. This moves focus and
17
+ // lets the marks' own click handlers fire; it never writes an angle.
18
+ // ════════════════════════════════════════════════════════════════════════
19
+
20
+ const MARK = '.juno-gizmo__mark';
21
+
22
+ /** The 16 compass points, as words rather than letters. */
23
+ const POINTS = [
24
+ 'north',
25
+ 'north-north-east',
26
+ 'north-east',
27
+ 'east-north-east',
28
+ 'east',
29
+ 'east-south-east',
30
+ 'south-east',
31
+ 'south-south-east',
32
+ 'south',
33
+ 'south-south-west',
34
+ 'south-west',
35
+ 'west-south-west',
36
+ 'west',
37
+ 'west-north-west',
38
+ 'north-west',
39
+ 'north-north-west',
40
+ ];
41
+
42
+ /** Normalise any angle into [0, 360). */
43
+ export const normalizeBearing = (deg) => ((Number(deg) % 360) + 360) % 360;
44
+
45
+ /**
46
+ * A bearing as a person would say it: `37` → `"north-east"`.
47
+ *
48
+ * Sixteen points, nearest wins, and the boundaries are half a sector wide so
49
+ * 348.75..360 and 0..11.25 are both "north" — a naive `Math.round(deg / 22.5)`
50
+ * yields index 16 near 360 and reads off the end of the table.
51
+ */
52
+ export function bearingLabel(deg) {
53
+ const d = normalizeBearing(deg);
54
+ return POINTS[Math.round(d / 22.5) % 16];
55
+ }
56
+
57
+ /**
58
+ * The full spoken description: direction, degrees, and the tilt when there is
59
+ * one. This is what goes in the live region — the ring itself is decoration to
60
+ * a screen reader, and a rotating needle announces nothing.
61
+ */
62
+ export function orientationLabel(heading, pitch = null) {
63
+ const d = Math.round(normalizeBearing(heading));
64
+ const base = `Facing ${bearingLabel(d)}, ${d} degrees`;
65
+ return pitch === null || pitch === undefined
66
+ ? `${base}.`
67
+ : `${base}. Tilted ${Math.round(Number(pitch))} degrees.`;
68
+ }
69
+
70
+ /**
71
+ * Wire the gizmo as one focus stop. Returns a teardown function.
72
+ *
73
+ * Idempotent — enhancing twice replaces the first listener rather than
74
+ * stacking two, so a framework re-running an effect does not double-move focus
75
+ * on every keypress.
76
+ */
77
+ export function enhanceGizmo(root) {
78
+ if (!root) throw new Error('enhanceGizmo: no root element');
79
+ root._junoGizmoTeardown?.();
80
+
81
+ const marks = () => [...root.querySelectorAll(MARK)].filter((m) => !m.disabled);
82
+
83
+ // Seed the roving tabindex: the mark the app marks current, else the first.
84
+ const seed = () => {
85
+ const list = marks();
86
+ if (!list.length) return;
87
+ for (const m of list) m.tabIndex = -1;
88
+ (list.find((m) => m.getAttribute('aria-current') === 'true') ?? list[0]).tabIndex = 0;
89
+ };
90
+ seed();
91
+
92
+ const onKeyDown = (event) => {
93
+ const list = marks();
94
+ const at = list.indexOf(event.target.closest(MARK));
95
+ if (at < 0) return;
96
+
97
+ // WRAPPING, because a compass ring wraps. Clamping at the ends is the
98
+ // behaviour of a slider, and it would make west unreachable from north by
99
+ // the short way round.
100
+ const move = (next) => {
101
+ event.preventDefault();
102
+ const target = list[(next + list.length) % list.length];
103
+ for (const m of list) m.tabIndex = -1;
104
+ target.tabIndex = 0;
105
+ target.focus();
106
+ };
107
+
108
+ switch (event.key) {
109
+ case 'ArrowRight':
110
+ case 'ArrowDown':
111
+ return move(at + 1);
112
+ case 'ArrowLeft':
113
+ case 'ArrowUp':
114
+ return move(at - 1);
115
+ case 'Home':
116
+ return move(0);
117
+ case 'End':
118
+ return move(list.length - 1);
119
+ default:
120
+ // Enter and Space are the button's own; intercepting them would mean
121
+ // reimplementing activation, and a <button> already does it right.
122
+ return;
123
+ }
124
+ };
125
+
126
+ const onFocusIn = (event) => {
127
+ const mark = event.target.closest(MARK);
128
+ if (!mark || !root.contains(mark)) return;
129
+ // The pointer path: a click focuses a mark without passing through the
130
+ // keyboard move above, and the invariant has to hold for both.
131
+ for (const m of marks()) m.tabIndex = -1;
132
+ mark.tabIndex = 0;
133
+ };
134
+
135
+ root.addEventListener('keydown', onKeyDown);
136
+ root.addEventListener('focusin', onFocusIn);
137
+ const teardown = () => {
138
+ root.removeEventListener('keydown', onKeyDown);
139
+ root.removeEventListener('focusin', onFocusIn);
140
+ delete root._junoGizmoTeardown;
141
+ };
142
+ root._junoGizmoTeardown = teardown;
143
+ return teardown;
144
+ }
package/tools/tree.mjs ADDED
@@ -0,0 +1,178 @@
1
+ // ════════════════════════════════════════════════════════════════════════
2
+ // junoui/tree — keyboard traversal for .juno-tree
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // A tree without arrow-key traversal and a roving tabindex is a list of
5
+ // buttons wearing tree roles. That part is behaviour, so no stylesheet can
6
+ // ship it — and it is also the part apps get wrong, so junoui ships it
7
+ // rather than only describing it.
8
+ //
9
+ // import { enhanceTree } from 'junoui/tree';
10
+ // const stop = enhanceTree(document.querySelector('.juno-tree'));
11
+ //
12
+ // STATELESS, which is what keeps it inside junoui's "no stateful widgets"
13
+ // line: it stores nothing. Expansion lives in `aria-expanded` and selection
14
+ // in `aria-selected`, both on the DOM, both owned by your app — this reads
15
+ // them, moves focus, and asks you to change them by dispatching events.
16
+ // Unmount and remount the tree and nothing here needs to know.
17
+ //
18
+ // WHAT IT DOES NOT DO: it does not expand, collapse, select or reorder. It
19
+ // dispatches `juno-tree-toggle` and `juno-tree-select` (bubbling, cancelable,
20
+ // `detail.item`) and leaves the decision to you, because in a real outliner
21
+ // expanding a node may need to load it.
22
+ //
23
+ // WAI-ARIA Authoring Practices, Tree View pattern. See
24
+ // docs/components/tree.md for the full contract and the markup.
25
+ // ════════════════════════════════════════════════════════════════════════
26
+
27
+ const ITEM = '[role="treeitem"]';
28
+
29
+ /** Is this item's subtree currently shown? Absent aria-expanded = a leaf. */
30
+ const isExpanded = (el) => el.getAttribute('aria-expanded') === 'true';
31
+ const isBranch = (el) => el.hasAttribute('aria-expanded');
32
+
33
+ /** Every treeitem the user can currently reach, in visual order.
34
+ *
35
+ * Derived from the DOM each time rather than cached: a tree's shape changes
36
+ * under it (lazy children, filtering, a reorder), and a cached list is a
37
+ * stale list the moment it does. Cheap — this only runs on a keypress. */
38
+ function visibleItems(root) {
39
+ return [...root.querySelectorAll(ITEM)].filter((el) => {
40
+ for (let p = el.parentElement?.closest(ITEM); p; p = p.parentElement?.closest(ITEM)) {
41
+ if (!isExpanded(p)) return false;
42
+ }
43
+ return true;
44
+ });
45
+ }
46
+
47
+ /** The row inside an item — the thing that actually takes focus. */
48
+ const rowOf = (item) => item.querySelector(':scope > .juno-tree__row') ?? item;
49
+
50
+ /** Move the roving tabindex, and focus. Exactly one row is tabbable at a
51
+ * time; that is the difference between one Tab stop and one per node.
52
+ *
53
+ * The reset here and the one in `onFocusIn` are REDUNDANT ON PURPOSE, and
54
+ * mutation testing is what turned that into a decision: removing either alone
55
+ * leaves the invariant holding, because `row.focus()` below fires focusin.
56
+ * They cover different entrances — this one the keyboard, that one a pointer
57
+ * click, which never passes through here — so neither is dead, and a single
58
+ * edit to either cannot leave two rows tabbable. */
59
+ function focusItem(root, item) {
60
+ for (const other of root.querySelectorAll(ITEM)) rowOf(other).tabIndex = -1;
61
+ const row = rowOf(item);
62
+ row.tabIndex = 0;
63
+ row.focus();
64
+ }
65
+
66
+ const ask = (item, type) =>
67
+ item.dispatchEvent(new CustomEvent(type, { bubbles: true, cancelable: true, detail: { item } }));
68
+
69
+ /**
70
+ * Wire keyboard traversal on a `.juno-tree`. Returns a teardown function.
71
+ *
72
+ * Idempotent: enhancing the same root twice replaces the first listener
73
+ * rather than stacking two, so a framework that re-runs an effect does not
74
+ * double-fire every keypress.
75
+ */
76
+ export function enhanceTree(root) {
77
+ if (!root) throw new Error('enhanceTree: no root element');
78
+ root._junoTreeTeardown?.();
79
+
80
+ // Seed the roving tabindex: the selected item if there is one, else the
81
+ // first. Without this every row is tabbable (0) or none is (-1), and both
82
+ // are wrong.
83
+ const items = visibleItems(root);
84
+ if (items.length) {
85
+ for (const el of root.querySelectorAll(ITEM)) rowOf(el).tabIndex = -1;
86
+ const seed = items.find((el) => el.getAttribute('aria-selected') === 'true') ?? items[0];
87
+ rowOf(seed).tabIndex = 0;
88
+ }
89
+
90
+ const onKeyDown = (event) => {
91
+ const item = event.target.closest(ITEM);
92
+ if (!item || !root.contains(item)) return;
93
+
94
+ const list = visibleItems(root);
95
+ const at = list.indexOf(item);
96
+ const go = (next) => {
97
+ if (!next) return;
98
+ event.preventDefault();
99
+ focusItem(root, next);
100
+ };
101
+
102
+ switch (event.key) {
103
+ case 'ArrowDown':
104
+ return go(list[at + 1]);
105
+ case 'ArrowUp':
106
+ return go(list[at - 1]);
107
+
108
+ case 'ArrowRight':
109
+ // Closed branch → open it. Open branch → into its first child. Leaf →
110
+ // nothing, which is the pattern's answer and not an oversight.
111
+ if (isBranch(item) && !isExpanded(item)) {
112
+ event.preventDefault();
113
+ ask(item, 'juno-tree-toggle');
114
+ } else if (isExpanded(item)) {
115
+ go(list[at + 1]);
116
+ }
117
+ return;
118
+
119
+ case 'ArrowLeft': {
120
+ // Open branch → close it. Anything else → out to the parent. This is
121
+ // what makes a deep tree navigable without a mouse.
122
+ if (isBranch(item) && isExpanded(item)) {
123
+ event.preventDefault();
124
+ ask(item, 'juno-tree-toggle');
125
+ return;
126
+ }
127
+ return go(item.parentElement?.closest(ITEM));
128
+ }
129
+
130
+ case 'Home':
131
+ return go(list[0]);
132
+ case 'End':
133
+ return go(list[list.length - 1]);
134
+
135
+ case 'Enter':
136
+ event.preventDefault();
137
+ return void ask(item, 'juno-tree-select');
138
+
139
+ case ' ':
140
+ // Space selects; it must not scroll the page out from under the tree.
141
+ event.preventDefault();
142
+ return void ask(item, 'juno-tree-select');
143
+
144
+ case '*':
145
+ // Expand every sibling at this level — the pattern's one bulk action.
146
+ event.preventDefault();
147
+ for (const sib of item.parentElement?.children ?? []) {
148
+ if (sib.matches?.(ITEM) && isBranch(sib) && !isExpanded(sib)) {
149
+ ask(sib, 'juno-tree-toggle');
150
+ }
151
+ }
152
+ return;
153
+
154
+ default:
155
+ return;
156
+ }
157
+ };
158
+
159
+ // Clicking a row moves the roving tabindex there too, or the next arrow key
160
+ // jumps back to wherever focus notionally was.
161
+ const onFocusIn = (event) => {
162
+ const item = event.target.closest(ITEM);
163
+ if (item && root.contains(item)) {
164
+ for (const other of root.querySelectorAll(ITEM)) rowOf(other).tabIndex = -1;
165
+ rowOf(item).tabIndex = 0;
166
+ }
167
+ };
168
+
169
+ root.addEventListener('keydown', onKeyDown);
170
+ root.addEventListener('focusin', onFocusIn);
171
+ const teardown = () => {
172
+ root.removeEventListener('keydown', onKeyDown);
173
+ root.removeEventListener('focusin', onFocusIn);
174
+ delete root._junoTreeTeardown;
175
+ };
176
+ root._junoTreeTeardown = teardown;
177
+ return teardown;
178
+ }