Skip to content

Commit 3be049b

Browse files
committed
feat(web): add shared menu, switch and chip primitives
Every anchored menu in the web app writes its own flip and clamp logic, and no two lists agree on row height, radius or text size. Add `Popover`, `MenuRow`, `SwitchToggle` and `Chip` under `components/ui/`. `Popover` carries the positioning behaviour that `OpenInMenu` already proved: a 4px offset, below-first placement, a flip above when there is no room, and a viewport clamp. `MenuRow` is the standard row, sized from `--ui-font-size` rather than a fixed pixel height so the font-size setting keeps working. Existing callers are left alone; they move onto these in a later change. The four files style themselves only from theme tokens, so all three themes stay coherent in both colour schemes. A guard test reads every file under `components/ui/` and fails on a `dark:` utility or a colour literal.
1 parent f97b801 commit 3be049b

6 files changed

Lines changed: 677 additions & 0 deletions

File tree

.changeset/web-ui-primitives.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Add four shared UI primitives to the web app: `Popover`, `MenuRow`, `SwitchToggle` and `Chip`. `Popover` holds the anchored-menu positioning that each menu used to write for itself, including the flip above the trigger and the viewport clamp. `MenuRow` carries the standard list row, sized from `--ui-font-size` so the font-size setting still scales it. All four style themselves only from theme tokens, and a guard test fails on any colour literal.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<script setup lang="ts">
2+
type ChipVariant = 'neutral' | 'active';
3+
4+
const props = withDefaults(defineProps<{
5+
label?: string;
6+
variant?: ChipVariant;
7+
}>(), {
8+
variant: 'neutral',
9+
});
10+
11+
const emit = defineEmits<{
12+
click: [event: MouseEvent];
13+
}>();
14+
</script>
15+
16+
<template>
17+
<button
18+
type="button"
19+
class="chip"
20+
:class="variant"
21+
@click="emit('click', $event)"
22+
>
23+
<span class="icon" aria-hidden="true">
24+
<span v-if="$slots.icon" class="icon-default"><slot name="icon" /></span>
25+
<span class="close-glyph">×</span>
26+
</span>
27+
<span v-if="label || $slots.label || $slots.default" class="label">
28+
<slot name="label">{{ label }}<slot /></slot>
29+
</span>
30+
</button>
31+
</template>
32+
33+
<style scoped>
34+
.chip {
35+
display: inline-flex;
36+
align-items: center;
37+
gap: 6px;
38+
max-width: 100%;
39+
box-sizing: border-box;
40+
padding: 6px;
41+
border: 1px solid var(--line);
42+
border-radius: 999px;
43+
background: none;
44+
color: var(--ink);
45+
font: inherit;
46+
cursor: pointer;
47+
}
48+
49+
.chip.neutral {
50+
background: var(--panel);
51+
}
52+
53+
.chip.active {
54+
border-color: color-mix(in srgb, var(--blue) 30%, var(--line));
55+
background: color-mix(in srgb, var(--soft) 60%, var(--panel));
56+
color: var(--blue);
57+
}
58+
59+
.chip:hover {
60+
background: var(--hover);
61+
}
62+
63+
.icon {
64+
display: inline-flex;
65+
align-items: center;
66+
justify-content: center;
67+
flex: 0 0 16px;
68+
width: 16px;
69+
height: 16px;
70+
line-height: 1;
71+
}
72+
73+
.icon-default :deep(svg) {
74+
display: block;
75+
width: 16px;
76+
height: 16px;
77+
}
78+
79+
.close-glyph {
80+
display: none;
81+
font-size: var(--ui-font-size-lg);
82+
line-height: 1;
83+
}
84+
85+
.chip:hover .icon-default {
86+
display: none;
87+
}
88+
89+
.chip:hover .close-glyph {
90+
display: inline;
91+
}
92+
93+
.label {
94+
min-width: 0;
95+
max-width: 160px;
96+
overflow: hidden;
97+
text-overflow: ellipsis;
98+
white-space: nowrap;
99+
}
100+
</style>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<script setup lang="ts">
2+
withDefaults(defineProps<{
3+
count?: number;
4+
active?: boolean;
5+
selected?: boolean;
6+
disabled?: boolean;
7+
}>(), {
8+
active: false,
9+
selected: false,
10+
disabled: false,
11+
});
12+
</script>
13+
14+
<template>
15+
<button
16+
type="button"
17+
class="menu-row"
18+
:class="{ active, selected, disabled }"
19+
:disabled="disabled"
20+
>
21+
<span v-if="$slots.leading" class="leading"><slot name="leading" /></span>
22+
<span class="label">
23+
<slot name="label"><slot /></slot>
24+
</span>
25+
<span v-if="count !== undefined" class="count">{{ count }}</span>
26+
<span v-if="$slots.trailing" class="trailing"><slot name="trailing" /></span>
27+
</button>
28+
</template>
29+
30+
<style scoped>
31+
.menu-row {
32+
/* Default 14px: 14 + 13 = 27px; 14 - 1 = 13px. */
33+
width: 100%;
34+
height: calc(var(--ui-font-size) + 13px);
35+
display: flex;
36+
align-items: center;
37+
gap: 8px;
38+
box-sizing: border-box;
39+
padding: 0 8px;
40+
border: 0;
41+
border-radius: var(--r-md);
42+
background: none;
43+
color: var(--ink);
44+
font-family: inherit;
45+
font-size: calc(var(--ui-font-size) - 1px);
46+
font-weight: 400;
47+
line-height: 1;
48+
text-align: left;
49+
cursor: pointer;
50+
}
51+
52+
.menu-row:hover {
53+
background: var(--hover);
54+
}
55+
56+
.menu-row.active,
57+
.menu-row.selected {
58+
background: color-mix(in srgb, var(--soft) 45%, var(--panel));
59+
}
60+
61+
.menu-row:focus-visible {
62+
outline: 2px solid var(--blue);
63+
outline-offset: -2px;
64+
}
65+
66+
.menu-row.disabled,
67+
.menu-row:disabled {
68+
opacity: 0.5;
69+
pointer-events: none;
70+
}
71+
72+
.leading {
73+
display: inline-flex;
74+
align-items: center;
75+
justify-content: center;
76+
flex: 0 0 14px;
77+
width: 14px;
78+
height: 14px;
79+
}
80+
81+
.leading :deep(svg) {
82+
display: block;
83+
width: 14px;
84+
height: 14px;
85+
}
86+
87+
.label {
88+
min-width: 0;
89+
overflow: hidden;
90+
text-overflow: ellipsis;
91+
white-space: nowrap;
92+
}
93+
94+
.count {
95+
flex: none;
96+
color: var(--muted);
97+
}
98+
99+
.trailing {
100+
display: inline-flex;
101+
align-items: center;
102+
justify-content: center;
103+
flex: none;
104+
margin-left: auto;
105+
}
106+
</style>
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<script setup lang="ts">
2+
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
3+
4+
type Alignment = 'start' | 'end';
5+
6+
const props = withDefaults(defineProps<{
7+
anchor: HTMLElement | null;
8+
open: boolean;
9+
align?: Alignment;
10+
}>(), {
11+
align: 'start',
12+
});
13+
14+
const emit = defineEmits<{
15+
close: [];
16+
}>();
17+
18+
const panelRef = ref<HTMLElement | null>(null);
19+
const panelStyle = ref<Record<string, string>>({});
20+
let opener: HTMLElement | null = null;
21+
let listenersAttached = false;
22+
23+
function positionPanel(): void {
24+
const anchor = props.anchor;
25+
const panel = panelRef.value;
26+
if (!anchor || !panel) return;
27+
28+
const anchorRect = anchor.getBoundingClientRect();
29+
const gap = 4;
30+
const margin = 16;
31+
const panelWidth = panel.offsetWidth;
32+
const panelHeight = panel.offsetHeight;
33+
const preferredLeft = props.align === 'end'
34+
? anchorRect.right - panelWidth
35+
: anchorRect.left;
36+
const maxLeft = Math.max(margin, window.innerWidth - margin - panelWidth);
37+
let left = Math.min(Math.max(preferredLeft, margin), maxLeft);
38+
let top = anchorRect.bottom + gap;
39+
40+
if (top + panelHeight > window.innerHeight - margin) {
41+
top = Math.max(margin, anchorRect.top - panelHeight - gap);
42+
}
43+
44+
const maxTop = Math.max(margin, window.innerHeight - margin - panelHeight);
45+
top = Math.min(Math.max(top, margin), maxTop);
46+
left = Math.min(Math.max(left, margin), maxLeft);
47+
panelStyle.value = {
48+
top: `${Math.round(top)}px`,
49+
left: `${Math.round(left)}px`,
50+
};
51+
}
52+
53+
function onViewportChange(): void {
54+
if (props.open) positionPanel();
55+
}
56+
57+
function onPointerDown(event: PointerEvent): void {
58+
const target = event.target;
59+
if (target instanceof Node && (panelRef.value?.contains(target) || props.anchor?.contains(target))) return;
60+
emit('close');
61+
}
62+
63+
function onKeydown(event: KeyboardEvent): void {
64+
if (event.key === 'Escape') emit('close');
65+
}
66+
67+
function attachListeners(): void {
68+
if (listenersAttached) return;
69+
document.addEventListener('pointerdown', onPointerDown);
70+
document.addEventListener('keydown', onKeydown);
71+
document.addEventListener('scroll', onViewportChange, true);
72+
window.addEventListener('resize', onViewportChange);
73+
listenersAttached = true;
74+
}
75+
76+
function detachListeners(): void {
77+
if (!listenersAttached) return;
78+
document.removeEventListener('pointerdown', onPointerDown);
79+
document.removeEventListener('keydown', onKeydown);
80+
document.removeEventListener('scroll', onViewportChange, true);
81+
window.removeEventListener('resize', onViewportChange);
82+
listenersAttached = false;
83+
}
84+
85+
function restoreFocusIfNeeded(): void {
86+
const panel = panelRef.value;
87+
const activeElement = document.activeElement;
88+
if (!panel || !(activeElement instanceof Node) || !panel.contains(activeElement)) return;
89+
90+
const target = props.anchor ?? opener;
91+
if (target?.isConnected) target.focus();
92+
}
93+
94+
watch(() => props.open, (open, wasOpen) => {
95+
if (open) {
96+
if (!wasOpen) {
97+
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
98+
}
99+
void nextTick(() => {
100+
if (!props.open) return;
101+
positionPanel();
102+
attachListeners();
103+
});
104+
return;
105+
}
106+
107+
restoreFocusIfNeeded();
108+
detachListeners();
109+
panelStyle.value = {};
110+
}, { immediate: true });
111+
112+
watch([() => props.anchor, () => props.align], () => {
113+
if (props.open) void nextTick(positionPanel);
114+
});
115+
116+
onBeforeUnmount(() => {
117+
restoreFocusIfNeeded();
118+
detachListeners();
119+
});
120+
</script>
121+
122+
<template>
123+
<Teleport to="body">
124+
<div
125+
v-if="open"
126+
ref="panelRef"
127+
class="popover"
128+
:style="panelStyle"
129+
role="menu"
130+
tabindex="-1"
131+
>
132+
<slot />
133+
</div>
134+
</Teleport>
135+
</template>
136+
137+
<style scoped>
138+
.popover {
139+
position: fixed;
140+
z-index: 200;
141+
box-sizing: border-box;
142+
max-width: calc(100vw - 32px);
143+
max-height: calc(100vh - 32px);
144+
overflow-y: auto;
145+
padding: 2px;
146+
border: 1px solid var(--line);
147+
border-radius: var(--r-md);
148+
background: var(--panel);
149+
box-shadow: 0 8px 24px color-mix(in srgb, var(--ink) 18%, transparent);
150+
}
151+
</style>

0 commit comments

Comments
 (0)