mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(ui): add the Vue component kit with UiRail, UiTooltip and UiIcon
This commit is contained in:
1 parent
c13c487dcf
commit
7307c11c74
13 files changed
+340
-3
No files matched your search
Generated
+15
@@ -24,6 +24,7 @@
|
|||||||
"vue": "^3.5.44"
|
"vue": "^3.5.44"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@happy-dom/global-registrator": "^20.14.6",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vue/test-utils": "^2.5.1",
|
"@vue/test-utils": "^2.5.1",
|
||||||
"happy-dom": "^20.14.6",
|
"happy-dom": "^20.14.6",
|
||||||
@@ -588,6 +589,20 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"vue": "^3.5.44"
|
"vue": "^3.5.44"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@happy-dom/global-registrator": "^20.14.6",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vue/test-utils": "^2.5.1",
|
"@vue/test-utils": "^2.5.1",
|
||||||
"happy-dom": "^20.14.6",
|
"happy-dom": "^20.14.6",
|
||||||
|
|||||||
@@ -1118,5 +1118,9 @@
|
|||||||
"src/testSupport/shareViewFixtures.mjs",
|
"src/testSupport/shareViewFixtures.mjs",
|
||||||
"src/ui/shareLinks.test.mjs",
|
"src/ui/shareLinks.test.mjs",
|
||||||
"src/ui/shareButton.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"
|
||||||
]
|
]
|
||||||
+1
-1
@@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) {
|
|||||||
const files = [];
|
const files = [];
|
||||||
for (const name of new Set(candidates)) {
|
for (const name of new Set(candidates)) {
|
||||||
if (
|
if (
|
||||||
!/\.[cm]?js$/.test(name) ||
|
!/\.([cm]?js|vue)$/.test(name) ||
|
||||||
/\.test\.[cm]?js$/.test(name) ||
|
/\.test\.[cm]?js$/.test(name) ||
|
||||||
!existsSync(path.join(root, name))
|
!existsSync(path.join(root, name))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const SPAN_TEXT =
|
|||||||
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
|
/** 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 TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
|
||||||
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
|
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.
|
* 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));
|
if (entry.isDirectory()) files.push(...sourceFiles(absolute));
|
||||||
else if (
|
else if (
|
||||||
entry.isFile() &&
|
entry.isFile() &&
|
||||||
/\.(js|mjs|html)$/.test(entry.name) &&
|
/\.(js|mjs|html|vue)$/.test(entry.name) &&
|
||||||
!entry.name.endsWith('.test.mjs')
|
!entry.name.endsWith('.test.mjs')
|
||||||
) {
|
) {
|
||||||
files.push(absolute);
|
files.push(absolute);
|
||||||
@@ -62,6 +64,7 @@ function referencedGlyphs() {
|
|||||||
if (!found.has(glyph)) found.set(glyph, relative);
|
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(SPAN_TEXT)) add(match[1]);
|
||||||
|
for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]);
|
||||||
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
|
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
|
||||||
const statement = match[1].replaceAll('?.', '.');
|
const statement = match[1].replaceAll('?.', '.');
|
||||||
const assigned = statement.includes('?')
|
const assigned = statement.includes('?')
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
/** A Material Symbols glyph; the name must be in index.html's `icon_names`. */
|
||||||
|
defineProps({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
size: { type: Number, default: 18 },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="ui-icon material-symbols-outlined"
|
||||||
|
:style="{ fontSize: `${size}px` }"
|
||||||
|
aria-hidden="true"
|
||||||
|
>{{ name }}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup>
|
||||||
|
import UiIcon from './UiIcon.vue';
|
||||||
|
import UiTooltip from './UiTooltip.vue';
|
||||||
|
import { RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS } from './rail.js';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
/** @type {import('vue').PropType<import('./rail.js').UiRailItem[]>} */
|
||||||
|
items: { type: Array, required: true },
|
||||||
|
/** The selected item's id, or null. */
|
||||||
|
active: { type: String, default: null },
|
||||||
|
/** `left` or `right`: the screen edge the rail sits on. */
|
||||||
|
side: { type: String, required: true },
|
||||||
|
/** The nav's accessible name. */
|
||||||
|
label: { type: String, required: true },
|
||||||
|
/**
|
||||||
|
* A panel is open beside the rail: the open item's tip is redundant with the
|
||||||
|
* panel's title, and the other tips wait for the pointer to rest.
|
||||||
|
*/
|
||||||
|
panelOpen: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['select']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="ui-rail" :class="`ui-rail--${side}`" :aria-label="label">
|
||||||
|
<template v-for="item in items" :key="item.id">
|
||||||
|
<i v-if="item.separatorBefore" class="ui-rail__sep" aria-hidden="true" />
|
||||||
|
<UiTooltip
|
||||||
|
:text="item.label"
|
||||||
|
:side="side === 'left' ? 'right' : 'left'"
|
||||||
|
:disabled="panelOpen && item.id === active"
|
||||||
|
:delay="panelOpen ? RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS : 0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ui-rail__item"
|
||||||
|
:class="{ 'ui-rail__item--on': item.id === active }"
|
||||||
|
:data-rail-item="item.id"
|
||||||
|
:aria-label="item.label"
|
||||||
|
:aria-pressed="item.id === active"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="emit('select', item.id)"
|
||||||
|
>
|
||||||
|
<UiIcon :name="item.icon" />
|
||||||
|
<span v-if="item.badge !== undefined" class="ui-rail__badge">{{
|
||||||
|
item.badge
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
</UiTooltip>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
text: { type: String, required: true },
|
||||||
|
/** Which side of the trigger the tip opens on. */
|
||||||
|
side: { type: String, default: 'right' },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
/** Milliseconds the pointer rests before the tip shows. */
|
||||||
|
delay: { type: Number, default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (props.delay > 0)
|
||||||
|
timer = setTimeout(() => (open.value = true), props.delay);
|
||||||
|
else open.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(hide);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="ui-tooltip"
|
||||||
|
@pointerenter="show"
|
||||||
|
@pointerleave="hide"
|
||||||
|
@focusin="show"
|
||||||
|
@focusout="hide"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<span
|
||||||
|
v-if="open && !disabled"
|
||||||
|
class="ui-tooltip__tip"
|
||||||
|
:class="`ui-tooltip__tip--${side}`"
|
||||||
|
role="tooltip"
|
||||||
|
>{{ text }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
@import './src/ui/styles/weather.css';
|
@import './src/ui/styles/weather.css';
|
||||||
@import './src/ui/styles/panel-surfaces.css';
|
@import './src/ui/styles/panel-surfaces.css';
|
||||||
@import './src/ui/styles/segmented-control.css';
|
@import './src/ui/styles/segmented-control.css';
|
||||||
|
@import './src/ui/styles/kit.css';
|
||||||
@import './src/ui/styles/cjk.css';
|
@import './src/ui/styles/cjk.css';
|
||||||
@import './src/ui/styles/mobile-shell.css';
|
@import './src/ui/styles/mobile-shell.css';
|
||||||
@import './src/ui/styles/cyber.css';
|
@import './src/ui/styles/cyber.css';
|
||||||
Reference in new issue
Block a user