@motionstudies/web 0.1.0-alpha.0 → 0.1.0-alpha.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/README.md CHANGED
@@ -4,7 +4,7 @@ Shared packages for the Motion Studies transport instrument. The source workspac
4
4
 
5
5
  - `@motionstudies/core`: transport contracts, indexing, interpolation and visual theme contracts; no browser or Node dependencies.
6
6
  - `@motionstudies/three`: `NationalNetworkScene`, `HubPulseScene`, `StationFlowScene`, camera framing and label-mode contracts. React, React Three Fiber and Three.js are peers; rendering internals are not public subpaths.
7
- - `@motionstudies/web`: picker, theme application, mounting, progressive loaders, observed operations and recording. Import `tokens.css` and `mobile-picker.css` for isolated widgets. `shell.css` is an optional full-page study shell scoped to `.motion-study`; `mountMotionStudy` applies that class. Fonts and edition layouts belong to consumers.
7
+ - `@motionstudies/web`: picker, button tooltips, theme application, mounting, progressive loaders, observed operations and recording. Import `tokens.css` and `mobile-picker.css` for isolated widgets. `shell.css` is an optional full-page study shell scoped to `.motion-study`; `mountMotionStudy` applies that class. Fonts and edition layouts belong to consumers.
8
8
  - `@motionstudies/data`: Node-only GTFS readers, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
9
9
 
10
10
  ```tsx
@@ -24,3 +24,9 @@ Keep the resolver stable across renders. Manifest paths and their chunk paths ar
24
24
  Build release candidates with `npm run build:packages`. Distribution manifests and compiled ESM/declarations are written to `.package-dist/`; workspace manifests continue to point at source for fast local iteration. `npm run check:packed` packs and installs those distributions into a separate consumer, builds the lab and validates the public exports. No source aliases or workspace links are used in that consumer.
25
25
 
26
26
  Source workspace manifests always stay private. `npm run check:release` builds public candidates, tests their packed consumer and records the tested tarball hashes; `npm run release:dry-run` inspects the publication without writing to npm. The manual main-branch `release.yml` workflow publishes those same tarballs with public access and provenance. See [release instructions](https://github.com/emmettl/motionstudies/blob/main/docs/RELEASING.md) for bootstrap-token and trusted-publisher setup. All four shared packages are MIT-licensed; each distribution includes `LICENSE`.
27
+
28
+ ## Button help
29
+
30
+ `mountMotionStudy` installs one shared tooltip surface. Independent consumers such as the lab can render `ButtonTooltips` from `@motionstudies/web/components/ButtonTooltips` once instead. Put concise, action-oriented help in each button’s `data-tooltip`; icon buttons fall back to their `aria-label`. An empty `data-tooltip` opts out. Avoid native `title` attributes on these buttons, which can also appear during touch interaction.
31
+
32
+ Help appears after a short mouse hover or on keyboard focus when the primary pointer is fine and supports hover. Touch input suppresses it, including on hybrid devices. Escape, activation, scrolling and blur dismiss it. The tooltip stays inside the viewport, can itself be hovered, and temporarily extends `aria-describedby` without replacing existing descriptions. Copy and translations stay in the edition; rendering and input handling stay in this package. The Controls specimen and packed-consumer tests exercise this contract.
@@ -0,0 +1,5 @@
1
+ /** One delegated tooltip surface per application. Copy belongs to each edition.
2
+ * data-tooltip supplies help; aria-label is the fallback for icon controls.
3
+ * An empty data-tooltip opts a self-explanatory button out.
4
+ */
5
+ export declare function ButtonTooltips(): null;
@@ -0,0 +1,146 @@
1
+ import { useEffect } from 'react';
2
+ let nextTooltipId = 0;
3
+ /** One delegated tooltip surface per application. Copy belongs to each edition.
4
+ * data-tooltip supplies help; aria-label is the fallback for icon controls.
5
+ * An empty data-tooltip opts a self-explanatory button out.
6
+ */
7
+ export function ButtonTooltips() {
8
+ useEffect(() => {
9
+ const capability = window.matchMedia('(hover: hover) and (pointer: fine)');
10
+ const tooltip = document.createElement('div');
11
+ tooltip.id = `motion-study-tooltip-${++nextTooltipId}`;
12
+ tooltip.setAttribute('role', 'tooltip');
13
+ Object.assign(tooltip.style, {
14
+ position: 'fixed', zIndex: '2147483647', boxSizing: 'border-box',
15
+ maxWidth: 'min(300px, calc(100vw - 16px))', padding: '9px 12px',
16
+ border: '1px solid #73818d', borderRadius: '6px', background: '#101820',
17
+ color: '#f5f7fa', font: '400 13px/1.45 system-ui, sans-serif',
18
+ letterSpacing: 'normal', textTransform: 'none', textAlign: 'start',
19
+ whiteSpace: 'normal', overflowWrap: 'anywhere',
20
+ boxShadow: '0 4px 18px #0006', pointerEvents: 'auto',
21
+ });
22
+ let anchor = null;
23
+ let showTimer = 0;
24
+ let hideTimer = 0;
25
+ let touch = false;
26
+ const copy = (button) => (button.getAttribute('data-tooltip') ?? button.getAttribute('aria-label') ?? '').trim();
27
+ const buttonAt = (target) => target instanceof Element ? target.closest('button') : null;
28
+ const hide = () => {
29
+ window.clearTimeout(showTimer);
30
+ window.clearTimeout(hideTimer);
31
+ if (anchor) {
32
+ const descriptions = (anchor.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id && id !== tooltip.id);
33
+ if (descriptions.length)
34
+ anchor.setAttribute('aria-describedby', descriptions.join(' '));
35
+ else
36
+ anchor.removeAttribute('aria-describedby');
37
+ }
38
+ anchor = null;
39
+ observer.disconnect();
40
+ tooltip.remove();
41
+ };
42
+ const refresh = () => {
43
+ if (!anchor)
44
+ return;
45
+ if (!anchor.isConnected || !copy(anchor) || !anchor.getClientRects().length) {
46
+ hide();
47
+ return;
48
+ }
49
+ const text = copy(anchor);
50
+ if (tooltip.textContent !== text)
51
+ tooltip.textContent = text;
52
+ const lang = anchor.closest('[lang]')?.getAttribute('lang') ?? document.documentElement.lang;
53
+ if (tooltip.lang !== lang)
54
+ tooltip.lang = lang;
55
+ const rect = anchor.getBoundingClientRect();
56
+ const width = tooltip.offsetWidth;
57
+ const height = tooltip.offsetHeight;
58
+ const left = Math.max(8, Math.min(rect.left + (rect.width - width) / 2, window.innerWidth - width - 8));
59
+ const below = rect.bottom + 8;
60
+ const top = below + height <= window.innerHeight - 8 ? below : Math.max(8, rect.top - height - 8);
61
+ tooltip.style.left = `${left}px`;
62
+ tooltip.style.top = `${top}px`;
63
+ };
64
+ const show = (button, delay) => {
65
+ if (!capability.matches || touch || !copy(button))
66
+ return;
67
+ if (anchor === button) {
68
+ window.clearTimeout(hideTimer);
69
+ return;
70
+ }
71
+ hide();
72
+ showTimer = window.setTimeout(() => {
73
+ if (!button.isConnected || !capability.matches || touch)
74
+ return;
75
+ anchor = button;
76
+ document.body.append(tooltip);
77
+ const descriptions = (button.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
78
+ button.setAttribute('aria-describedby', [...descriptions, tooltip.id].join(' '));
79
+ refresh();
80
+ observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-tooltip', 'aria-label', 'hidden', 'lang'] });
81
+ }, delay);
82
+ };
83
+ const over = (event) => {
84
+ if (event.pointerType !== 'mouse')
85
+ return;
86
+ touch = false;
87
+ if (tooltip.contains(event.target)) {
88
+ window.clearTimeout(hideTimer);
89
+ return;
90
+ }
91
+ const button = buttonAt(event.target);
92
+ if (button && !button.contains(event.relatedTarget))
93
+ show(button, 400);
94
+ };
95
+ const out = (event) => {
96
+ const button = buttonAt(event.target);
97
+ if (button?.contains(event.relatedTarget) || tooltip.contains(event.relatedTarget))
98
+ return;
99
+ window.clearTimeout(showTimer);
100
+ if (anchor || tooltip.contains(event.target)) {
101
+ window.clearTimeout(hideTimer);
102
+ hideTimer = window.setTimeout(hide, 160);
103
+ }
104
+ };
105
+ const focus = (event) => {
106
+ const button = buttonAt(event.target);
107
+ if (button)
108
+ show(button, 0);
109
+ };
110
+ const down = (event) => { touch = event.pointerType !== 'mouse'; hide(); };
111
+ const key = (event) => {
112
+ if (event.key === 'Escape')
113
+ hide();
114
+ else if (event.key === 'Tab')
115
+ touch = false;
116
+ };
117
+ const observer = new MutationObserver(refresh);
118
+ document.addEventListener('pointerover', over, true);
119
+ document.addEventListener('pointerout', out, true);
120
+ document.addEventListener('pointerdown', down, true);
121
+ document.addEventListener('focusin', focus);
122
+ document.addEventListener('focusout', hide);
123
+ document.addEventListener('keydown', key, true);
124
+ document.addEventListener('click', hide, true);
125
+ document.addEventListener('scroll', hide, true);
126
+ window.addEventListener('resize', hide);
127
+ window.addEventListener('blur', hide);
128
+ capability.addEventListener('change', hide);
129
+ return () => {
130
+ hide();
131
+ observer.disconnect();
132
+ document.removeEventListener('pointerover', over, true);
133
+ document.removeEventListener('pointerout', out, true);
134
+ document.removeEventListener('pointerdown', down, true);
135
+ document.removeEventListener('focusin', focus);
136
+ document.removeEventListener('focusout', hide);
137
+ document.removeEventListener('keydown', key, true);
138
+ document.removeEventListener('click', hide, true);
139
+ document.removeEventListener('scroll', hide, true);
140
+ window.removeEventListener('resize', hide);
141
+ window.removeEventListener('blur', hide);
142
+ capability.removeEventListener('change', hide);
143
+ };
144
+ }, []);
145
+ return null;
146
+ }
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { StrictMode } from 'react';
3
3
  import { createRoot } from 'react-dom/client';
4
+ import { ButtonTooltips } from './components/ButtonTooltips.js';
4
5
  import { applyVisualTheme } from './visual-theme.js';
5
6
  export function mountMotionStudy(edition, application) {
6
7
  const root = document.getElementById('root');
@@ -9,5 +10,5 @@ export function mountMotionStudy(edition, application) {
9
10
  document.documentElement.classList.add('motion-study');
10
11
  document.documentElement.dataset.edition = edition.id;
11
12
  applyVisualTheme(edition.theme);
12
- createRoot(root).render(_jsx(StrictMode, { children: application }));
13
+ createRoot(root).render(_jsxs(StrictMode, { children: [application, _jsx(ButtonTooltips, {})] }));
13
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/web",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser controls and loaders for Motion Studies.",
@@ -72,7 +72,12 @@
72
72
  },
73
73
  "./tokens.css": "./tokens.css",
74
74
  "./shell.css": "./shell.css",
75
- "./mobile-picker.css": "./components/mobile-picker.css"
75
+ "./mobile-picker.css": "./components/mobile-picker.css",
76
+ "./components/ButtonTooltips": {
77
+ "types": "./components/ButtonTooltips.d.ts",
78
+ "import": "./components/ButtonTooltips.js",
79
+ "default": "./components/ButtonTooltips.js"
80
+ }
76
81
  },
77
82
  "files": [
78
83
  "**/*.js",
@@ -87,7 +92,7 @@
87
92
  "**/*.css"
88
93
  ],
89
94
  "dependencies": {
90
- "@motionstudies/core": "0.1.0-alpha.0"
95
+ "@motionstudies/core": "0.1.0-alpha.2"
91
96
  },
92
97
  "peerDependencies": {
93
98
  "react": "^19.2.8",