mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge main into the Utilities panel, rebuilt on the Vue panel rail
main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.
- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
item (handyman). LayerPanel renders the Directions rows into its list with
LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
which render what the draw tool publishes to the display store and call
its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
drawMarkList.js is gone. A row's fly-to is an immediate navigation like
Import's ('drawing' gets its Cockpit refusal), the name field keeps a
draft through v-field-value, and the colours are one radio group with a
single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
buttons.
- The catalogs take main's languages with this branch's strings, Arabic
included, and the tests follow the views to their new homes.
This commit is contained in:
commit
4ebb287d36
699 files changed
+46673
-20476
No files matched your search
@@ -0,0 +1,49 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiBadge;
|
||||
let UiEmpty;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiBadge = (await import('./UiBadge.vue')).default;
|
||||
UiEmpty = (await import('./UiEmpty.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
test('a badge carries the kit classes, Core’s and the caller’s', async () => {
|
||||
const wrapper = mount(UiBadge, {
|
||||
attrs: { id: 'cal', class: 'gev-status-badge--box', role: 'status' },
|
||||
slots: { default: 'CAL · --' },
|
||||
});
|
||||
const badge = wrapper.get('span');
|
||||
assert.deepEqual(badge.classes(), [
|
||||
'ui-badge',
|
||||
'gev-status-badge',
|
||||
'ui-badge--neutral',
|
||||
'gev-status-badge--box',
|
||||
]);
|
||||
assert.equal(badge.attributes('role'), 'status');
|
||||
assert.equal(badge.element.textContent, 'CAL · --');
|
||||
await wrapper.setProps({ tone: 'warn' });
|
||||
assert.ok(badge.classes().includes('ui-badge--warn'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('an empty state is its title, then its hint', async () => {
|
||||
const wrapper = mount(UiEmpty, {
|
||||
props: { title: 'No shots yet.' },
|
||||
attrs: { class: 'scene-shot-empty', hidden: true },
|
||||
});
|
||||
const empty = wrapper.get('div');
|
||||
assert.deepEqual(empty.classes(), ['ui-empty', 'scene-shot-empty']);
|
||||
assert.equal(empty.element.textContent, 'No shots yet.');
|
||||
assert.equal(empty.attributes('title'), undefined);
|
||||
assert.equal(empty.element.hidden, true);
|
||||
await wrapper.setProps({ hint: 'Capture one.' });
|
||||
assert.equal(empty.get('.ui-empty__hint').text(), 'Capture one.');
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
// Core's read-only badge, with a `tone` and the kit's classes. It carries Core's `gev-status-badge` class, and
|
||||
// callers add its density variants (`gev-status-badge--box`, `--plain`) and
|
||||
// the feature classes that colour it; the kit classes carry no Core styles
|
||||
// yet. Not mirrored yet: pulse, busy, the tone glyphs and the clipped title.
|
||||
defineProps({
|
||||
tone: { type: String, default: 'neutral' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ui-badge gev-status-badge" :class="`ui-badge--${tone}`"
|
||||
><slot
|
||||
/></span>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let vue;
|
||||
let renderToString;
|
||||
let UiButton;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
vue = await import('vue');
|
||||
({ renderToString } = await import('vue/server-renderer'));
|
||||
UiButton = (await import('./UiButton.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
function mountButton(props = {}, attrs = {}) {
|
||||
return mount(UiButton, {
|
||||
attachTo: document.body,
|
||||
props,
|
||||
attrs,
|
||||
slots: { default: 'Go' },
|
||||
});
|
||||
}
|
||||
|
||||
test('a pressed toggle carries aria-pressed, the kit class and Core’s', async () => {
|
||||
const wrapper = mountButton(
|
||||
{ pressed: false, pressedClass: 'active' },
|
||||
{ id: 'go', class: 'pp-toggle-btn' },
|
||||
);
|
||||
const button = wrapper.get('button');
|
||||
assert.equal(button.attributes('aria-pressed'), 'false');
|
||||
assert.equal(button.classes().includes('active'), false);
|
||||
await wrapper.setProps({ pressed: true });
|
||||
assert.equal(button.attributes('aria-pressed'), 'true');
|
||||
assert.ok(button.classes().includes('active'));
|
||||
assert.ok(button.classes().includes('ui-button--pressed'));
|
||||
assert.ok(button.classes().includes('pp-toggle-btn'));
|
||||
assert.equal(button.attributes('type'), 'button');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('no aria-pressed unless pressed is passed', () => {
|
||||
const wrapper = mountButton();
|
||||
assert.equal(wrapper.get('button').attributes('aria-pressed'), undefined);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a class other code adds survives a press', async () => {
|
||||
const wrapper = mountButton({ pressed: false, pressedClass: 'active' });
|
||||
const button = wrapper.get('button').element;
|
||||
button.classList.add('voice-highlight');
|
||||
await wrapper.setProps({ pressed: true });
|
||||
await wrapper.setProps({ pressed: false });
|
||||
assert.ok(button.classList.contains('voice-highlight'));
|
||||
assert.equal(button.classList.contains('active'), false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a caller’s class change keeps the pressed classes', async () => {
|
||||
const wrapper = mountButton(
|
||||
{ pressed: true, pressedClass: 'active' },
|
||||
{ class: { panoptic: false } },
|
||||
);
|
||||
await wrapper.setProps({ class: { panoptic: true } });
|
||||
const classes = wrapper.get('button').classes();
|
||||
assert.ok(classes.includes('panoptic'));
|
||||
assert.ok(classes.includes('active'));
|
||||
assert.ok(classes.includes('ui-button--pressed'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a renamed pressedClass leaves the button', async () => {
|
||||
const wrapper = mountButton({ pressed: true, pressedClass: 'active' });
|
||||
await wrapper.setProps({ pressedClass: 'selected' });
|
||||
let classes = wrapper.get('button').classes();
|
||||
assert.equal(classes.includes('active'), false);
|
||||
assert.ok(classes.includes('selected'));
|
||||
await wrapper.setProps({ pressed: false });
|
||||
classes = wrapper.get('button').classes();
|
||||
assert.equal(classes.includes('selected'), false);
|
||||
assert.equal(classes.includes('active'), false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('server-rendered markup carries the pressed classes', async () => {
|
||||
const { createSSRApp, h } = vue;
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
UiButton,
|
||||
{ id: 'fm', pressed: true, pressedClass: 'active' },
|
||||
() => 'FM',
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.match(html, /<button[^>]*class="[^"]*\bactive\b[^"]*"/);
|
||||
assert.match(html, /aria-pressed="true"/);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
|
||||
// Core's button and its attribute contract: the button is always the root,
|
||||
// attributes passed in win over its own (a `title` replaces the label's), and
|
||||
// `pressed` renders aria-pressed only when it is passed. Core styles the
|
||||
// button through the classes its callers pass; the kit classes carry no Core
|
||||
// styles yet. Core adds `pressedClass`, its class on a pressed button (most
|
||||
// toggles use `active`). The pressed classes toggle through `classList`, so
|
||||
// classes other code adds to the button (a voice highlight) survive a press.
|
||||
// Not mirrored yet: icon, loading, disabledReason and the missing-label
|
||||
// warning.
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'ghost' },
|
||||
tone: { type: String, default: 'accent' },
|
||||
/** The accessible name (aria-label) and, with nativeTitle, the tip. */
|
||||
label: { type: String, default: undefined },
|
||||
pressed: { type: Boolean, default: undefined },
|
||||
/** Core's class on the button while it is pressed. */
|
||||
pressedClass: { type: String, default: undefined },
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' },
|
||||
/** The id of the form a submit button belongs to, where it sits outside it. */
|
||||
form: { type: String, default: undefined },
|
||||
nativeTitle: { type: Boolean, default: true },
|
||||
});
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
// A pressedClass that changes keeps its old name, switched off, so the class
|
||||
// leaves the button with it.
|
||||
const pressedClasses = computed((previous) => {
|
||||
const on = Boolean(props.pressed);
|
||||
const classes = { 'ui-button--pressed': on };
|
||||
for (const name of Object.keys(previous || {}))
|
||||
if (name !== 'ui-button--pressed') classes[name] = false;
|
||||
if (props.pressedClass) classes[props.pressedClass] = on;
|
||||
return classes;
|
||||
});
|
||||
// Server-rendered markup carries the pressed classes in its class attribute.
|
||||
const vPressedClasses = {
|
||||
...vClassToggle,
|
||||
getSSRProps: ({ value }) => ({
|
||||
class: Object.keys(value).filter((name) => value[name]),
|
||||
}),
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="props.type"
|
||||
:form="props.form"
|
||||
class="ui-button ui-focus"
|
||||
:class="[`ui-button--${props.variant}`, `ui-button--${props.tone}`]"
|
||||
:aria-label="props.label"
|
||||
:title="props.nativeTitle ? props.label : undefined"
|
||||
:aria-pressed="
|
||||
props.pressed === undefined ? undefined : props.pressed ? 'true' : 'false'
|
||||
"
|
||||
:disabled="props.disabled"
|
||||
v-bind="$attrs"
|
||||
v-pressed-classes="pressedClasses"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiChip;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiChip = (await import('./UiChip.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
test('a chip is a span with the kit classes and Core’s', async () => {
|
||||
const wrapper = mount(UiChip, {
|
||||
attrs: { class: 'gev-voice-card-chip', 'data-tone': 'ok' },
|
||||
slots: { default: 'ok' },
|
||||
});
|
||||
const chip = wrapper.get('span');
|
||||
assert.deepEqual(chip.classes(), ['ui-chip', 'gev-voice-card-chip']);
|
||||
assert.equal(chip.attributes('data-tone'), 'ok');
|
||||
assert.equal(chip.text(), 'ok');
|
||||
await wrapper.setProps({ selected: true, mono: true });
|
||||
assert.ok(chip.classes().includes('ui-chip--selected'));
|
||||
assert.ok(chip.classes().includes('ui-mono'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
// Core's chip: a label that says what something is, never a control.
|
||||
// Core styles it through the classes its callers pass; the kit classes carry
|
||||
// no Core styles yet. A chip that presses is a UiButton with pressedClass.
|
||||
defineProps({
|
||||
selected: { type: Boolean, default: false },
|
||||
mono: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="ui-chip"
|
||||
:class="{ 'ui-chip--selected': selected, 'ui-mono': mono }"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
// Core's empty state, with a `title` and `hint`. Core's empty states are one
|
||||
// line, so the title is the text itself rather than a bold heading; a hint
|
||||
// follows it. Core
|
||||
// styles it through the classes its callers pass. Not mirrored yet: icon.
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
hint: { type: String, default: undefined },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-empty">
|
||||
{{ title }}<span v-if="hint" class="ui-empty__hint">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 @@
|
||||
<script setup>
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import UiPanelToggle from './UiPanelToggle.vue';
|
||||
|
||||
// Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup
|
||||
// (glow, inner, a header with the title, badges, divider, actions and the
|
||||
// collapse button, then the body) and keeps Core's classes beside the kit's.
|
||||
// `collapsed` does not hide the body: the root's classes
|
||||
// stay as rendered, and whatever owns the panel's state writes its
|
||||
// `collapsed` class at once (PanelChrome, through vPanelState), because the
|
||||
// rails measure a panel in the task that opens it. `collapsed` drives the
|
||||
// button and the kit's `ui-panel--collapsed`, toggled without a re-render.
|
||||
// There is no Close: Core's panels collapse.
|
||||
const props = defineProps({
|
||||
/** The root's id, its inner's class (`{id}-inner`) and the button's target. */
|
||||
id: { type: String, required: true },
|
||||
/** The heading, already translated; also the button's "Expand {name}". */
|
||||
title: { type: String, required: true },
|
||||
/** The heading's tip. */
|
||||
hint: { type: String, default: undefined },
|
||||
tag: { type: String, default: 'div' },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
|
||||
sideways: { type: Boolean, default: false },
|
||||
/** Words that replace "Expand/Collapse {title}" on the button. */
|
||||
toggleWords: { type: Object, default: null },
|
||||
glow: { type: Boolean, default: true },
|
||||
/** The inner scrolls with Core's scroll bar. */
|
||||
scroll: { type: Boolean, default: false },
|
||||
/** The inner is a scroller the right rail keeps in view. */
|
||||
railScroller: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['update:collapsed']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.tag"
|
||||
:id="props.id"
|
||||
v-class-toggle="{
|
||||
'ui-panel--collapsed': props.collapsible && props.collapsed,
|
||||
}"
|
||||
class="ui-panel panel-collapsible"
|
||||
>
|
||||
<div v-if="props.glow" class="panel-glow"></div>
|
||||
<div
|
||||
:class="[`${props.id}-inner`, { 'ui-scroll': props.scroll }]"
|
||||
:data-rail-scroller="props.railScroller ? '' : undefined"
|
||||
>
|
||||
<div class="ui-panel__head panel-header">
|
||||
<slot name="title">
|
||||
<span class="ui-panel__title panel-title" :title="props.hint">{{
|
||||
props.title
|
||||
}}</span>
|
||||
</slot>
|
||||
<slot name="badge" />
|
||||
<span class="panel-divider"></span>
|
||||
<slot name="actions" />
|
||||
<UiPanelToggle
|
||||
v-if="props.collapsible"
|
||||
:target="props.id"
|
||||
:name="props.title"
|
||||
:collapsed="props.collapsed"
|
||||
:sideways="props.sideways"
|
||||
:words="props.toggleWords"
|
||||
@update:collapsed="emit('update:collapsed', $event)"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import UiButton from './UiButton.vue';
|
||||
|
||||
// A panel's collapse button, internal to the kit: UiPanel draws it, and so
|
||||
// does PanelCollapseButton for the panels not built on UiPanel. Its glyph and
|
||||
// words follow `collapsed`; a click asks for the other state.
|
||||
const props = defineProps({
|
||||
/** The panel's id, which PanelChrome finds the button by. */
|
||||
target: { type: String, required: true },
|
||||
/** The panel's name in "Expand {name}", already translated; "panel" when unset. */
|
||||
name: { type: String, default: null },
|
||||
collapsed: { type: Boolean, required: true },
|
||||
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
|
||||
sideways: { type: Boolean, default: false },
|
||||
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
|
||||
words: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(['update:collapsed']);
|
||||
|
||||
const glyph = computed(() => {
|
||||
if (props.sideways) return props.collapsed ? '◀' : '▶';
|
||||
return props.collapsed ? '+' : '−';
|
||||
});
|
||||
const label = computed(() => {
|
||||
if (props.words)
|
||||
return props.collapsed
|
||||
? props.words.expandLabel
|
||||
: props.words.collapseLabel;
|
||||
const key = props.collapsed ? msg('Expand {name}') : msg('Collapse {name}');
|
||||
return t(key, { name: props.name ?? t('panel') });
|
||||
});
|
||||
const title = computed(() => {
|
||||
if (props.words)
|
||||
return props.collapsed
|
||||
? props.words.expandTitle
|
||||
: props.words.collapseTitle;
|
||||
return label.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiButton
|
||||
variant="icon"
|
||||
class="panel-collapse-btn"
|
||||
:data-collapse-target="target"
|
||||
:label="label"
|
||||
:title="title"
|
||||
:aria-expanded="String(!collapsed)"
|
||||
@click="emit('update:collapsed', !collapsed)"
|
||||
>{{ glyph }}</UiButton
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,145 @@
|
||||
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);
|
||||
});
|
||||
|
||||
test('a collapsible rail shows labelled pills until a panel opens', async () => {
|
||||
const wrapper = mountRail({ collapsible: true });
|
||||
const nav = wrapper.get('nav');
|
||||
assert.ok(nav.classes().includes('ui-rail--collapsible'));
|
||||
assert.ok(nav.classes().includes('ui-rail--expanded'));
|
||||
assert.deepEqual(
|
||||
wrapper.findAll('.ui-rail__label').map((label) => label.text()),
|
||||
['DATA LAYERS', 'AUTHORING', 'CCTV'],
|
||||
);
|
||||
// The label is on screen, so its tip would only repeat it.
|
||||
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
|
||||
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
|
||||
// The button keeps its own accessible name; the visible label is hidden
|
||||
// from assistive technology so the name is not read twice.
|
||||
const button = wrapper.get('[data-rail-item="layers"]');
|
||||
assert.equal(button.attributes('aria-label'), 'DATA LAYERS');
|
||||
assert.equal(button.get('.ui-rail__label').attributes('aria-hidden'), 'true');
|
||||
|
||||
await wrapper.setProps({ active: 'layers', panelOpen: true });
|
||||
assert.ok(nav.classes().includes('ui-rail--collapsible'));
|
||||
assert.equal(nav.classes().includes('ui-rail--expanded'), false);
|
||||
// Collapsed, the other items' tips come back.
|
||||
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
assert.equal(wrapper.get('[role="tooltip"]').text(), 'AUTHORING');
|
||||
});
|
||||
|
||||
test('a label cut short by the pill keeps its tip', async (t) => {
|
||||
const observers = [];
|
||||
const original = globalThis.ResizeObserver;
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.targets = [];
|
||||
observers.push(this);
|
||||
}
|
||||
observe(target) {
|
||||
this.targets.push(target);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
t.after(() => (globalThis.ResizeObserver = original));
|
||||
const wrapper = mountRail({ collapsible: true });
|
||||
const [observer] = observers;
|
||||
const [layers, scenes] = observer.targets;
|
||||
Object.defineProperties(layers, {
|
||||
scrollWidth: { value: 260 },
|
||||
clientWidth: { value: 220 },
|
||||
});
|
||||
Object.defineProperties(scenes, {
|
||||
scrollWidth: { value: 90 },
|
||||
clientWidth: { value: 90 },
|
||||
});
|
||||
observer.callback([{ target: layers }, { target: scenes }]);
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
|
||||
assert.equal(wrapper.get('[role="tooltip"]').text(), 'DATA LAYERS');
|
||||
await wrapper.findAll('.ui-tooltip')[0].trigger('focusout');
|
||||
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
|
||||
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
|
||||
});
|
||||
|
||||
test('a plain rail has no labels', () => {
|
||||
const wrapper = mountRail();
|
||||
assert.equal(wrapper.find('.ui-rail__label').exists(), false);
|
||||
assert.equal(
|
||||
wrapper.get('nav').classes().includes('ui-rail--expanded'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, reactive } from 'vue';
|
||||
import UiIcon from './UiIcon.vue';
|
||||
import UiTooltip from './UiTooltip.vue';
|
||||
import { RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS } from './rail.js';
|
||||
|
||||
const props = 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 },
|
||||
/**
|
||||
* Show each item as a labelled pill while no panel is open, and collapse to
|
||||
* icons once one is, making room for the panel.
|
||||
*/
|
||||
collapsible: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['select']);
|
||||
const expanded = computed(() => props.collapsible && !props.panelOpen);
|
||||
|
||||
// A label cut short by the pill's width keeps its tip, so the full name can
|
||||
// still be read. Each label is measured whenever its box changes size, which
|
||||
// includes the moment it settles after expanding.
|
||||
const truncated = reactive(new Set());
|
||||
const labelIds = new WeakMap();
|
||||
const labelElements = new Map();
|
||||
const labelObserver =
|
||||
typeof ResizeObserver === 'function'
|
||||
? new ResizeObserver((entries) => {
|
||||
for (const { target } of entries) {
|
||||
const id = labelIds.get(target);
|
||||
if (target.scrollWidth > target.clientWidth) truncated.add(id);
|
||||
else truncated.delete(id);
|
||||
}
|
||||
})
|
||||
: null;
|
||||
function trackLabel(id, element) {
|
||||
const previous = labelElements.get(id);
|
||||
if (previous === element) return;
|
||||
if (previous) {
|
||||
labelObserver?.unobserve(previous);
|
||||
labelElements.delete(id);
|
||||
truncated.delete(id);
|
||||
}
|
||||
if (!element) return;
|
||||
labelElements.set(id, element);
|
||||
labelIds.set(element, id);
|
||||
labelObserver?.observe(element);
|
||||
}
|
||||
onBeforeUnmount(() => labelObserver?.disconnect());
|
||||
const tipHidden = (item) =>
|
||||
(expanded.value && !truncated.has(item.id)) ||
|
||||
(props.panelOpen && item.id === props.active);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="ui-rail"
|
||||
:class="[
|
||||
`ui-rail--${side}`,
|
||||
{ 'ui-rail--collapsible': collapsible, 'ui-rail--expanded': expanded },
|
||||
]"
|
||||
: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="tipHidden(item)"
|
||||
: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="collapsible"
|
||||
:ref="(element) => trackLabel(item.id, element)"
|
||||
class="ui-rail__label"
|
||||
aria-hidden="true"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
<span v-if="item.badge !== undefined" class="ui-rail__badge">{{
|
||||
item.badge
|
||||
}}</span>
|
||||
</button>
|
||||
</UiTooltip>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiSegmented;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiSegmented = (await import('./UiSegmented.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
const options = [
|
||||
{ value: 'a', label: 'Alpha', attrs: { id: 'seg-a', 'data-v': 'a' } },
|
||||
{ value: 'b', label: 'Beta', disabled: true },
|
||||
{ value: 'c', label: 'Gamma' },
|
||||
];
|
||||
|
||||
function mountGroup(props = {}) {
|
||||
return mount(UiSegmented, {
|
||||
attachTo: document.body,
|
||||
props: { options, label: 'Pick', modelValue: 'a', ...props },
|
||||
});
|
||||
}
|
||||
const tabStops = (wrapper) =>
|
||||
wrapper.findAll('button').map((b) => b.attributes('tabindex') ?? null);
|
||||
|
||||
test('a named radio group with Core and kit classes on every option', () => {
|
||||
const wrapper = mountGroup({ itemClass: 'pp-mode-btn' });
|
||||
const group = wrapper.get('[role="radiogroup"]');
|
||||
assert.equal(group.attributes('aria-label'), 'Pick');
|
||||
assert.ok(group.classes().includes('gev-segmented'));
|
||||
const [a, b, c] = wrapper.findAll('button');
|
||||
assert.equal(a.text(), 'Alpha');
|
||||
assert.equal(a.attributes('id'), 'seg-a');
|
||||
assert.equal(a.attributes('data-v'), 'a');
|
||||
assert.equal(a.attributes('type'), 'button');
|
||||
assert.equal(a.attributes('aria-checked'), 'true');
|
||||
assert.ok(a.classes().includes('active'));
|
||||
assert.ok(a.classes().includes('ui-segmented__item--on'));
|
||||
assert.ok(a.classes().includes('gev-segment'));
|
||||
assert.ok(a.classes().includes('pp-mode-btn'));
|
||||
assert.equal(b.attributes('disabled'), '');
|
||||
assert.equal(a.attributes('disabled'), undefined);
|
||||
assert.equal(c.attributes('aria-checked'), 'false');
|
||||
assert.equal(c.classes().includes('active'), false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a click asks for its option; a disabled one asks for nothing', async () => {
|
||||
const wrapper = mountGroup();
|
||||
const [, b, c] = wrapper.findAll('button');
|
||||
await c.trigger('click');
|
||||
await b.trigger('click');
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c']]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('keyboard none: every option is a tab stop and arrows do nothing', async () => {
|
||||
const wrapper = mountGroup({ keyboard: 'none', activeClass: 'is-active' });
|
||||
assert.deepEqual(tabStops(wrapper), [null, null, null]);
|
||||
assert.ok(wrapper.get('#seg-a').classes().includes('is-active'));
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
|
||||
assert.equal(wrapper.emitted('update:modelValue'), undefined);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('keyboard arrows: one roving stop, arrows skip disabled options and wrap', async () => {
|
||||
const wrapper = mountGroup();
|
||||
assert.deepEqual(tabStops(wrapper), ['0', '-1', '-1']);
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c'], ['c']]);
|
||||
await wrapper.setProps({ modelValue: 'c' });
|
||||
assert.deepEqual(tabStops(wrapper), ['-1', '-1', '0']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('keyboard radio: Home and End reach the ends', async () => {
|
||||
const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'c' });
|
||||
const [a, , c] = wrapper.findAll('button');
|
||||
await c.trigger('keydown', { key: 'Home' });
|
||||
await wrapper.setProps({ modelValue: 'a' });
|
||||
await a.trigger('keydown', { key: 'End' });
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['a'], ['c']]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a key that lands where it started moves focus without a change', async () => {
|
||||
const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'a' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
|
||||
assert.equal(wrapper.emitted('update:modelValue'), undefined);
|
||||
assert.ok(document.activeElement === wrapper.get('#seg-a').element);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a disabled group answers nothing and leaves the tab order', async () => {
|
||||
const wrapper = mountGroup({ disabled: true });
|
||||
assert.deepEqual(tabStops(wrapper), ['-1', '-1', '-1']);
|
||||
await wrapper.get('#seg-a').trigger('click');
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
|
||||
assert.equal(wrapper.emitted('update:modelValue'), undefined);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a radio group moves from the focused option, an arrows group from the selection', async () => {
|
||||
// The selection was restored to c while a keeps focus.
|
||||
const radio = mountGroup({ keyboard: 'radio', modelValue: 'c' });
|
||||
await radio.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
|
||||
assert.deepEqual(radio.emitted('update:modelValue'), [['c']]);
|
||||
radio.unmount();
|
||||
const arrows = mountGroup({ keyboard: 'arrows', modelValue: 'c' });
|
||||
await arrows.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
|
||||
assert.deepEqual(arrows.emitted('update:modelValue'), [['a']]);
|
||||
arrows.unmount();
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Core's segmented control, with v-model, options, label, stacked and
|
||||
// disabled. It adds `keyboard`, because its groups keep their own policy: `none`
|
||||
// (Tab reaches every option and arrows do nothing), `arrows` (a roving tab
|
||||
// stop that arrows move) or `radio` (arrows plus Home and
|
||||
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
|
||||
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
|
||||
// selected option's `activeClass`. An option with an `icon` shows the glyph,
|
||||
// and its label becomes the button's accessible name and tooltip.
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: undefined },
|
||||
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
|
||||
options: { type: Array, required: true },
|
||||
/** The group's accessible name, already translated. */
|
||||
label: { type: String, required: true },
|
||||
stacked: { type: Boolean, default: false },
|
||||
/** The whole group is off: no option is in the tab order or answers. */
|
||||
disabled: { type: Boolean, default: false },
|
||||
keyboard: {
|
||||
type: String,
|
||||
default: 'arrows',
|
||||
validator: (value) => ['none', 'arrows', 'radio'].includes(value),
|
||||
},
|
||||
/** Core's class on every option, beside `gev-segment`. */
|
||||
itemClass: { type: String, default: undefined },
|
||||
/** Core's class on the selected option. */
|
||||
activeClass: { type: String, default: 'active' },
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
// The option buttons, by index, for focus; not reactive state.
|
||||
const items = [];
|
||||
|
||||
const selectedIndex = computed(() =>
|
||||
props.options.findIndex((option) => option.value === props.modelValue),
|
||||
);
|
||||
const optionOff = (option) => props.disabled || !!option.disabled;
|
||||
/** The roving tab stop; with no selection, the first option. */
|
||||
const tabStop = computed(() =>
|
||||
props.disabled ? -1 : Math.max(0, selectedIndex.value),
|
||||
);
|
||||
function tabindex(index) {
|
||||
if (props.keyboard === 'none') return undefined;
|
||||
return index === tabStop.value ? 0 : -1;
|
||||
}
|
||||
|
||||
function choose(index) {
|
||||
const option = props.options[index];
|
||||
if (!option || optionOff(option)) return;
|
||||
emit('update:modelValue', option.value);
|
||||
}
|
||||
/**
|
||||
* Select the option `delta` away from `from`, wrapping past disabled ones, or
|
||||
* `to` an end. A key that lands where it started (Home on the first) only
|
||||
* moves focus.
|
||||
*/
|
||||
function move({ delta = 0, to = null, from }) {
|
||||
const count = props.options.length;
|
||||
if (!count || props.disabled) return;
|
||||
const enabled = props.options
|
||||
.map((option, index) => (option.disabled ? -1 : index))
|
||||
.filter((index) => index >= 0);
|
||||
if (!enabled.length) return;
|
||||
let target;
|
||||
if (to === 'first') target = enabled[0];
|
||||
else if (to === 'last') target = enabled.at(-1);
|
||||
else {
|
||||
target = from < 0 ? (delta > 0 ? -1 : count) : from;
|
||||
do target = (((target + delta) % count) + count) % count;
|
||||
while (props.options[target].disabled);
|
||||
}
|
||||
if (target !== from) choose(target);
|
||||
// Focus moves at once, as a radio group's does; the option is already there.
|
||||
items[target]?.focus();
|
||||
}
|
||||
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||
function onKey(event) {
|
||||
if (props.keyboard === 'none') return;
|
||||
// A radio group moves from the option holding focus, `arrows` from the
|
||||
// selection.
|
||||
const from =
|
||||
props.keyboard === 'radio'
|
||||
? items.indexOf(event.target)
|
||||
: selectedIndex.value;
|
||||
if (STEPS[event.key]) move({ delta: STEPS[event.key], from });
|
||||
else if (props.keyboard === 'radio' && event.key === 'Home')
|
||||
move({ to: 'first', from });
|
||||
else if (props.keyboard === 'radio' && event.key === 'End')
|
||||
move({ to: 'last', from });
|
||||
else return;
|
||||
event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="radiogroup"
|
||||
class="ui-segmented gev-segmented"
|
||||
:class="{ 'ui-segmented--stacked': props.stacked }"
|
||||
:aria-label="props.label"
|
||||
:aria-disabled="props.disabled ? 'true' : undefined"
|
||||
@keydown="onKey"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in props.options"
|
||||
:key="option.value"
|
||||
v-bind="option.attrs"
|
||||
:ref="(el) => (items[index] = el)"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="ui-segmented__item ui-focus gev-segment"
|
||||
:class="[
|
||||
props.itemClass,
|
||||
{
|
||||
'ui-segmented__item--on': option.value === props.modelValue,
|
||||
[props.activeClass]: option.value === props.modelValue,
|
||||
},
|
||||
]"
|
||||
:aria-checked="String(option.value === props.modelValue)"
|
||||
:aria-label="option.icon ? option.label : undefined"
|
||||
:title="option.icon ? option.label : undefined"
|
||||
:disabled="optionOff(option)"
|
||||
:tabindex="tabindex(index)"
|
||||
@click="choose(index)"
|
||||
><span
|
||||
v-if="option.icon"
|
||||
class="ui-segmented__icon material-symbols-outlined"
|
||||
aria-hidden="true"
|
||||
>{{ option.icon }}</span
|
||||
><template v-else>{{ option.label }}</template></button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { h, mergeProps, nextTick, withDirectives } from 'vue';
|
||||
import { vFieldValue } from '../rail/fieldValue.js';
|
||||
import { vRangeValue } from '../rail/rangeValue.js';
|
||||
|
||||
// A null model leaves the select as it is.
|
||||
const live = (el, binding) => {
|
||||
if (binding.value != null) vRangeValue.updated(el, binding);
|
||||
};
|
||||
const LIVE = { mounted: live, updated: live };
|
||||
|
||||
/** Put the model back on the select. */
|
||||
function takeModel(select, props) {
|
||||
const next = props.modelValue;
|
||||
if (next != null && select.value !== String(next))
|
||||
select.value = String(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's select, with v-model, options and disabled. Core renders the bare select: its
|
||||
* labels sit outside it or come as an `aria-label` attribute, so the attribute
|
||||
* pass translates them. Options come from `options` ({ value, label }) or,
|
||||
* where they carry their own attributes, the default slot. A change emits the
|
||||
* chosen value; a caller's own `@change` still reaches the select.
|
||||
*
|
||||
* The select takes the model's value only when it differs (`v-range-value`),
|
||||
* after its options are in place; a null model leaves it as it is. With
|
||||
* `hold`, it never takes it while focused (`v-field-value`), and takes it back
|
||||
* when it loses focus. Without `hold`, it takes the model back once the
|
||||
* change's render has run: an owner that refuses a pick republishes the same
|
||||
* value, which renders nothing, so this puts the refused pick back. The
|
||||
* owners of these selects answer synchronously. Like UiSlider it is a functional render-function
|
||||
* component. Not mirrored yet: `label` and `hideLabel`.
|
||||
*/
|
||||
export default function UiSelect(props, { attrs, emit, slots }) {
|
||||
const options = props.options
|
||||
? props.options.map((option) =>
|
||||
h('option', { key: option.value, value: option.value }, option.label),
|
||||
)
|
||||
: slots.default?.();
|
||||
const select = h(
|
||||
'select',
|
||||
mergeProps(attrs, {
|
||||
disabled: props.disabled,
|
||||
onChange: (event) => {
|
||||
const select = event.target;
|
||||
emit('update:modelValue', select.value);
|
||||
if (!props.hold) void nextTick(() => takeModel(select, props));
|
||||
},
|
||||
onBlur: props.hold
|
||||
? (event) => takeModel(event.target, props)
|
||||
: undefined,
|
||||
}),
|
||||
options,
|
||||
);
|
||||
return withDirectives(select, [
|
||||
[props.hold ? vFieldValue : LIVE, props.modelValue],
|
||||
]);
|
||||
}
|
||||
|
||||
UiSelect.props = {
|
||||
modelValue: { type: [String, Number], default: undefined },
|
||||
options: { type: Array, default: undefined },
|
||||
disabled: { type: Boolean, default: false },
|
||||
hold: { type: Boolean, default: false },
|
||||
};
|
||||
UiSelect.emits = ['update:modelValue'];
|
||||
UiSelect.inheritAttrs = false;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let vue;
|
||||
let UiSelect;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
vue = await import('vue');
|
||||
UiSelect = (await import('./UiSelect.js')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'a', label: 'Alpha' },
|
||||
{ value: 'b', label: 'Bravo' },
|
||||
{ value: 'c', label: 'Charlie' },
|
||||
];
|
||||
|
||||
function mountSelect(props = {}, attrs = {}) {
|
||||
return mount(UiSelect, {
|
||||
attachTo: document.body,
|
||||
props: { options: OPTIONS, modelValue: 'b', ...props },
|
||||
attrs,
|
||||
});
|
||||
}
|
||||
|
||||
test('a bare select with the caller’s attributes and the model chosen', () => {
|
||||
const wrapper = mountSelect(
|
||||
{},
|
||||
{ id: 'pick', class: 'gev-field', 'aria-label': 'Pick' },
|
||||
);
|
||||
const select = wrapper.get('select');
|
||||
assert.equal(select.attributes('class'), 'gev-field');
|
||||
assert.equal(select.attributes('id'), 'pick');
|
||||
assert.equal(select.attributes('aria-label'), 'Pick');
|
||||
assert.equal(select.attributes('value'), undefined);
|
||||
assert.deepEqual(
|
||||
[...select.element.options].map((o) => [o.value, o.textContent]),
|
||||
[
|
||||
['a', 'Alpha'],
|
||||
['b', 'Bravo'],
|
||||
['c', 'Charlie'],
|
||||
],
|
||||
);
|
||||
assert.equal(select.element.value, 'b');
|
||||
assert.equal(select.element.disabled, false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a change emits the value, and a caller’s own change still runs', async () => {
|
||||
const seen = [];
|
||||
const wrapper = mountSelect(
|
||||
{},
|
||||
{ onChange: (event) => seen.push(event.target.value) },
|
||||
);
|
||||
const select = wrapper.get('select');
|
||||
select.element.value = 'c';
|
||||
await select.trigger('change');
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c']]);
|
||||
assert.deepEqual(seen, ['c']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a refused pick goes back to the model', async () => {
|
||||
const { nextTick } = vue;
|
||||
const wrapper = mountSelect();
|
||||
const select = wrapper.get('select');
|
||||
select.element.value = 'c';
|
||||
await select.trigger('change');
|
||||
await nextTick();
|
||||
assert.equal(select.element.value, 'b');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('the model picks the option, and a null model leaves the select', async () => {
|
||||
const wrapper = mountSelect();
|
||||
const select = wrapper.get('select').element;
|
||||
await wrapper.setProps({ modelValue: 'c' });
|
||||
assert.equal(select.value, 'c');
|
||||
select.value = 'a';
|
||||
await wrapper.setProps({ modelValue: null, disabled: true });
|
||||
assert.equal(select.value, 'a');
|
||||
assert.equal(select.disabled, true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a held select keeps a focused pick and takes the model back on blur', async () => {
|
||||
const wrapper = mountSelect({ hold: true });
|
||||
const select = wrapper.get('select').element;
|
||||
select.focus();
|
||||
select.value = 'a';
|
||||
await wrapper.setProps({ modelValue: 'c' });
|
||||
assert.equal(select.value, 'a');
|
||||
select.blur();
|
||||
await wrapper.get('select').trigger('blur');
|
||||
assert.equal(select.value, 'c');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('slotted options render, and the model applies once they arrive', async () => {
|
||||
const { defineComponent, h, ref, nextTick } = vue;
|
||||
const options = ref(['x']);
|
||||
const model = ref('x');
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(
|
||||
UiSelect,
|
||||
{ modelValue: model.value },
|
||||
{
|
||||
default: () =>
|
||||
options.value.map((value) =>
|
||||
h('option', { key: value, value, 'data-i18n': '' }, value),
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
const select = wrapper.get('select').element;
|
||||
assert.equal(select.options[0].hasAttribute('data-i18n'), true);
|
||||
options.value = ['x', 'y'];
|
||||
model.value = 'y';
|
||||
await nextTick();
|
||||
assert.equal(select.options.length, 2);
|
||||
assert.equal(select.value, 'y');
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { h, mergeProps, normalizeClass, withDirectives } from 'vue';
|
||||
import { vFieldValue } from '../rail/fieldValue.js';
|
||||
import { vRangeValue } from '../rail/rangeValue.js';
|
||||
|
||||
// Server-rendered markup carries the model as the input's value.
|
||||
const ssrValue = ({ value }) => (value == null ? {} : { value });
|
||||
// A null model leaves the input as it is.
|
||||
const live = (el, binding) => {
|
||||
if (binding.value != null) vRangeValue.updated(el, binding);
|
||||
};
|
||||
const LIVE = { mounted: live, updated: live, getSSRProps: ssrValue };
|
||||
const HOLD = { ...vFieldValue, getSSRProps: ssrValue };
|
||||
|
||||
/** Put the model back on a held input that lost focus. */
|
||||
function takeModel(input, props) {
|
||||
const next = props.modelValue;
|
||||
if (next != null && input.value !== String(next)) input.value = String(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's slider, with v-model, min, max, step and disabled. Core keeps the
|
||||
* native range input (its keyboard, form and styling) rather than drawing a
|
||||
* `role="slider"` track. The input carries Core's `gev-quantitative-slider` class ahead of
|
||||
* the caller's (`unstyled` leaves it off, for a range drawn as the browser's
|
||||
* own), the caller's attributes come first, as in the markup this replaced,
|
||||
* and the accessible name comes as an `aria-label` attribute so the attribute
|
||||
* pass translates it. The value emits as a number, and a caller's own input,
|
||||
* change or blur handler still reaches the input.
|
||||
*
|
||||
* The input takes the model's value only when it differs (`v-range-value`),
|
||||
* and a null model leaves it as it is;
|
||||
* with `hold`, never while it is focused (`v-field-value`), so a render
|
||||
* cannot move a handle a person is dragging, and it takes the model back
|
||||
* when it loses focus: a model left unchanged renders nothing. Owners take
|
||||
* every value their slider can reach, some only after a delay, so an input
|
||||
* is never put back on its own. It is a functional component written as a
|
||||
* render function, so portable entries need no component compiler. Not
|
||||
* mirrored yet: `label`, `format`, `markers` and `endLabels`.
|
||||
*/
|
||||
export default function UiSlider(props, { attrs, emit }) {
|
||||
const { class: className, ...rest } = attrs;
|
||||
const classes = normalizeClass([
|
||||
props.unstyled ? null : 'gev-quantitative-slider',
|
||||
className,
|
||||
]);
|
||||
return withDirectives(
|
||||
h(
|
||||
'input',
|
||||
mergeProps(rest, {
|
||||
class: classes || undefined,
|
||||
type: 'range',
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
step: props.step,
|
||||
disabled: props.disabled,
|
||||
onInput: (event) =>
|
||||
emit('update:modelValue', Number(event.target.value)),
|
||||
onBlur: props.hold
|
||||
? (event) => takeModel(event.target, props)
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
[[props.hold ? HOLD : LIVE, props.modelValue]],
|
||||
);
|
||||
}
|
||||
|
||||
UiSlider.props = {
|
||||
modelValue: { type: [Number, String], default: undefined },
|
||||
min: { type: [Number, String], required: true },
|
||||
max: { type: [Number, String], required: true },
|
||||
step: { type: [Number, String], default: undefined },
|
||||
disabled: { type: Boolean, default: false },
|
||||
hold: { type: Boolean, default: false },
|
||||
unstyled: { type: Boolean, default: false },
|
||||
};
|
||||
UiSlider.emits = ['update:modelValue'];
|
||||
UiSlider.inheritAttrs = false;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiSlider;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiSlider = (await import('./UiSlider.js')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
function mountSlider(props = {}, attrs = {}) {
|
||||
return mount(UiSlider, {
|
||||
attachTo: document.body,
|
||||
props: { min: 0, max: 100, modelValue: 40, ...props },
|
||||
attrs,
|
||||
});
|
||||
}
|
||||
|
||||
test('a native range input with Core’s class beside the caller’s', () => {
|
||||
const wrapper = mountSlider(
|
||||
{ step: 5 },
|
||||
{ id: 'vol', class: 'pp-slider', 'aria-label': 'Volume' },
|
||||
);
|
||||
const input = wrapper.get('input');
|
||||
assert.equal(input.attributes('type'), 'range');
|
||||
assert.equal(input.attributes('class'), 'gev-quantitative-slider pp-slider');
|
||||
assert.equal(input.attributes('id'), 'vol');
|
||||
assert.equal(input.attributes('aria-label'), 'Volume');
|
||||
assert.equal(input.attributes('step'), '5');
|
||||
assert.equal(input.attributes('value'), undefined);
|
||||
assert.equal(input.element.value, '40');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('no step attribute unless one is given, and disabled reaches the input', () => {
|
||||
const wrapper = mountSlider({ disabled: true });
|
||||
const input = wrapper.get('input');
|
||||
assert.equal(input.attributes('step'), undefined);
|
||||
assert.equal(input.element.disabled, true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a drag emits the value as a number', async () => {
|
||||
const wrapper = mountSlider({ min: 0.25, max: 4, step: 0.25, modelValue: 1 });
|
||||
const input = wrapper.get('input');
|
||||
input.element.value = '2.5';
|
||||
await input.trigger('input');
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [[2.5]]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('the model moves the handle, even while focused, unless it holds', async () => {
|
||||
const live = mountSlider();
|
||||
live.element.focus();
|
||||
await live.setProps({ modelValue: 70 });
|
||||
assert.equal(live.element.value, '70');
|
||||
live.unmount();
|
||||
|
||||
const held = mountSlider({ hold: true });
|
||||
held.element.focus();
|
||||
held.element.value = '55';
|
||||
await held.setProps({ modelValue: 70 });
|
||||
assert.equal(held.element.value, '55');
|
||||
held.element.blur();
|
||||
await held.setProps({ modelValue: 80 });
|
||||
assert.equal(held.element.value, '80');
|
||||
held.unmount();
|
||||
});
|
||||
|
||||
test('a held slider with no model keeps its value', async () => {
|
||||
const wrapper = mountSlider({ hold: true, modelValue: 30 });
|
||||
await wrapper.setProps({ modelValue: null });
|
||||
assert.equal(wrapper.element.value, '30');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a held handle takes the model back when it loses focus', async () => {
|
||||
const wrapper = mountSlider({ hold: true, modelValue: 60 });
|
||||
const input = wrapper.get('input');
|
||||
input.element.focus();
|
||||
input.element.value = '35';
|
||||
await input.trigger('input');
|
||||
await wrapper.setProps({ modelValue: 60 });
|
||||
assert.equal(input.element.value, '35');
|
||||
input.element.blur();
|
||||
await input.trigger('blur');
|
||||
assert.equal(input.element.value, '60');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('an input stays until the owner answers, however late', async () => {
|
||||
for (const hold of [false, true]) {
|
||||
const wrapper = mountSlider({ hold, modelValue: 40 });
|
||||
const input = wrapper.get('input');
|
||||
input.element.value = '70';
|
||||
await input.trigger('input');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(input.element.value, '70', `hold: ${hold}`);
|
||||
await wrapper.setProps({ modelValue: 70 });
|
||||
assert.equal(input.element.value, '70', `hold: ${hold}`);
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
test('a caller’s own input, change and blur handlers still run', async () => {
|
||||
const seen = [];
|
||||
const wrapper = mountSlider(
|
||||
{},
|
||||
{
|
||||
onInput: (event) => seen.push(['input', event.target.value]),
|
||||
onChange: () => seen.push(['change']),
|
||||
onBlur: () => seen.push(['blur']),
|
||||
},
|
||||
);
|
||||
const input = wrapper.get('input');
|
||||
input.element.value = '55';
|
||||
await input.trigger('input');
|
||||
await input.trigger('change');
|
||||
await input.trigger('blur');
|
||||
assert.deepEqual(seen, [['input', '55'], ['change'], ['blur']]);
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [[55]]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a null model leaves the input, and unstyled leaves Core’s class off', async () => {
|
||||
const wrapper = mountSlider({ modelValue: null, unstyled: true });
|
||||
const input = wrapper.get('input');
|
||||
assert.equal(input.attributes('class'), undefined);
|
||||
input.element.value = '12';
|
||||
await wrapper.setProps({ disabled: true });
|
||||
assert.equal(input.element.value, '12');
|
||||
await wrapper.setProps({ unstyled: false });
|
||||
assert.equal(input.attributes('class'), 'gev-quantitative-slider');
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -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