mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(display): answer the Display controls through the store with UiSegmented
The Display panel, the dock's style buttons and the clean-view exit call the display store's actions, and DisplayBindings connects their handlers to it, replacing the bindDisplayControls listeners. The detection allocation and 3D model coverage choices render through a new kit UiSegmented, which takes v-model, options, label, stacked and disabled, and adds a keyboard policy, item classes and per-option attributes. Shell element fields and facade accessors that only the old listeners read are removed. The package boundaries now own UiSegmented and the UiPanel kit files, and the format scope drops the deleted test.
This commit is contained in:
1 parent
fc53682129
commit
3c3f8e3e67
24 files changed
+624
-562
No files matched your search
@@ -0,0 +1,97 @@
|
||||
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' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'End' });
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['a'], ['c']]);
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
import { computed, nextTick } from 'vue';
|
||||
|
||||
// Core's segmented control, with @halfpixel/ui-vue UiSegmented's
|
||||
// v-model, options, label, stacked and disabled so the two kits can merge.
|
||||
// Core 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, as in ui-vue) 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`. Not mirrored yet: an option's icon.
|
||||
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, wrapping past disabled ones, or `to` an end. */
|
||||
function move({ delta = 0, to = null }) {
|
||||
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 =
|
||||
selectedIndex.value < 0 ? (delta > 0 ? -1 : count) : selectedIndex.value;
|
||||
do target = (((target + delta) % count) + count) % count;
|
||||
while (props.options[target].disabled);
|
||||
}
|
||||
choose(target);
|
||||
// Focus follows the selection once the options re-render.
|
||||
nextTick(() => items[target]?.focus());
|
||||
}
|
||||
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||
function onKey(event) {
|
||||
if (props.keyboard === 'none') return;
|
||||
if (STEPS[event.key]) move({ delta: STEPS[event.key] });
|
||||
else if (props.keyboard === 'radio' && event.key === 'Home')
|
||||
move({ to: 'first' });
|
||||
else if (props.keyboard === 'radio' && event.key === 'End')
|
||||
move({ to: 'last' });
|
||||
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)"
|
||||
:disabled="optionOff(option)"
|
||||
:tabindex="tabindex(index)"
|
||||
@click="choose(index)"
|
||||
>{{ option.label }}</button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user