diff --git a/src/essence/Tools/MapControl/lib/FloatingPopover/FloatingPopover.tsx b/src/essence/Tools/MapControl/lib/FloatingPopover/FloatingPopover.tsx new file mode 100644 index 000000000..e37554c84 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/FloatingPopover/FloatingPopover.tsx @@ -0,0 +1,235 @@ +import React, { useLayoutEffect, useState, useRef, useEffect } from 'react' +import { createPortal } from 'react-dom' + +export interface FloatingPopoverProps { + anchorRef: React.RefObject + isOpen: boolean + onClose?: () => void + placement?: 'top' | 'bottom' | 'left' | 'right' + offset?: number + className?: string + /** Ties the anchor's aria-controls to this popover. */ + id?: string + /** Accessible name for the dialog. */ + label?: string + /** + * A dialog is a surface the user operates. A tooltip only describes its + * anchor: it takes no focus of its own and is named by the text it + * carries, so it needs neither a tab stop nor a label. + */ + role?: 'dialog' | 'tooltip' + /** + * Moves focus into the popover on open. Leave false for informational + * content, where taking focus off the trigger is disruptive. + */ + autoFocus?: boolean + children: React.ReactNode +} + +export const FloatingPopover: React.FC = ({ + anchorRef, + isOpen, + onClose, + placement = 'bottom', + offset = 8, + className = '', + id, + label, + role = 'dialog', + autoFocus = false, + children +}) => { + const popupRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0 }) + // Whether focus currently sits inside the popover. Read on close, when the + // portal's DOM is already detached and document.activeElement has fallen + // back to , so it can't be worked out from the DOM by then. + const focusInsideRef = useRef(false) + + useEffect(() => { + if (!isOpen) return + + const trackFocus = () => { + focusInsideRef.current = !!( + popupRef.current && + document.activeElement && + popupRef.current.contains(document.activeElement) + ) + } + + trackFocus() + document.addEventListener('focusin', trackFocus) + return () => document.removeEventListener('focusin', trackFocus) + }, [isOpen]) + + // Escape closes from anywhere, including while focus sits on the trigger. + useEffect(() => { + if (!isOpen || !onClose) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + // The portal renders at the end of document.body, so focus is placed and + // restored explicitly rather than following DOM order. + useEffect(() => { + if (!isOpen) return + const previouslyFocused = document.activeElement as HTMLElement | null + + if (autoFocus) { + const focusTarget = + popupRef.current?.querySelector( + 'input, button, [href], select, textarea, [tabindex]:not([tabindex="-1"])', + ) || popupRef.current + focusTarget?.focus() + } + + return () => { + // Only reclaim focus if the popover held it as it closed; a click + // elsewhere has already placed focus where the user wants it. + if (!focusInsideRef.current) return + focusInsideRef.current = false + + const anchor = anchorRef.current + const restoreTo = + anchor && document.contains(anchor) ? anchor : previouslyFocused + restoreTo?.focus() + } + }, [isOpen, autoFocus, anchorRef]) + + // Close on outside click + useEffect(() => { + if (!isOpen || !onClose) return + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + // If the anchor exists and the click is inside it, ignore (the button toggle will handle it) + const clickedInsideAnchor = anchorRef.current && anchorRef.current.contains(target) + // If the click is inside the popup itself, ignore + const clickedInsidePopup = popupRef.current && popupRef.current.contains(target) + + if (!clickedInsideAnchor && !clickedInsidePopup) { + onClose() + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen, onClose, anchorRef]) + + // Update position + useLayoutEffect(() => { + if (!isOpen) return + + const updatePosition = () => { + if (!anchorRef.current || !popupRef.current) return + + const anchorRect = anchorRef.current.getBoundingClientRect() + const popupRect = popupRef.current.getBoundingClientRect() + + let top = 0 + let left = 0 + + switch (placement) { + case 'top': + top = anchorRect.top - popupRect.height - offset + left = anchorRect.left + (anchorRect.width / 2) - (popupRect.width / 2) + break + case 'bottom': + top = anchorRect.bottom + offset + left = anchorRect.left + (anchorRect.width / 2) - (popupRect.width / 2) + break + case 'left': + top = anchorRect.top + (anchorRect.height / 2) - (popupRect.height / 2) + left = anchorRect.left - popupRect.width - offset + break + case 'right': + top = anchorRect.top + (anchorRect.height / 2) - (popupRect.height / 2) + left = anchorRect.right + offset + break + } + + // Simple viewport bounds checking + if (left < 8) left = 8 + if (top < 8) { + if (placement === 'top') { + top = anchorRect.bottom + offset + } else { + top = 8 + } + } + if (left + popupRect.width > window.innerWidth - 8) { + left = window.innerWidth - popupRect.width - 8 + } + if (top + popupRect.height > window.innerHeight - 8) { + if (placement === 'bottom') { + top = anchorRect.top - popupRect.height - offset + } else { + top = window.innerHeight - popupRect.height - 8 + } + } + + // Prevent React state updates if the position hasn't changed to avoid infinite loops + setPos(prev => { + if (Math.abs(prev.top - top) < 1 && Math.abs(prev.left - left) < 1) { + return prev + } + return { top, left } + }) + } + + updatePosition() + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + + // Content can change size after opening — a validation message + // appearing, a calendar switching to a longer month — which moves where + // the popover should sit relative to its anchor. + const contentObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(updatePosition) + : null + if (popupRef.current) contentObserver?.observe(popupRef.current) + + // Wait a tick and update again in case children render changed dimensions + const timeout = setTimeout(updatePosition, 0) + + return () => { + clearTimeout(timeout) + contentObserver?.disconnect() + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [isOpen, placement, offset, anchorRef]) + + if (!isOpen) return null + + return createPortal( + , + document.body + ) +} diff --git a/src/essence/Tools/MapControl/lib/FloatingPopover/index.ts b/src/essence/Tools/MapControl/lib/FloatingPopover/index.ts new file mode 100644 index 000000000..f0c557299 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/FloatingPopover/index.ts @@ -0,0 +1 @@ +export * from './FloatingPopover' diff --git a/src/essence/Tools/MapControl/lib/geo/MapControlBar/MapControlBar.tsx b/src/essence/Tools/MapControl/lib/geo/MapControlBar/MapControlBar.tsx index 1b3910af9..30f5f1e11 100644 --- a/src/essence/Tools/MapControl/lib/geo/MapControlBar/MapControlBar.tsx +++ b/src/essence/Tools/MapControl/lib/geo/MapControlBar/MapControlBar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useId, useRef, useState } from 'react' import type { BasemapStyle, GeocodeResult, @@ -6,9 +6,9 @@ import type { MapOverlayOpts, MapSubscribeHandlers, } from '../../types' -import { useOutsideClick } from '../../hooks/useOutsideClick' import { useDebouncedSearch } from '../../hooks/useDebouncedSearch' import { useMeasure } from '../../hooks/useMeasure' +import { FloatingPopover } from '../../FloatingPopover' import { BasemapPanel } from '../BasemapPanel/BasemapPanel' import { SearchPanel } from '../SearchPanel/SearchPanel' import { BasemapIcon, MinusIcon, PlusIcon, RulerIcon, SearchIcon } from '../icons' @@ -57,7 +57,11 @@ export function MapControlBar({ onSearchSelect, endSlot, }: MapControlBarProps) { - const rootRef = useRef(null) + const searchBtnRef = useRef(null) + const basemapBtnRef = useRef(null) + const measureBtnRef = useRef(null) + const searchPopoverId = useId() + const basemapPopoverId = useId() const [basemapOpen, setBasemapOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') @@ -72,23 +76,21 @@ export function MapControlBar({ }) const { results, loading } = useDebouncedSearch(searchOpen ? searchQuery : '') - useOutsideClick(rootRef, () => { - setBasemapOpen(false) - setSearchOpen(false) - }) - - // Escape closes panels and exits measure mode + // Escape exits measure mode. An open panel's own Escape handler fires on + // the same press (both listen on document), so the panel closes and + // measure exits together. useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key !== 'Escape') return - setBasemapOpen(false) - setSearchOpen(false) measure.stop() } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [measure.stop]) + const closeBasemap = useCallback(() => setBasemapOpen(false), []) + const closeSearch = useCallback(() => setSearchOpen(false), []) + // Clear the query when the search panel closes useEffect(() => { if (!searchOpen) setSearchQuery('') @@ -117,62 +119,78 @@ export function MapControlBar({ } return ( - <> -
-
- {onSearchSelect && ( -
- -
- )} - {hasStyles && ( -
- -
- )} - {measure.supported && ( -
- -
- )} - {hasZoom && ( -
- - - -
- )} - {endSlot} -
+
+
+ {onSearchSelect && ( +
+ +
+ )} + {hasStyles && ( +
+ +
+ )} + {measure.supported && ( +
+ +
+ )} + {hasZoom && ( +
+ + + +
+ )} + {endSlot} +
- {basemapOpen && hasStyles && ( + {/* Portaled to so the panel clears the floating panel card + the bar sits in, which clips its overflow. */} + {hasStyles && ( + - )} - - {searchOpen && ( - - )} - - - {measure.awaitingFirst && ( -
Click two points on the map
- )} -
- - + + )} + + {/* Portaled like the basemap panel. Focus moves into the surface on + open so the query field takes typing straight away. */} + + + +
) } diff --git a/src/essence/Tools/MapControl/lib/geo/SearchPanel/SearchPanel.tsx b/src/essence/Tools/MapControl/lib/geo/SearchPanel/SearchPanel.tsx index e4c0b6b4f..780cebc4d 100644 --- a/src/essence/Tools/MapControl/lib/geo/SearchPanel/SearchPanel.tsx +++ b/src/essence/Tools/MapControl/lib/geo/SearchPanel/SearchPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react' +import React from 'react' import type { GeocodeResult } from '../../types' export type SearchPanelProps = { @@ -10,18 +10,12 @@ export type SearchPanelProps = { } export function SearchPanel({ query, results, loading, onQueryChange, onSelect }: SearchPanelProps) { - const inputRef = useRef(null) - - // Auto-focus the input when the panel mounts (i.e. when search opens). - useEffect(() => { - inputRef.current?.focus() - }, []) - + // Presentational only: the surface hosting the panel places focus, which on + // open lands on the query field as the first focusable control here. return (
@@ -20,20 +28,24 @@ function Icon({ children }: { children: React.ReactNode }) { export function BasemapIcon() { return ( - + ) } export function SearchIcon() { return ( - + ) } export function RulerIcon() { return ( - + ) @@ -41,14 +53,22 @@ export function RulerIcon() { export function PlusIcon() { return ( - + ) } export function MinusIcon() { return ( - + ) } diff --git a/src/essence/Tools/MapControl/lib/hooks/useMeasure.ts b/src/essence/Tools/MapControl/lib/hooks/useMeasure.ts index e96ab41c9..ef263f9c5 100644 --- a/src/essence/Tools/MapControl/lib/hooks/useMeasure.ts +++ b/src/essence/Tools/MapControl/lib/hooks/useMeasure.ts @@ -20,7 +20,7 @@ export type MeasureState = { measuring: boolean /** Two-point segment being measured (or live preview), else null. */ segment: [LatLng, LatLng] | null - /** No points collected yet (show the hint). */ + /** Measuring, with no points collected yet. */ awaitingFirst: boolean toggle: () => void stop: () => void diff --git a/src/essence/Tools/MapControl/lib/hooks/useOutsideClick.ts b/src/essence/Tools/MapControl/lib/hooks/useOutsideClick.ts deleted file mode 100644 index 9439e4deb..000000000 --- a/src/essence/Tools/MapControl/lib/hooks/useOutsideClick.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from 'react' -import type { RefObject } from 'react' - -/** Calls onOutside when a mousedown lands outside the ref'd element. */ -export function useOutsideClick( - ref: RefObject, - onOutside: () => void, -): void { - useEffect(() => { - function onDown(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) onOutside() - } - document.addEventListener('mousedown', onDown) - return () => document.removeEventListener('mousedown', onDown) - }, [ref, onOutside]) -} diff --git a/src/essence/Tools/MapControl/lib/index.ts b/src/essence/Tools/MapControl/lib/index.ts index b4be39aff..8e3ebd401 100644 --- a/src/essence/Tools/MapControl/lib/index.ts +++ b/src/essence/Tools/MapControl/lib/index.ts @@ -2,6 +2,7 @@ export { MapControlBar, type MapControlBarProps } from './geo/MapControlBar/MapControlBar' export { BasemapPanel, type BasemapPanelProps } from './geo/BasemapPanel/BasemapPanel' export { SearchPanel, type SearchPanelProps } from './geo/SearchPanel/SearchPanel' +export { FloatingPopover, type FloatingPopoverProps } from './FloatingPopover' // Shared domain types export type { diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/basemap-panel.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/basemap-panel.scss index 6af3c2b44..01fe2d44a 100644 --- a/src/essence/Tools/MapControl/lib/styles/components-geo/basemap-panel.scss +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/basemap-panel.scss @@ -1,14 +1,15 @@ +/* Basemap Panel + The basemap list on a popover surface. FloatingPopover owns placement, + stacking and the gap to the trigger; only the surface chrome lives here. */ .blocks-basemap-panel { - margin-top: var(--theme-spacing-05, 0.25rem); min-width: 220px; max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; background: var(--theme-color-white, #ffffff); - border-radius: var(--theme-radius-lg, 0.5rem); - box-shadow: 0 4px 16px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); - font-family: var(--theme-font-ui); + border-radius: var(--theme-radius-0, 0); + font-family: var(--theme-font-ui, 'Inter Variable', 'Helvetica Neue', sans-serif); &__header { flex-shrink: 0; @@ -17,7 +18,7 @@ font-weight: var(--theme-font-weight-bold, 700); letter-spacing: 0.8px; text-transform: uppercase; - color: var(--theme-color-base, #71767a); + color: var(--theme-color-base, #77777a); } &__row { @@ -32,41 +33,50 @@ border: none; cursor: pointer; text-align: left; - transition: background 0.1s ease; + color: var(--theme-color-base, #77777a); + transition: background-color 0.1s ease, color 0.1s ease; &:hover { - background: var(--theme-color-base-lightest, #f1f3f6); + background: var(--theme-color-primary-lightest, #eaf0fd); + color: var(--theme-color-primary, #1c67e3); } - &--active { - background: var(--theme-color-base-lighter, #dfe1e2); + /* Core's global `*:focus { outline: none }` strips the native ring, so + the panel restores one for keyboard users. */ + &:focus-visible { + outline: var(--theme-border-width-md, 2px) solid var(--theme-color-primary, #1c67e3); + outline-offset: -2px; + } + + /* The selected basemap. Stated after :hover so pointing at the row that + is already selected keeps it filled. */ + &--active, + &--active:hover { + background: var(--theme-color-primary, #1c67e3); + color: var(--theme-color-white, #ffffff); .blocks-basemap-panel__label { - color: var(--theme-color-primary, #0e7482); font-weight: var(--theme-font-weight-semibold, 600); } - - .blocks-basemap-panel__thumb { - border-color: var(--theme-color-primary, #0e7482); - } } } + /* Takes the row's color so it follows the hover and selected states. */ &__label { flex: 1; font-size: var(--theme-font-size-2xs, 0.88rem); - color: var(--theme-color-ink, #1b1b1b); + color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + /* The selected row is filled, so the thumbnail carries no marker of its + own — it only shows the style it stands for. */ &__thumb { width: 44px; height: 28px; flex-shrink: 0; - border-radius: var(--theme-radius-sm, 2px); - border: 2px solid transparent; - box-shadow: 0 1px 3px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + border-radius: var(--theme-radius-0, 0); } } diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/map-control-bar.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/map-control-bar.scss index 2ac60f5cf..c6a6046a7 100644 --- a/src/essence/Tools/MapControl/lib/styles/components-geo/map-control-bar.scss +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/map-control-bar.scss @@ -1,31 +1,49 @@ +/* Map Control Bar + Grouped icon buttons over the map. Every value resolves against the theme's + exported --theme-* custom properties; the literal after each is the horizon + value, used only when no theme bundle is loaded. Control geometry (button + and divider sizes) has no token and is stated in px. + + Every surface is square-cornered: --theme-radius-0 is stated rather than + left to the initial value, so a UA or framework default cannot round it. */ +/* The element the tool mounts into. It spans the full width of the panel's + tool card so the bar below has room to align itself within it; without this + the host shrink-wraps the bar and alignment has no effect. */ +.mapControl-tool-host { + width: 100%; + flex: 1 1 auto; + min-width: 0; +} + .blocks-map-control { // Placement is owned by the host container (e.g. a floating panel zone); - // the bar only anchors its own dropdowns. + // the bar only anchors its own dropdowns. It spans its host and pins its + // contents to the right edge. position: relative; display: flex; flex-direction: column; align-items: flex-end; + width: 100%; pointer-events: auto; - font-family: var(--theme-font-ui); + font-family: var(--theme-font-ui, 'Inter Variable', 'Helvetica Neue', sans-serif); &__bar { display: flex; flex-direction: row; align-items: center; - gap: var(--theme-spacing-1, 0.5rem); + gap: var(--theme-spacing-05, 0.25rem); white-space: nowrap; } + /* Sizes to its buttons: each is a 36x36 square, so a group of two is + 72x36 plus its border. */ &__group { display: flex; flex-direction: row; align-items: center; - height: 36px; overflow: hidden; background: var(--theme-color-white, #ffffff); - border: 1px solid var(--theme-color-base-lighter, #dfe1e2); - border-radius: var(--theme-radius-md, 0.25rem); - box-shadow: 0 1px 3px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + border-radius: var(--theme-radius-0, 0); } &__btn { @@ -33,22 +51,34 @@ align-items: center; justify-content: center; width: 36px; - height: 100%; + height: 36px; padding: 0; border: none; cursor: pointer; flex-shrink: 0; background: transparent; - color: var(--theme-color-ink, #1b1b1b); - transition: background 0.12s ease, color 0.12s ease; + /* The glyph is drawn in currentColor, so this is the icon's color. */ + color: var(--theme-color-base-darker, #444447); + transition: background-color 0.12s ease, color 0.12s ease; &:hover { - background: var(--theme-color-base-lightest, #f1f3f6); + background: var(--theme-color-primary-lightest, #eaf0fd); + color: var(--theme-color-primary, #1c67e3); } - &--active { - color: var(--theme-color-primary, #0e7482); - background: var(--theme-color-base-lighter, #dfe1e2); + /* Core's global `*:focus { outline: none }` strips the native ring, so + the bar restores one for keyboard users. */ + &:focus-visible { + outline: var(--theme-border-width-md, 2px) solid var(--theme-color-primary, #1c67e3); + outline-offset: -2px; + } + + /* Filled while the button's panel is open or measure mode is on. Stated + after :hover so pointing at an already-on button keeps it filled. */ + &--active, + &--active:hover { + background: var(--theme-color-primary, #1c67e3); + color: var(--theme-color-white, #ffffff); } } @@ -56,39 +86,23 @@ width: 1px; height: 24px; flex-shrink: 0; - background: var(--theme-color-base-lighter, #dfe1e2); + background: var(--theme-color-base-lighter, #e3e3e3); } - // The hosted share control ("Share map") in the bar's end slot — the - // dropdown stays the component's own; only the trigger is restyled to the - // bar's dark filled-button design. + // The hosted share control ("Share map") in the bar's end slot. Its colors, + // square corners and interaction states come from the component's own + // tokens, which resolve to the same theme values the bar uses; only the + // boxed chrome and the 36px control height are the bar's to state. &__share { display: flex; align-items: center; + background: var(--theme-color-white, #ffffff); + border-radius: var(--theme-radius-0, 0); &.shareExport-tool-host .share-menu__trigger { - background: var(--theme-color-primary-darker, #162e51); - color: var(--theme-color-white, #ffffff); - font-family: var(--theme-font-ui); + height: 36px; font-size: var(--theme-font-size-2xs, 0.88rem); - border-radius: var(--theme-radius-md, 0.25rem); - box-shadow: 0 1px 3px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); - - &:hover { - background: var(--theme-color-primary-dark, #1a4480); - } } } - &__hint { - margin-top: var(--theme-spacing-05, 0.25rem); - align-self: flex-end; - padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem); - background: var(--theme-color-white, #ffffff); - border-radius: var(--theme-radius-md, 0.25rem); - box-shadow: 0 2px 8px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); - font-size: var(--theme-font-size-3xs, 0.75rem); - font-weight: var(--theme-font-weight-semibold, 600); - color: var(--theme-color-base-dark, #565c65); - } } diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/measure-label.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/measure-label.scss index 48cdc0be9..2a2c2942f 100644 --- a/src/essence/Tools/MapControl/lib/styles/components-geo/measure-label.scss +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/measure-label.scss @@ -3,12 +3,11 @@ // positions (and centers) itself — no positioning here. padding: var(--theme-spacing-05, 0.25rem) var(--theme-spacing-1, 0.5rem); background: var(--theme-color-white, #ffffff); - border-radius: var(--theme-radius-lg, 0.5rem); - box-shadow: 0 2px 8px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); - font-family: var(--theme-font-ui); + border-radius: var(--theme-radius-0, 0); + font-family: var(--theme-font-ui, 'Inter Variable', 'Helvetica Neue', sans-serif); font-size: var(--theme-font-size-3xs, 0.75rem); font-weight: var(--theme-font-weight-semibold, 600); - color: var(--theme-color-primary, #0e7482); + color: var(--theme-color-primary, #1c67e3); white-space: nowrap; pointer-events: none; z-index: 1003; diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/search-panel.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/search-panel.scss index 88d7a2e54..845c4b11f 100644 --- a/src/essence/Tools/MapControl/lib/styles/components-geo/search-panel.scss +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/search-panel.scss @@ -1,35 +1,41 @@ +/* Search Panel + The geocode query field and its results on a popover surface. FloatingPopover + owns placement, stacking and the gap to the trigger. */ .blocks-search-panel { - margin-top: var(--theme-spacing-05, 0.25rem); min-width: 220px; max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; background: var(--theme-color-white, #ffffff); - border-radius: var(--theme-radius-lg, 0.5rem); - box-shadow: 0 4px 16px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); - font-family: var(--theme-font-ui); + border-radius: var(--theme-radius-0, 0); + font-family: var(--theme-font-ui, 'Inter Variable', 'Helvetica Neue', sans-serif); &__row { flex-shrink: 0; padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem); } + /* Borderless: a filled ground marks the field instead, and focus is shown + by an outline rather than by moving a border color. */ &__input { width: 100%; box-sizing: border-box; padding: var(--theme-spacing-05, 0.25rem) var(--theme-spacing-1, 0.5rem); - border: 1px solid var(--theme-color-base-light, #a9aeb1); - border-radius: var(--theme-radius-md, 0.25rem); - background: var(--theme-color-white, #ffffff); - color: var(--theme-color-ink, #1b1b1b); - font-family: var(--theme-font-ui); + border: none; + /* Stated, not left to the initial value: Safari rounds type=search. */ + border-radius: var(--theme-radius-0, 0); + -webkit-appearance: none; + appearance: none; + background: var(--theme-color-base-lightest, #f6f6f6); + color: var(--theme-color-ink, #17171b); + font-family: inherit; font-size: var(--theme-font-size-2xs, 0.88rem); outline: none; &:focus { - border-color: var(--theme-color-primary, #0e7482); - box-shadow: 0 0 0 2px var(--theme-color-primary-light, #87bac1); + outline: var(--theme-border-width-md, 2px) solid var(--theme-color-primary, #1c67e3); + outline-offset: -2px; } } @@ -37,7 +43,7 @@ flex-shrink: 0; padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem); font-size: var(--theme-font-size-3xs, 0.75rem); - color: var(--theme-color-base, #71767a); + color: var(--theme-color-base, #77777a); } &__result { @@ -50,17 +56,27 @@ border: none; cursor: pointer; text-align: left; - transition: background 0.1s ease; + color: var(--theme-color-base, #77777a); + transition: background-color 0.1s ease, color 0.1s ease; &:hover { - background: var(--theme-color-base-lightest, #f1f3f6); + background: var(--theme-color-primary-lightest, #eaf0fd); + color: var(--theme-color-primary, #1c67e3); + } + + /* Core's global `*:focus { outline: none }` strips the native ring, so + the panel restores one for keyboard users. */ + &:focus-visible { + outline: var(--theme-border-width-md, 2px) solid var(--theme-color-primary, #1c67e3); + outline-offset: -2px; } } + /* Takes the result row's color so it follows the hover state. */ &__label { flex: 1; font-size: var(--theme-font-size-2xs, 0.88rem); - color: var(--theme-color-ink, #1b1b1b); + color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/essence/Tools/_shared/share/share-menu.scss b/src/essence/Tools/_shared/share/share-menu.scss index 86c37cb99..44d99425e 100644 --- a/src/essence/Tools/_shared/share/share-menu.scss +++ b/src/essence/Tools/_shared/share/share-menu.scss @@ -1,30 +1,33 @@ // ShareExport "Share map" control — a compact trigger button that opens a // small dropdown menu. The trigger is placed by the layout (e.g. a // float-top-right floating panel); this file only styles the control, it never -// positions it. Mirrors the AOI plugin's token convention: every literal -// carries an `--mmgis-*` fallback so a host theme can override it at :root -// without touching this file. Scoped entirely under the .shareExport-tool-host +// positions it. Mirrors the AOI plugin's token convention: every value carries +// an `--mmgis-*` override so a host theme can retheme it at :root without +// touching this file. Scoped entirely under the .shareExport-tool-host // root so it never leaks into core styles. The dropdown menu is portaled to // document.body (so the float zone's overflow clipping can't cut it off), so // the portal element carries the .shareExport-tool-host class too to keep the // token scope. .shareExport-tool-host { - // Tokens (host theme overrides --mmgis-* on :root). - --se-bg: var(--mmgis-surface, #ffffff); - --se-bg-hover: var(--mmgis-surface-muted, #f6f7f8); - --se-fg: var(--mmgis-text, #1b1b1b); - --se-fg-muted: var(--mmgis-text-muted, #565c65); - --se-accent: var(--mmgis-accent, #137480); - --se-border: var(--mmgis-border, #dfe1e2); - --se-radius-sm: var(--mmgis-radius-sm, 4px); - --se-radius-md: var(--mmgis-radius-md, 6px); - --se-shadow-pop: var(--mmgis-shadow-pop, 0 2px 6px rgba(0, 0, 0, 0.18)); + // Tokens, resolved in three steps: a host theme's --mmgis-* override, then + // the design system's --theme-* token, then a literal for when neither is + // loaded. Surfaces are square and unshadowed, separated by ground alone. + --se-bg: var(--mmgis-surface, var(--theme-color-white, #ffffff)); + --se-bg-hover: var( + --mmgis-surface-muted, + var(--theme-color-primary-lightest, #eaf0fd) + ); + --se-fg: var(--mmgis-text, var(--theme-color-base, #77777a)); + --se-fg-hover: var(--mmgis-accent, var(--theme-color-primary, #1c67e3)); + --se-fg-muted: var(--mmgis-text-muted, var(--theme-color-base, #77777a)); + --se-accent: var(--mmgis-accent, var(--theme-color-primary, #1c67e3)); + --se-border: var(--mmgis-border, var(--theme-color-base-lighter, #e3e3e3)); + --se-radius-sm: var(--mmgis-radius-sm, var(--theme-radius-0, 0)); + --se-radius-md: var(--mmgis-radius-md, var(--theme-radius-0, 0)); --se-font-body: var( --mmgis-font-body, - 'Source Sans Pro', - system-ui, - sans-serif + var(--theme-font-ui, 'Inter Variable', 'Helvetica Neue', sans-serif) ); --se-font-size-sm: var(--mmgis-font-size-sm, 13px); --se-font-size-md: var(--mmgis-font-size-md, 14px); @@ -49,9 +52,9 @@ display: inline-block; } -// The hosting tool card provides the chrome (white background, rounded -// corners), so the trigger itself is chromeless — a bordered/shadowed button -// inside the card reads as a double border. +// The hosting tool card provides the chrome (background and border), so the +// trigger itself is chromeless — a bordered button inside the card reads as a +// double border. .share-menu__trigger { display: inline-flex; align-items: center; @@ -67,33 +70,40 @@ font-weight: 600; line-height: 1; white-space: nowrap; - transition: background 0.15s ease; + transition: background-color 0.15s ease, color 0.15s ease; &:hover { background: var(--se-bg-hover); + color: var(--se-fg-hover); } &:focus-visible { outline: 2px solid var(--se-accent); outline-offset: -2px; } + + // Filled while its menu is open, matching the other map controls. + &[aria-expanded='true'], + &[aria-expanded='true']:hover { + background: var(--se-accent); + color: var(--theme-color-white, #ffffff); + } } // Portaled to document.body and anchored to the trigger with an inline // top/right computed from the trigger's bounding rect (see ), so // the float zone's overflow clipping can't cut the menu off. +// The surface is a plain list: no inset padding and no gaps, so each item +// spans it edge to edge and a hovered item reads as a full-width row rather +// than a pill floating inside a frame. .share-menu__dropdown { position: fixed; z-index: var(--mmgis-popup-z, 1200); - min-width: 200px; - padding: var(--se-space-1); + min-width: 220px; background: var(--se-bg); - border: 1px solid var(--se-border); border-radius: var(--se-radius-md); - box-shadow: var(--se-shadow-pop); display: flex; flex-direction: column; - gap: 1px; } .share-menu__item { @@ -101,21 +111,22 @@ align-items: center; gap: var(--se-space-2); width: 100%; - padding: 6px var(--se-space-2); // ~px-2 py-1.5 + padding: var(--se-space-2) var(--se-space-3); background: none; border: 0; border-radius: var(--se-radius-sm); color: var(--se-fg); cursor: pointer; font: inherit; - font-size: var(--se-font-size-sm); + font-size: var(--se-font-size-md); line-height: 1.2; text-align: left; white-space: nowrap; - transition: background 0.12s ease; + transition: background-color 0.12s ease, color 0.12s ease; &:hover { background: var(--se-bg-hover); + color: var(--se-fg-hover); } &:focus-visible { @@ -129,11 +140,13 @@ } } +// Takes the item's color so it follows the hover state rather than holding a +// muted tint the filled row would leave stranded. .share-menu__item-icon { flex: 0 0 auto; width: 16px; height: 16px; - color: var(--se-fg-muted); + color: inherit; } .share-menu__separator {