@junoput01/junoui 0.6.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.
- package/CHANGELOG.md +209 -0
- package/README.md +17 -16
- package/dist/android/dimens.xml +2 -0
- package/dist/classes.json +1774 -0
- package/dist/css/juno-tokens.css +10 -0
- package/dist/css/juno.css +1057 -56
- package/dist/flutter/juno_tokens.dart +10 -0
- package/dist/ios/JunoTokens.swift +10 -0
- package/dist/js/tokens.js +16 -0
- package/dist/json/tokens.json +51 -0
- package/dist/rust/juno_tokens.rs +383 -0
- package/dist/scss/_juno-tokens.scss +10 -0
- package/docs/accessibility.md +6 -0
- package/docs/browser-support.md +3 -0
- package/docs/components/button.md +11 -2
- package/docs/components/canvas-ink.md +71 -0
- package/docs/components/dock.md +34 -0
- package/docs/components/fold-slot.md +26 -1
- package/docs/components/gizmo.md +114 -0
- package/docs/components/swatch.md +95 -0
- package/docs/components/tree.md +112 -0
- package/docs/conformance-kit.md +255 -0
- package/docs/getting-started.md +14 -0
- package/docs/integration.md +52 -6
- package/docs/ios-conformance.md +200 -3
- package/docs/ios-pwa.md +273 -0
- package/docs/native.md +38 -1
- package/docs/tokens-reference.md +15 -0
- package/package.json +8 -2
- package/src/css/base.css +35 -43
- package/src/css/components/button.css +42 -2
- package/src/css/components/canvas-ink.css +97 -0
- package/src/css/components/dock.css +65 -6
- package/src/css/components/fold-slot.css +49 -3
- package/src/css/components/gizmo.css +238 -0
- package/src/css/components/segmented.css +15 -2
- package/src/css/components/swatch.css +187 -0
- package/src/css/components/tree.css +259 -0
- package/src/css/touch-surfaces.mjs +95 -0
- package/tools/gizmo.mjs +144 -0
- package/tools/testing.mjs +177 -0
- package/tools/tree.mjs +178 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
2
|
+
* Component — Tree / outliner (nested rows, disclosure, selection)
|
|
3
|
+
* Layer stacks, file browsers, settings trees, org charts, comment
|
|
4
|
+
* threads, nested navigation. Where .juno-list is flat and
|
|
5
|
+
* .juno-accordion is single-level, this nests to arbitrary depth and
|
|
6
|
+
* carries a selection.
|
|
7
|
+
*
|
|
8
|
+
* ZERO JS FOR THE VISUALS. Indentation comes from the nested `role=group`
|
|
9
|
+
* lists the ARIA pattern already requires, so depth costs no custom
|
|
10
|
+
* property and no inline style; collapse is `[aria-expanded='false']`
|
|
11
|
+
* hiding the child group. The app owns the attribute, exactly like
|
|
12
|
+
* __item's aria-pressed elsewhere in junoui.
|
|
13
|
+
*
|
|
14
|
+
* KEYBOARD IS NOT OPTIONAL and is not CSS. A tree without arrow-key
|
|
15
|
+
* traversal and a roving tabindex is a list of buttons wearing tree
|
|
16
|
+
* roles. junoui ships the contract in docs/components/tree.md and a
|
|
17
|
+
* stateless enhancer at `junoui/tree` — use one or the other, but a tree
|
|
18
|
+
* that has neither is not accessible and no stylesheet can fix it.
|
|
19
|
+
*
|
|
20
|
+
* Usage (the markup the roles require — see the doc for the full ARIA
|
|
21
|
+
* contract, which is the half apps get wrong):
|
|
22
|
+
* <ul class="juno-tree" role="tree" aria-label="Scene">
|
|
23
|
+
* <li class="juno-tree__item" role="treeitem" aria-level="1"
|
|
24
|
+
* aria-expanded="true" aria-selected="false">
|
|
25
|
+
* <div class="juno-tree__row">
|
|
26
|
+
* <button class="juno-tree__caret" tabindex="-1" aria-hidden="true"></button>
|
|
27
|
+
* <svg class="juno-icon juno-tree__icon" aria-hidden="true">…</svg>
|
|
28
|
+
* <span class="juno-tree__label">Terrain</span>
|
|
29
|
+
* <span class="juno-tree__count">12</span>
|
|
30
|
+
* <span class="juno-tree__trail">…</span>
|
|
31
|
+
* <button class="juno-tree__handle" aria-label="Reorder Terrain"></button>
|
|
32
|
+
* </div>
|
|
33
|
+
* <ul class="juno-tree__group" role="group">…</ul>
|
|
34
|
+
* </li>
|
|
35
|
+
* </ul>
|
|
36
|
+
* ════════════════════════════════════════════════════════════════════ */
|
|
37
|
+
|
|
38
|
+
.juno-tree {
|
|
39
|
+
--juno-role: var(--juno-active);
|
|
40
|
+
|
|
41
|
+
/* One indent step. Applied per nested group, so depth is structural and
|
|
42
|
+
arbitrary rather than a level number someone has to keep in sync. */
|
|
43
|
+
--juno-tree-indent: var(--juno-space-16);
|
|
44
|
+
|
|
45
|
+
margin: 0;
|
|
46
|
+
padding: 0;
|
|
47
|
+
list-style: none;
|
|
48
|
+
font-family: var(--juno-font-family-sans);
|
|
49
|
+
font-size: var(--juno-font-size-13);
|
|
50
|
+
color: var(--juno-data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.juno-tree__group {
|
|
54
|
+
margin: 0;
|
|
55
|
+
padding: 0;
|
|
56
|
+
padding-inline-start: var(--juno-tree-indent);
|
|
57
|
+
list-style: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Collapsed: the child group goes. `display: none` and not a height
|
|
61
|
+
animation — a tree collapses an unknown number of rows at an unknown
|
|
62
|
+
depth, so there is no end value to transition to, and interpolate-size
|
|
63
|
+
is not available everywhere junoui targets. */
|
|
64
|
+
.juno-tree__item[aria-expanded='false'] > .juno-tree__group {
|
|
65
|
+
display: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.juno-tree__row {
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: var(--juno-space-8);
|
|
72
|
+
inline-size: 100%;
|
|
73
|
+
min-block-size: var(--juno-size-tap-min);
|
|
74
|
+
padding-inline: var(--juno-space-8);
|
|
75
|
+
padding-block: var(--juno-space-4);
|
|
76
|
+
border: none;
|
|
77
|
+
border-radius: var(--juno-radius-3);
|
|
78
|
+
background: transparent;
|
|
79
|
+
color: inherit;
|
|
80
|
+
font: inherit;
|
|
81
|
+
text-align: start;
|
|
82
|
+
text-decoration: none;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.juno-tree__row:hover {
|
|
87
|
+
background: var(--juno-s2);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* SELECTION is a different fact from hover and from aria-current, and the
|
|
91
|
+
ticket is right that conflating them is the common bug: hover is where the
|
|
92
|
+
pointer is, aria-current is which page you are on, aria-selected is what
|
|
93
|
+
the next action will apply to. A layer stack has all three at once. */
|
|
94
|
+
.juno-tree__item[aria-selected='true'] > .juno-tree__row {
|
|
95
|
+
background: var(--juno-s3);
|
|
96
|
+
color: var(--juno-role);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.juno-tree__item[aria-current] > .juno-tree__row {
|
|
100
|
+
box-shadow: inset var(--juno-border-width-2) 0 0 var(--juno-role);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.juno-tree__row:focus-visible {
|
|
104
|
+
outline: var(--juno-border-width-2) solid var(--juno-active);
|
|
105
|
+
outline-offset: calc(-1 * var(--juno-border-width-2));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ── disclosure caret ──────────────────────────────────────────────────
|
|
109
|
+
aria-hidden and tabindex=-1 on purpose: the row itself is the treeitem
|
|
110
|
+
and Left/Right already collapse and expand, so a separately focusable
|
|
111
|
+
caret puts a second stop in the tab order for an action the row has. It
|
|
112
|
+
stays a real button for the pointer. */
|
|
113
|
+
.juno-tree__caret {
|
|
114
|
+
flex-shrink: 0;
|
|
115
|
+
inline-size: var(--juno-space-16);
|
|
116
|
+
block-size: var(--juno-space-16);
|
|
117
|
+
display: grid;
|
|
118
|
+
place-items: center;
|
|
119
|
+
padding: 0;
|
|
120
|
+
border: none;
|
|
121
|
+
background: transparent;
|
|
122
|
+
color: var(--juno-label);
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
transition: transform var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.juno-tree__caret::before {
|
|
128
|
+
content: '';
|
|
129
|
+
inline-size: 0;
|
|
130
|
+
block-size: 0;
|
|
131
|
+
border-block: calc(var(--juno-space-4) + 1px) solid transparent;
|
|
132
|
+
border-inline-start: calc(var(--juno-space-4) + 2px) solid currentcolor;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.juno-tree__item[aria-expanded='true'] > .juno-tree__row > .juno-tree__caret {
|
|
136
|
+
transform: rotate(90deg);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* A leaf keeps the caret's box so labels line up down the column, but paints
|
|
140
|
+
nothing — an empty gap would let sibling labels wander. */
|
|
141
|
+
.juno-tree__item:not([aria-expanded]) > .juno-tree__row > .juno-tree__caret {
|
|
142
|
+
visibility: hidden;
|
|
143
|
+
cursor: default;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.juno-tree__icon {
|
|
147
|
+
flex-shrink: 0;
|
|
148
|
+
color: var(--juno-label);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.juno-tree__label {
|
|
152
|
+
min-inline-size: 0;
|
|
153
|
+
overflow: hidden;
|
|
154
|
+
text-overflow: ellipsis;
|
|
155
|
+
white-space: nowrap;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Count badge on a group row. Pushed to the trailing edge with the rest of
|
|
159
|
+
the trailing furniture. */
|
|
160
|
+
.juno-tree__count {
|
|
161
|
+
margin-inline-start: auto;
|
|
162
|
+
flex-shrink: 0;
|
|
163
|
+
font-size: var(--juno-font-size-11);
|
|
164
|
+
font-variant-numeric: tabular-nums;
|
|
165
|
+
color: var(--juno-muted);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* The same trailing-control slot .juno-list__row has: a switch, a menu
|
|
169
|
+
trigger, a value. Follows the count when both are present. */
|
|
170
|
+
.juno-tree__trail {
|
|
171
|
+
margin-inline-start: auto;
|
|
172
|
+
flex-shrink: 0;
|
|
173
|
+
display: inline-flex;
|
|
174
|
+
align-items: center;
|
|
175
|
+
gap: var(--juno-space-8);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.juno-tree__count + .juno-tree__trail {
|
|
179
|
+
margin-inline-start: 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── reorder handle ────────────────────────────────────────────────────
|
|
183
|
+
The AFFORDANCE and its hit area; the reorder logic is the app's, which is
|
|
184
|
+
junoui's usual line. It is a real button and always visible on touch: a
|
|
185
|
+
long-press-drag is the obvious gesture and the wrong one, because a tree
|
|
186
|
+
sitting on or beside a pan/zoom surface has to let the pan win. An
|
|
187
|
+
explicit handle is the only unambiguous target — see the doc.
|
|
188
|
+
`touch-action: none` on the handle ALONE, so dragging it never scrolls
|
|
189
|
+
while the rest of the row still pans normally. */
|
|
190
|
+
.juno-tree__handle {
|
|
191
|
+
flex-shrink: 0;
|
|
192
|
+
inline-size: var(--juno-space-16);
|
|
193
|
+
block-size: var(--juno-space-16);
|
|
194
|
+
display: grid;
|
|
195
|
+
place-items: center;
|
|
196
|
+
padding: 0;
|
|
197
|
+
border: none;
|
|
198
|
+
background: transparent;
|
|
199
|
+
color: var(--juno-muted);
|
|
200
|
+
cursor: grab;
|
|
201
|
+
touch-action: none;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.juno-tree__handle:active {
|
|
205
|
+
cursor: grabbing;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.juno-tree__handle::before {
|
|
209
|
+
content: '';
|
|
210
|
+
inline-size: var(--juno-space-10);
|
|
211
|
+
block-size: var(--juno-border-width-1);
|
|
212
|
+
box-shadow:
|
|
213
|
+
0 calc(-1 * var(--juno-space-4)) 0 currentcolor,
|
|
214
|
+
0 var(--juno-space-4) 0 currentcolor;
|
|
215
|
+
background: currentcolor;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* The row being dragged, while the app moves it. */
|
|
219
|
+
.juno-tree__item[data-juno-dragging] > .juno-tree__row {
|
|
220
|
+
opacity: var(--juno-opacity-muted);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* Drop target edge. A line rather than a filled row: the drop lands BETWEEN
|
|
224
|
+
rows, and a filled highlight says "into this one", which is a different
|
|
225
|
+
operation in a tree and the one users complain about. */
|
|
226
|
+
.juno-tree__item[data-juno-drop='before'] > .juno-tree__row {
|
|
227
|
+
box-shadow: inset 0 var(--juno-border-width-2) 0 var(--juno-active);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.juno-tree__item[data-juno-drop='after'] > .juno-tree__row {
|
|
231
|
+
box-shadow: inset 0 calc(-1 * var(--juno-border-width-2)) 0 var(--juno-active);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.juno-tree__item[data-juno-drop='into'] > .juno-tree__row {
|
|
235
|
+
outline: var(--juno-border-width-2) solid var(--juno-active);
|
|
236
|
+
outline-offset: calc(-1 * var(--juno-border-width-2));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ── touch ─────────────────────────────────────────────────────────────
|
|
240
|
+
The caret and the handle paint small so a dense tree stays dense, but a
|
|
241
|
+
16px target is not tappable. Grow the HIT AREA only, with a transparent
|
|
242
|
+
overlay, exactly as .nx-check does downstream: a 44px painted caret would
|
|
243
|
+
swallow the row it sits in. The row's own floor comes from
|
|
244
|
+
--juno-size-tap-min, which base.css promotes on a coarse pointer. */
|
|
245
|
+
@media (pointer: coarse) {
|
|
246
|
+
.juno-tree__caret,
|
|
247
|
+
.juno-tree__handle {
|
|
248
|
+
position: relative;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.juno-tree__caret::after,
|
|
252
|
+
.juno-tree__handle::after {
|
|
253
|
+
content: '';
|
|
254
|
+
position: absolute;
|
|
255
|
+
|
|
256
|
+
/* (44 - 16) / 2 = 14 */
|
|
257
|
+
inset: calc(-1 * (var(--juno-size-tap-comfortable) - var(--juno-space-16)) / 2);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -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
|
+
}
|
package/tools/gizmo.mjs
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// junoui/testing — guards a consumer can run against its own source
|
|
3
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
4
|
+
// Framework-agnostic: throws an Error with a readable message, so it works
|
|
5
|
+
// under vitest, node:test, jest or a plain script. No dependencies.
|
|
6
|
+
//
|
|
7
|
+
// import { assertJunoClasses } from 'junoui/testing';
|
|
8
|
+
// assertJunoClasses(['src/**/*.tsx']);
|
|
9
|
+
//
|
|
10
|
+
// WHAT IT ANSWERS, and what it does not. It answers "junoui defines a rule
|
|
11
|
+
// mentioning this class". It does not answer "the class does what your
|
|
12
|
+
// component assumes" — a class that exists but was repurposed upstream
|
|
13
|
+
// passes. What it catches with certainty is a name that matches NOTHING,
|
|
14
|
+
// which is the whole of the defect it was written for: eleven such names
|
|
15
|
+
// once compiled silently in a consumer and rendered a phone dialog as
|
|
16
|
+
// unstyled UA defaults, with its confirm button off the bottom of the screen.
|
|
17
|
+
//
|
|
18
|
+
// See docs/conformance-kit.md.
|
|
19
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
20
|
+
|
|
21
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
22
|
+
import { join, dirname, relative, sep } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
/** `juno-` or the role form `juno--`, then BEM segments.
|
|
28
|
+
* The leading boundary keeps `--juno-warning` out: a custom property is
|
|
29
|
+
* always preceded by a hyphen and a class never is. Getting this wrong makes
|
|
30
|
+
* the guard report components that have no defect. */
|
|
31
|
+
const CLASS_RE = /(?<![-\w])juno-{1,2}[a-z0-9]+(?:[-_]{1,2}[a-z0-9]+)*/g;
|
|
32
|
+
|
|
33
|
+
/** The manifest this build ships. */
|
|
34
|
+
export function loadJunoClasses() {
|
|
35
|
+
return JSON.parse(readFileSync(join(HERE, '..', 'dist', 'classes.json'), 'utf8'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Source with comments removed.
|
|
40
|
+
*
|
|
41
|
+
* Crude by design — it does not parse string literals, so a `//` inside one
|
|
42
|
+
* truncates that line. Worth the simplicity: a class name does not live inside
|
|
43
|
+
* a URL, and the alternative is a second implementation of a compiler to
|
|
44
|
+
* answer a question about strings. Comments MUST be stripped: a file that
|
|
45
|
+
* documents a typo in order to explain it would otherwise be reported for it.
|
|
46
|
+
*/
|
|
47
|
+
export function stripComments(source) {
|
|
48
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `juno-*` class names appearing in a source file, comments excluded.
|
|
53
|
+
*
|
|
54
|
+
* Matches anywhere in the code rather than parsing JSX: a class reaches the
|
|
55
|
+
* DOM through a template literal, a ternary or a helper as often as through a
|
|
56
|
+
* literal `className="…"`, and a matcher that only understood the literal form
|
|
57
|
+
* would skip the conditional ones — which is exactly where a typo hides.
|
|
58
|
+
*/
|
|
59
|
+
export function junoClassesIn(source) {
|
|
60
|
+
return [...new Set([...stripComments(source).matchAll(CLASS_RE)].map((m) => m[0]))].sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Minimal glob: supports `**`, `*` and `?`. No braces, no negation — a
|
|
64
|
+
* consumer wanting more can pass an explicit file list instead. */
|
|
65
|
+
function globToRegExp(pattern) {
|
|
66
|
+
let out = '';
|
|
67
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
68
|
+
const c = pattern[i];
|
|
69
|
+
if (c === '*') {
|
|
70
|
+
if (pattern[i + 1] === '*') {
|
|
71
|
+
out += '.*';
|
|
72
|
+
i++;
|
|
73
|
+
if (pattern[i + 1] === '/') i++;
|
|
74
|
+
} else out += '[^/]*';
|
|
75
|
+
} else if (c === '?') out += '[^/]';
|
|
76
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
77
|
+
}
|
|
78
|
+
return new RegExp('^' + out + '$');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function walk(dir, acc = []) {
|
|
82
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
83
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
84
|
+
const p = join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) walk(p, acc);
|
|
86
|
+
else acc.push(p);
|
|
87
|
+
}
|
|
88
|
+
return acc;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Files matching any of `patterns`, resolved from `cwd`. */
|
|
92
|
+
export function resolveFiles(patterns, cwd = process.cwd()) {
|
|
93
|
+
const res = patterns.map(globToRegExp);
|
|
94
|
+
const roots = new Set();
|
|
95
|
+
for (const p of patterns) {
|
|
96
|
+
const literal = p.split(/[*?]/)[0];
|
|
97
|
+
const base = literal.endsWith('/') ? literal : dirname(literal);
|
|
98
|
+
roots.add(base === '' || base === '.' ? cwd : join(cwd, base));
|
|
99
|
+
}
|
|
100
|
+
const files = [];
|
|
101
|
+
for (const root of roots) {
|
|
102
|
+
let st;
|
|
103
|
+
try {
|
|
104
|
+
st = statSync(root);
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!st.isDirectory()) continue;
|
|
109
|
+
for (const f of walk(root)) {
|
|
110
|
+
const rel = relative(cwd, f).split(sep).join('/');
|
|
111
|
+
if (res.some((r) => r.test(rel))) files.push(rel);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return files.sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Throw if any file names a `juno-*` class this build does not define.
|
|
119
|
+
*
|
|
120
|
+
* @param patterns globs or explicit paths, relative to `cwd`
|
|
121
|
+
* @param options.allowed names the CONSUMER defines in its own stylesheet.
|
|
122
|
+
* Each one is a claim the caller is making; check it against that stylesheet
|
|
123
|
+
* rather than treating this as a waiver list.
|
|
124
|
+
* @param options.surface `'all'` (default) or `'public'`.
|
|
125
|
+
*
|
|
126
|
+
* 'all' is the default deliberately, against this kit's own first proposal.
|
|
127
|
+
* Measured on the 0.7.0 build: 310 classes have rules, 277 are named in
|
|
128
|
+
* docs/. The 33-name difference is NOT an internals list — it is
|
|
129
|
+
* `juno-sr-only`, `juno-bg-s0`, `juno-hide-below-lg`, `juno-eyebrow` and
|
|
130
|
+
* friends, i.e. public utilities nobody wrote up. Defaulting to 'public'
|
|
131
|
+
* would have failed consumers for using shipped API. 'public' remains
|
|
132
|
+
* available for a stricter check, and the docs gap is junoui's to close.
|
|
133
|
+
*/
|
|
134
|
+
export function assertJunoClasses(patterns, options = {}) {
|
|
135
|
+
const { allowed = [], surface = 'all', cwd = process.cwd() } = options;
|
|
136
|
+
const manifest = loadJunoClasses();
|
|
137
|
+
// The claim is "junoui ships NOTHING by this name", not "this is not a
|
|
138
|
+
// class". A consumer writes `junoPx('juno-pillbar-gap')` and `#juno-i-${n}`,
|
|
139
|
+
// and no regex over source text can tell those from a class name — so a
|
|
140
|
+
// guard that only knew about classes would report correct code. Measured on
|
|
141
|
+
// a real consumer: 8 of 24 reports were tokens, an icon-id template and a
|
|
142
|
+
// keyframe, all of them names junoui does ship.
|
|
143
|
+
const shipped = [...manifest.keyframes, ...manifest.tokens, ...manifest.icons];
|
|
144
|
+
const defined = new Set(
|
|
145
|
+
surface === 'public'
|
|
146
|
+
? [...manifest.public, ...manifest.roles, ...shipped]
|
|
147
|
+
: [...manifest.all, ...shipped],
|
|
148
|
+
);
|
|
149
|
+
const waived = new Set(allowed);
|
|
150
|
+
|
|
151
|
+
const files = Array.isArray(patterns)
|
|
152
|
+
? resolveFiles(patterns, cwd)
|
|
153
|
+
: resolveFiles([patterns], cwd);
|
|
154
|
+
if (files.length === 0) {
|
|
155
|
+
// A guard that inspected nothing and passed is the failure mode this
|
|
156
|
+
// whole kit exists to stop.
|
|
157
|
+
throw new Error(
|
|
158
|
+
`assertJunoClasses: no files matched ${JSON.stringify(patterns)} under ${cwd} — ` +
|
|
159
|
+
`the check would have passed vacuously`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const offenders = [];
|
|
164
|
+
for (const f of files) {
|
|
165
|
+
for (const cls of junoClassesIn(readFileSync(join(cwd, f), 'utf8'))) {
|
|
166
|
+
if (!defined.has(cls) && !waived.has(cls)) offenders.push(`${f}: ${cls}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (offenders.length) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`junoui ${manifest.version} ships nothing named by ${offenders.length} \`juno-*\` name(s) ` +
|
|
172
|
+
`(surface: ${surface}, ${files.length} file(s) checked):\n ` +
|
|
173
|
+
offenders.join('\n '),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return { files: files.length, checked: defined.size };
|
|
177
|
+
}
|