Skip to content

Commit 4009a39

Browse files
committed
Merge branch 'feat/web-ui-primitives'
2 parents b201e88 + 3be049b commit 4009a39

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)