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:
Said Turksever committed 2026-11-02 16:43:29 +00:00
commit 4ebb287d36
699 files changed
+46673 -20476

No files matched your search

+49
View File
@@ -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();
});
+15
View File
@@ -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>
+102
View File
@@ -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"/);
});
+69
View File
@@ -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>
+28
View File
@@ -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();
});
+18
View File
@@ -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>
+16
View File
@@ -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>
+16
View File
@@ -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>
+73
View File
@@ -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>
+54
View File
@@ -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>
+145
View File
@@ -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,
);
});
+107
View File
@@ -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>
+119
View File
@@ -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();
});
+134
View File
@@ -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>
+67
View File
@@ -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;
+132
View File
@@ -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();
});
+77
View File
@@ -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;
+139
View File
@@ -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();
});
+50
View File
@@ -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>
+12
View File
@@ -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;