C11: one legend collapse caret, and it says whether the legend is open
Three copies of the same control: SemanticTreeLegend's .stl-collapse-btn, and
two in LegendDesktop — the data-view caret and the detail-mode button, whose own
comment says it "Matches SemanticTreeLegend's .stl-collapse-btn look". It
matched the look. A control is not its look, and the copies lost both of the
parts that do not render:
· aria-expanded. A disclosure control that never reports its state leaves a
screen-reader user with a button called "toggle legend" and no way to learn
whether the legend is open. This is the same defect I fixed in LegendMobile
last week, in a component sitting in the same folder as the fix.
· the RTL arrow. STL carries .stl-collapse-btn:dir(rtl) svg{rotate(0deg)}
because a panel that slides off the other edge needs its caret to point the
other way. LegendDesktop has no :dir(rtl) rule at all, so in Arabic its
caret points AT the panel it would open.
The label also now names the ACTION rather than the control: collapseLegend /
expandLegend instead of a fixed "toggle legend". All three keys were already
translated in all eleven locales, so this adds no translation debt.
THE ARROW AND THE ARIA COME FROM ONE PROP. They drifted apart because they were
two independent pieces of markup that happened to agree; `open` now drives both,
so a future copy cannot get one right and the other wrong.
STATE STAYS WITH THE CALLER. Two consumers drive collapse from uiStore's
tri-state cascade (fullyOpen -> open -> collapsed) and one from a local boolean.
A shared control that quietly adopted either would have broken the other, so
this one owns no state and no persistence — it renders what it is told and emits
the intent.
TWO THINGS DELIBERATELY UNCHANGED. The detail-mode control was an INVISIBLE
28x28 hit target — transparent fill, no border, transparent colour — over the
panel's corner; making it suddenly visible is a look change nobody asked for, so
the slot keeps the position, the size and the invisibility, and only the
semantics improve. And LegendDesktop's four light/dark theme rules stay in
LegendDesktop: they are its themes, not the control's. They outrank the shared
base on specificity (0,3,0 and 0,2,0 against 0,1,0), so which sheet is injected
first does not decide the colour.
Caught while writing it: the first draft styled the caret with <style scoped>,
which defineCustomElement silently DROPS for any child loaded through
import.meta.glob — it would have rendered unstyled in every shadow root while
looking right in the source. That is what useShadowStyles exists for, and what
every sibling legend component already does.
This commit is contained in:
parent
3adeca1474
commit
28789b2c93
2 changed files with 110 additions and 19 deletions
93
library/components/legends/LegendCollapseCaret.vue
Normal file
93
library/components/legends/LegendCollapseCaret.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<script setup>
|
||||
/**
|
||||
* LegendCollapseCaret.vue — SHARED (@map/components/legends/LegendCollapseCaret.vue).
|
||||
*
|
||||
* The disclosure control that collapses a legend panel. SemanticTreeLegend had one, LegendDesktop
|
||||
* grew a second by eye — its own comment says "Matches SemanticTreeLegend's .stl-collapse-btn
|
||||
* look" — and matching a look is not the same as matching a control. The copy lost the two things
|
||||
* that are not visible:
|
||||
*
|
||||
* 1. aria-expanded. A disclosure control that never reports its state leaves a screen-reader
|
||||
* user with a button called "toggle legend" and no way to know whether the legend is open.
|
||||
* This is the same defect I fixed in LegendMobile, in a component that already had the fix
|
||||
* sitting beside it. STL's own header records why it was added there on 2026-09-21.
|
||||
* 2. The RTL arrow. STL carries `.stl-collapse-btn:dir(rtl) svg { transform: rotate(0deg) }`
|
||||
* because a panel that slides off the other edge needs its caret to point the other way. The
|
||||
* copy has no :dir(rtl) rule at all, so in Arabic it points AT the panel it would open.
|
||||
*
|
||||
* ★ THE LABEL SAYS WHICH WAY IT GOES. `aria.toggleLegend` names the control; collapseLegend /
|
||||
* expandLegend name the ACTION about to happen, which is what a caret with two states owes its
|
||||
* reader. All three keys exist in all eleven locales already, so this costs no new translation.
|
||||
*
|
||||
* ★ STATE IS THE CALLER'S. This component owns no open/closed state and no persistence: two of its
|
||||
* consumers drive the legend from uiStore's tri-state cascade (fullyOpen → open → collapsed) and
|
||||
* one from a local boolean, and a shared control that quietly adopted one of those would break the
|
||||
* other. It renders a state it is told about and emits the intent to change it.
|
||||
*/
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
||||
defineProps({
|
||||
// Whether the panel this controls is currently open. Drives the arrow AND aria-expanded, so the
|
||||
// two cannot drift — the defect above was possible only because they were separate.
|
||||
open: { type: Boolean, required: true },
|
||||
// t() from the consumer's own i18n. Passed in rather than imported: these components are mounted
|
||||
// inside several bundles' shadow roots and each carries its own i18n instance.
|
||||
t: { type: Function, required: true },
|
||||
})
|
||||
|
||||
defineEmits(['toggle'])
|
||||
|
||||
// ★ useShadowStyles, NOT <style scoped>. Vue's defineCustomElement injects styles from the ROOT
|
||||
// component only; a child loaded through import.meta.glob has its <style> block silently dropped,
|
||||
// which is the whole reason this composable exists. Every sibling legend component does this, and
|
||||
// the first draft of this file did not — it would have rendered an unstyled caret inside every
|
||||
// shadow root while looking perfectly correct in the source.
|
||||
useShadowStyles(`
|
||||
/* The dark-theme look both copies already had, to the pixel: 22x20, 5px radius, the same
|
||||
rgba(110,118,129,0.15) fill and #30363d border. Nothing here is new styling. */
|
||||
.legend-collapse-caret {
|
||||
background: rgba(110,118,129,0.15); border: 1px solid #30363d; border-radius: 5px;
|
||||
color: #8b949e; cursor: pointer;
|
||||
width: 22px; height: 20px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: color 0.1s, background 0.1s;
|
||||
outline: none; -webkit-tap-highlight-color: transparent; padding: 0;
|
||||
}
|
||||
.legend-collapse-caret:hover { color: #c9d1d9; background: rgba(59,70,61,0.18); border-color: #73A17F; }
|
||||
.legend-collapse-caret:focus-visible { outline: 2px solid #73A17F; outline-offset: 2px; }
|
||||
|
||||
/* THE ARROW IS DRIVEN BY THE PROP, not by a style the caller remembers to add. Open points back
|
||||
at the panel (180deg); closed points out towards where the panel will appear. */
|
||||
.legend-collapse-caret svg { transition: transform 0.18s; transform: rotate(0deg); }
|
||||
.legend-collapse-caret--open svg { transform: rotate(180deg); }
|
||||
|
||||
/* RTL: the panel slides off the other edge, so both directions swap. This is the rule the copy in
|
||||
LegendDesktop never had — additive :dir(rtl) overrides, as in VerticalTabsLayout and STL. */
|
||||
.legend-collapse-caret:dir(rtl) svg { transform: rotate(180deg); }
|
||||
.legend-collapse-caret--open:dir(rtl) svg { transform: rotate(0deg); }
|
||||
`, 'legend-collapse-caret')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="legend-collapse-caret"
|
||||
:class="{ 'legend-collapse-caret--open': open }"
|
||||
:title="open ? t('aria.collapseLegend') : t('aria.expandLegend')"
|
||||
:aria-label="open ? t('aria.collapseLegend') : t('aria.expandLegend')"
|
||||
:aria-expanded="String(open)"
|
||||
@click.stop="$emit('toggle')"
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* All runtime styles are injected via useShadowStyles above — a child component's <style>
|
||||
block is silently DROPPED inside a defineCustomElement shadow root. */
|
||||
</style>
|
||||
|
|
@ -13,6 +13,7 @@ import { retryableAsyncComponent } from '../../composables/retryableAsyncCompone
|
|||
import LegendRows from './LegendRows.vue'
|
||||
import PeopleGroupDetail from '../PeopleGroupDetail.vue'
|
||||
import ApproxPinNote from './ApproxPinNote.vue'
|
||||
import LegendCollapseCaret from './LegendCollapseCaret.vue'
|
||||
import { buildColocationIndex, colocatedWith } from '../../utils/colocation.js'
|
||||
// Async-import the heavier branches so the boot bundle stays lean.
|
||||
const LegendMobile = retryableAsyncComponent(() => import('./LegendMobile.vue'))
|
||||
|
|
@ -68,11 +69,6 @@ useShadowStyles(`
|
|||
LegendRows. Matches SemanticTreeLegend's .stl-collapse-btn look:
|
||||
22x20px, rounded-rect, subtle dark bg + border, right-chevron SVG
|
||||
that rotates 180deg when expanded. */
|
||||
.legend-collapse-caret{background:rgba(110,118,129,0.15);border:1px solid #30363d;border-radius:5px;color:#8b949e;cursor:pointer;width:22px;height:20px;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:color 0.1s,background 0.1s,transform 0.18s;outline:none;-webkit-tap-highlight-color:transparent;padding:0;}
|
||||
.legend-collapse-caret:focus{outline:none;}
|
||||
.legend-collapse-caret:hover{color:#c9d1d9;background:rgba(59,70,61,0.18);border-color:#73A17F;}
|
||||
.legend-collapse-caret svg{transition:transform 0.18s;transform:rotate(180deg);}
|
||||
.legend-collapse-caret.rotated svg{transform:rotate(0deg);}
|
||||
/* Light mode caret — mirrors STL's [data-theme="light"] .stl-collapse-btn */
|
||||
.legend-container:not(.legend-dark) .legend-collapse-caret{background:rgba(208,215,222,0.4);border-color:#d0d7de;color:#57606a;}
|
||||
.legend-container:not(.legend-dark) .legend-collapse-caret:hover{background:rgba(59,70,61,0.12);border-color:#3b463d;color:#3b463d;}
|
||||
|
|
@ -86,8 +82,14 @@ useShadowStyles(`
|
|||
above the PeopleGroupDetail sticky header (.detail-header z-index:10) so
|
||||
it is always tappable even when the detail panel scrolls. The button is
|
||||
only rendered in the detail-mode template branch (see <template> below). */
|
||||
.detail-collapse-btn{position:absolute;top:10px;inset-inline-start:10px;z-index:20;background:transparent;border:none;color:transparent;cursor:pointer;width:28px;height:28px;padding:0;outline:none;-webkit-tap-highlight-color:transparent;}
|
||||
.detail-collapse-btn:focus{outline:none;}
|
||||
/* The detail-mode collapse control was an INVISIBLE 28x28 hit target (transparent fill, no
|
||||
border, transparent colour) sitting over the panel's own corner. Keeping it invisible is
|
||||
deliberate: making it suddenly visible is a look change nobody asked for. The slot keeps the
|
||||
position and the 28x28 target; the shared caret inside it supplies the semantics the old
|
||||
bare <button> lacked — aria-expanded, a direction-aware label, a focus ring. */
|
||||
.detail-collapse-slot{position:absolute;top:10px;inset-inline-start:10px;z-index:20;width:28px;height:28px;display:flex;align-items:center;justify-content:center;}
|
||||
.detail-collapse-slot .legend-collapse-caret{background:transparent;border-color:transparent;color:transparent;width:28px;height:28px;}
|
||||
.detail-collapse-slot .legend-collapse-caret:focus-visible{outline:2px solid #73A17F;outline-offset:2px;}
|
||||
/* Inside the detail panel: leave a 32px left gutter on the title text so the
|
||||
absolute-positioned .detail-collapse-btn at top:10px/left:10px does not
|
||||
overlap the people-group name. Targets the .pg-name-lead inside any
|
||||
|
|
@ -385,12 +387,11 @@ onBeforeUnmount(() => {
|
|||
selected (LegendRows + its #title-caret slot are not on screen in
|
||||
this branch). Lives at top-left, mirroring the caret position in
|
||||
data-view, so collapse is always one tap away regardless of mode. -->
|
||||
<button
|
||||
v-if="legendMode === 'detail'"
|
||||
class="detail-collapse-btn"
|
||||
@click.stop="toggleCollapse"
|
||||
:aria-label="t('aria.toggleLegend')"
|
||||
></button>
|
||||
<!-- The detail-mode collapse control, third copy of the same caret and carrying the same two
|
||||
gaps. Same shared control, positioned by .detail-collapse-slot. -->
|
||||
<span v-if="legendMode === 'detail'" class="detail-collapse-slot">
|
||||
<LegendCollapseCaret :open="!isCollapsed" :t="t" @toggle="toggleCollapse" />
|
||||
</span>
|
||||
<button
|
||||
v-if="legendMode === 'detail'"
|
||||
class="detail-close-btn"
|
||||
|
|
@ -431,12 +432,9 @@ onBeforeUnmount(() => {
|
|||
@filter-click="handleRowFilterClick"
|
||||
>
|
||||
<template #title-caret>
|
||||
<button class="legend-collapse-caret" @click.stop="toggleCollapse" :aria-label="t('aria.toggleLegend')">
|
||||
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg);transform-origin:center"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- The caret is the shared control now: it carries aria-expanded and the RTL arrow,
|
||||
neither of which this copy had. State stays here; only the rendering moved. -->
|
||||
<LegendCollapseCaret :open="!isCollapsed" :t="t" @toggle="toggleCollapse" />
|
||||
</template>
|
||||
</LegendRows>
|
||||
<ApproxPinNote v-if="legendMode !== 'detail'" :dark="isDark" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue