mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -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;
|
||||
Reference in new issue
Block a user