diff --git a/package-lock.json b/package-lock.json index 263fd793d..6430491c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "vue": "^3.5.44" }, "devDependencies": { + "@happy-dom/global-registrator": "^20.14.6", "@vitejs/plugin-vue": "^5.2.4", "@vue/test-utils": "^2.5.1", "happy-dom": "^20.14.6", @@ -588,6 +589,20 @@ "node": ">=18" } }, + "node_modules/@happy-dom/global-registrator": { + "version": "20.14.6", + "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.14.6.tgz", + "integrity": "sha512-Xd2FwCdm0cAZ+EG0upp69tpxBT9jtvVxaZv1lnbgWsNaZyEuPs0PojYgvCmtQ1RRdRCYCyWTeLjn3Z0aSxDchA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "happy-dom": "^20.14.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/package.json b/package.json index 138867968..44316183c 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "vue": "^3.5.44" }, "devDependencies": { + "@happy-dom/global-registrator": "^20.14.6", "@vitejs/plugin-vue": "^5.2.4", "@vue/test-utils": "^2.5.1", "happy-dom": "^20.14.6", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2522f91c7..6197aa43c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1118,5 +1118,9 @@ "src/testSupport/shareViewFixtures.mjs", "src/ui/shareLinks.test.mjs", "src/ui/shareButton.test.mjs", - "src/share/shareLinkPath.test.mjs" + "src/share/shareLinkPath.test.mjs", + "src/ui/kit/UiRail.test.mjs", + "src/testSupport/vueDom.mjs", + "src/testSupport/vueLoader.mjs", + "src/testSupport/registerVue.mjs" ] diff --git a/scripts/format.mjs b/scripts/format.mjs index 25311ba29..e91cf527b 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) { const files = []; for (const name of new Set(candidates)) { if ( - !/\.[cm]?js$/.test(name) || + !/\.([cm]?js|vue)$/.test(name) || /\.test\.[cm]?js$/.test(name) || !existsSync(path.join(root, name)) ) diff --git a/src/materialSymbolsSubset.test.mjs b/src/materialSymbolsSubset.test.mjs index ec9ffe2f0..e6352502a 100644 --- a/src/materialSymbolsSubset.test.mjs +++ b/src/materialSymbolsSubset.test.mjs @@ -14,6 +14,8 @@ const SPAN_TEXT = /** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */ const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs; const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g; +/** A rail or menu item's glyph: `{ icon: 'layers' }`, rendered by `UiIcon`. */ +const ICON_PROPERTY = /\bicon:\s*['"]([a-z0-9_]+)['"]/g; /** * Files that SHIP markup. Tests assert on markup, they do not render it. @@ -29,7 +31,7 @@ function sourceFiles(directory = SRC_ROOT) { if (entry.isDirectory()) files.push(...sourceFiles(absolute)); else if ( entry.isFile() && - /\.(js|mjs|html)$/.test(entry.name) && + /\.(js|mjs|html|vue)$/.test(entry.name) && !entry.name.endsWith('.test.mjs') ) { files.push(absolute); @@ -62,6 +64,7 @@ function referencedGlyphs() { if (!found.has(glyph)) found.set(glyph, relative); }; for (const match of source.matchAll(SPAN_TEXT)) add(match[1]); + for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]); for (const match of source.matchAll(TEXT_ASSIGNMENT)) { const statement = match[1].replaceAll('?.', '.'); const assigned = statement.includes('?') diff --git a/src/testSupport/vueDom.mjs b/src/testSupport/vueDom.mjs new file mode 100644 index 000000000..3fd138513 --- /dev/null +++ b/src/testSupport/vueDom.mjs @@ -0,0 +1,10 @@ +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +/** + * Give a component test a browser document; returns the teardown. + * Call it from `before`/`after`, since other suites assert there is no DOM. + */ +export function installVueDom() { + GlobalRegistrator.register({ url: 'http://localhost/' }); + return () => GlobalRegistrator.unregister(); +} diff --git a/src/ui/kit/UiIcon.vue b/src/ui/kit/UiIcon.vue new file mode 100644 index 000000000..4f34f29e4 --- /dev/null +++ b/src/ui/kit/UiIcon.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/ui/kit/UiRail.test.mjs b/src/ui/kit/UiRail.test.mjs new file mode 100644 index 000000000..6c443286a --- /dev/null +++ b/src/ui/kit/UiRail.test.mjs @@ -0,0 +1,73 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../../testSupport/vueDom.mjs'; + +let mount; +let UiRail; +let uninstall; +before(async () => { + uninstall = installVueDom(); + ({ mount } = await import('@vue/test-utils')); + UiRail = (await import('./UiRail.vue')).default; +}); +after(() => uninstall()); + +const items = [ + { id: 'layers', label: 'DATA LAYERS', icon: 'layers' }, + { id: 'scenes', label: 'AUTHORING', icon: 'movie', separatorBefore: true }, + { id: 'cctv', label: 'CCTV', icon: 'videocam', disabled: true, badge: 3 }, +]; + +function mountRail(props = {}) { + return mount(UiRail, { + props: { items, side: 'left', label: 'Left panels', ...props }, + }); +} + +test('the rail is a named nav with one pressed button per item', () => { + const wrapper = mountRail({ active: 'scenes' }); + const nav = wrapper.get('nav'); + assert.equal(nav.attributes('aria-label'), 'Left panels'); + assert.ok(nav.classes().includes('ui-rail--left')); + const buttons = wrapper.findAll('button'); + assert.deepEqual( + buttons.map((button) => button.attributes('data-rail-item')), + ['layers', 'scenes', 'cctv'], + ); + assert.deepEqual( + buttons.map((button) => button.attributes('aria-pressed')), + ['false', 'true', 'false'], + ); + assert.equal(buttons[0].attributes('aria-label'), 'DATA LAYERS'); + assert.equal(buttons[0].get('.material-symbols-outlined').text(), 'layers'); + assert.ok(buttons[1].classes().includes('ui-rail__item--on')); + assert.equal(wrapper.findAll('.ui-rail__sep').length, 1); + assert.equal(buttons[2].attributes('disabled'), ''); + assert.equal(buttons[2].get('.ui-rail__badge').text(), '3'); +}); + +test('selecting an item emits its id', async () => { + const wrapper = mountRail(); + await wrapper.get('[data-rail-item="scenes"]').trigger('click'); + assert.deepEqual(wrapper.emitted('select'), [['scenes']]); +}); + +test('tips open toward the screen, and not for the open panel', async () => { + const wrapper = mountRail({ + side: 'right', + active: 'layers', + panelOpen: true, + }); + const [open, other] = wrapper.findAll('.ui-tooltip'); + await open.trigger('focusin'); + assert.equal(wrapper.find('[role="tooltip"]').exists(), false); + await other.trigger('focusin'); + // Over an open panel the other tips wait for the pointer to rest. + assert.equal(wrapper.find('[role="tooltip"]').exists(), false); + await new Promise((resolve) => setTimeout(resolve, 450)); + const tip = wrapper.get('[role="tooltip"]'); + assert.equal(tip.text(), 'AUTHORING'); + assert.ok(tip.classes().includes('ui-tooltip__tip--left')); + await other.trigger('focusout'); + assert.equal(wrapper.find('[role="tooltip"]').exists(), false); +}); diff --git a/src/ui/kit/UiRail.vue b/src/ui/kit/UiRail.vue new file mode 100644 index 000000000..af740aae3 --- /dev/null +++ b/src/ui/kit/UiRail.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/ui/kit/UiTooltip.vue b/src/ui/kit/UiTooltip.vue new file mode 100644 index 000000000..4c63f2316 --- /dev/null +++ b/src/ui/kit/UiTooltip.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/ui/kit/rail.js b/src/ui/kit/rail.js new file mode 100644 index 000000000..498628770 --- /dev/null +++ b/src/ui/kit/rail.js @@ -0,0 +1,12 @@ +/** + * @typedef {object} UiRailItem + * @property {string} id Item identity, emitted on selection. + * @property {string} label Translated name, shown as the tooltip and accessible name. + * @property {string} icon Material Symbols glyph. + * @property {boolean} [disabled] Shown but not selectable. + * @property {boolean} [separatorBefore] Draw a divider above this item. + * @property {string|number} [badge] Small count or marker on the icon. + */ + +/** How long the pointer rests on an item before its tip shows over an open panel. */ +export const RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS = 400; diff --git a/src/ui/styles/kit.css b/src/ui/styles/kit.css new file mode 100644 index 000000000..ef65c1215 --- /dev/null +++ b/src/ui/styles/kit.css @@ -0,0 +1,100 @@ +/* Generic components in src/ui/kit. Names follow Halfpixel's ui-vue kit; the + surfaces use this application's glass, accent and type tokens. */ + +.ui-icon { + display: inline-block; + line-height: 1; + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 20; + user-select: none; +} + +.ui-tooltip { + position: relative; + display: inline-flex; +} +.ui-tooltip__tip { + position: absolute; + top: 50%; + z-index: 1; + padding: 4px 8px; + border: 1px solid var(--glass-border); + border-radius: 6px; + background: var(--menu-bg); + color: var(--dock-label-color); + font: var(--dock-label-font-weight) var(--dock-label-font-size) / 1.4 + var(--font-sans); + letter-spacing: var(--dock-label-letter-spacing); + white-space: nowrap; + pointer-events: none; + transform: translateY(-50%); +} +.ui-tooltip__tip--right { + left: calc(100% + 10px); +} +.ui-tooltip__tip--left { + right: calc(100% + 10px); +} + +.ui-rail { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px; + border: 1px solid var(--glass-border); + border-radius: calc(var(--btn-radius) + 4px); + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + pointer-events: auto; +} +.ui-rail__sep { + height: 1px; + margin: 3px 6px; + background: var(--glass-border); +} +.ui-rail__item { + position: relative; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 1px solid transparent; + border-radius: var(--btn-radius); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: + color var(--transition-fast), + background var(--transition-fast), + border-color var(--transition-fast); +} +.ui-rail__item:hover:not(:disabled) { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); +} +.ui-rail__item--on, +.ui-rail__item--on:hover:not(:disabled) { + border-color: var(--accent-dim); + background: var(--accent-dim); + color: var(--accent); +} +.ui-rail__item:disabled { + opacity: 0.35; + cursor: default; +} +.ui-rail__badge { + position: absolute; + top: 1px; + right: 1px; + min-width: 14px; + padding: 0 3px; + border-radius: 7px; + background: var(--accent); + color: var(--bg-dark); + font: 600 9px/14px var(--font-mono); + text-align: center; +} diff --git a/style.css b/style.css index 1b55c459f..f887debb1 100644 --- a/style.css +++ b/style.css @@ -32,6 +32,7 @@ @import './src/ui/styles/weather.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; +@import './src/ui/styles/kit.css'; @import './src/ui/styles/cjk.css'; @import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/cyber.css';