From 3be049bc433226a4c440e7f77a64e795c3bdc4b7 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 17 Aug 2026 02:26:36 -0400 Subject: [PATCH] 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. --- .changeset/web-ui-primitives.md | 5 + apps/pythinker-web/src/components/ui/Chip.vue | 100 ++++++++ .../src/components/ui/MenuRow.vue | 106 +++++++++ .../src/components/ui/Popover.vue | 151 ++++++++++++ .../src/components/ui/SwitchToggle.vue | 92 ++++++++ apps/pythinker-web/test/ui-primitives.test.ts | 223 ++++++++++++++++++ 6 files changed, 677 insertions(+) create mode 100644 .changeset/web-ui-primitives.md create mode 100644 apps/pythinker-web/src/components/ui/Chip.vue create mode 100644 apps/pythinker-web/src/components/ui/MenuRow.vue create mode 100644 apps/pythinker-web/src/components/ui/Popover.vue create mode 100644 apps/pythinker-web/src/components/ui/SwitchToggle.vue create mode 100644 apps/pythinker-web/test/ui-primitives.test.ts diff --git a/.changeset/web-ui-primitives.md b/.changeset/web-ui-primitives.md new file mode 100644 index 00000000..52279761 --- /dev/null +++ b/.changeset/web-ui-primitives.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +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. diff --git a/apps/pythinker-web/src/components/ui/Chip.vue b/apps/pythinker-web/src/components/ui/Chip.vue new file mode 100644 index 00000000..7c2a6e31 --- /dev/null +++ b/apps/pythinker-web/src/components/ui/Chip.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/apps/pythinker-web/src/components/ui/MenuRow.vue b/apps/pythinker-web/src/components/ui/MenuRow.vue new file mode 100644 index 00000000..573a6cad --- /dev/null +++ b/apps/pythinker-web/src/components/ui/MenuRow.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/apps/pythinker-web/src/components/ui/Popover.vue b/apps/pythinker-web/src/components/ui/Popover.vue new file mode 100644 index 00000000..20d2893c --- /dev/null +++ b/apps/pythinker-web/src/components/ui/Popover.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/apps/pythinker-web/src/components/ui/SwitchToggle.vue b/apps/pythinker-web/src/components/ui/SwitchToggle.vue new file mode 100644 index 00000000..ed94338b --- /dev/null +++ b/apps/pythinker-web/src/components/ui/SwitchToggle.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/apps/pythinker-web/test/ui-primitives.test.ts b/apps/pythinker-web/test/ui-primitives.test.ts new file mode 100644 index 00000000..60d348d1 --- /dev/null +++ b/apps/pythinker-web/test/ui-primitives.test.ts @@ -0,0 +1,223 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { afterEach, describe, expect, it } from 'vitest'; + +import Chip from '../src/components/ui/Chip.vue'; +import MenuRow from '../src/components/ui/MenuRow.vue'; +import Popover from '../src/components/ui/Popover.vue'; +import SwitchToggle from '../src/components/ui/SwitchToggle.vue'; + +// Resolved from this file, not the cwd: the pre-push hook runs the suite from +// the repository root. +const uiDir = join(import.meta.dirname, '../src/components/ui'); + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(path) : [path]; + }); +} + +async function settle(): Promise { + await nextTick(); + await nextTick(); +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('MenuRow', () => { + it('renders its label and count when provided', () => { + const wrapper = mount(MenuRow, { + props: { count: 3 }, + slots: { label: 'Models' }, + }); + + expect(wrapper.text()).toContain('Models'); + expect(wrapper.find('.count').text()).toBe('3'); + }); + + it('omits the count element when count is not provided', () => { + const wrapper = mount(MenuRow, { slots: { label: 'Models' } }); + + expect(wrapper.find('.count').exists()).toBe(false); + }); + + it('applies the disabled state', () => { + const wrapper = mount(MenuRow, { + props: { disabled: true }, + slots: { label: 'Models' }, + }); + + expect(wrapper.find('button').attributes('disabled')).toBeDefined(); + expect(wrapper.find('button').classes()).toContain('disabled'); + }); +}); + +describe('SwitchToggle', () => { + it('reflects aria-checked and emits on click', async () => { + const wrapper = mount(SwitchToggle, { props: { modelValue: false } }); + + expect(wrapper.attributes('aria-checked')).toBe('false'); + await wrapper.trigger('click'); + expect(wrapper.emitted('update:modelValue')).toEqual([[true]]); + + await wrapper.setProps({ modelValue: true }); + expect(wrapper.attributes('aria-checked')).toBe('true'); + }); + + it('emits on keyboard activation', async () => { + const wrapper = mount(SwitchToggle, { props: { modelValue: false } }); + + await wrapper.trigger('keydown', { key: 'Enter' }); + await wrapper.trigger('keydown', { key: ' ' }); + + expect(wrapper.emitted('update:modelValue')).toEqual([[true], [true]]); + }); +}); + +describe('Chip', () => { + it('renders neutral and active variants and emits on click', async () => { + const neutral = mount(Chip, { + props: { label: 'Neutral', variant: 'neutral' }, + slots: { icon: '' }, + }); + const active = mount(Chip, { + props: { label: 'Active', variant: 'active' }, + slots: { icon: '' }, + }); + + expect(neutral.classes()).toContain('neutral'); + expect(active.classes()).toContain('active'); + expect(active.text()).toContain('Active'); + + await active.trigger('click'); + expect(active.emitted('click')).toHaveLength(1); + }); +}); + +describe('Popover', () => { + it('opens and closes with the open prop', async () => { + const anchor = document.createElement('button'); + document.body.append(anchor); + const wrapper = mount(Popover, { + attachTo: document.body, + props: { anchor, open: false }, + slots: { default: 'Menu' }, + }); + + expect(document.body.querySelector('[role="menu"]')).toBeNull(); + await wrapper.setProps({ open: true }); + expect(document.body.querySelector('[role="menu"]')?.textContent).toBe('Menu'); + await wrapper.setProps({ open: false }); + expect(document.body.querySelector('[role="menu"]')).toBeNull(); + }); + + it('flips above the anchor and clamps to the viewport', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 800 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); + const anchor = document.createElement('button'); + anchor.getBoundingClientRect = () => ({ + bottom: 720, + height: 20, + left: 700, + right: 760, + top: 700, + width: 60, + x: 700, + y: 700, + toJSON: () => ({}), + }); + document.body.append(anchor); + const wrapper = mount(Popover, { + attachTo: document.body, + props: { anchor, open: true }, + slots: { default: 'Menu' }, + }); + const panel = document.body.querySelector('[role="menu"]') as HTMLElement; + Object.defineProperty(panel, 'offsetWidth', { configurable: true, value: 100 }); + Object.defineProperty(panel, 'offsetHeight', { configurable: true, value: 80 }); + + await settle(); + + expect(readFileSync(join(uiDir, 'Popover.vue'), 'utf8')).toMatch(/position:\s*fixed/u); + expect(panel.style.top).toBe('616px'); + expect(panel.style.left).toBe('684px'); + wrapper.unmount(); + }); + + it('closes on Escape', async () => { + const anchor = document.createElement('button'); + document.body.append(anchor); + const wrapper = mount(Popover, { + attachTo: document.body, + props: { anchor, open: true }, + }); + + await settle(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('closes on outside pointerdown but not inside the panel', async () => { + const anchor = document.createElement('button'); + const outside = document.createElement('div'); + document.body.append(anchor, outside); + const wrapper = mount(Popover, { + attachTo: document.body, + props: { anchor, open: true }, + slots: { default: 'Menu' }, + }); + + await settle(); + const panel = document.body.querySelector('[role="menu"]') as HTMLElement; + panel.dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(wrapper.emitted('close')).toBeUndefined(); + + outside.dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('restores focus to the anchor only when the panel held focus', async () => { + const anchor = document.createElement('button'); + document.body.append(anchor); + anchor.focus(); + const wrapper = mount(Popover, { + attachTo: document.body, + props: { anchor, open: false }, + }); + + await wrapper.setProps({ open: true }); + await settle(); + const panel = document.body.querySelector('[role="menu"]') as HTMLElement; + panel.focus(); + await wrapper.setProps({ open: false }); + expect(document.activeElement).toBe(anchor); + + await wrapper.setProps({ open: true }); + await settle(); + const outside = document.createElement('input'); + document.body.append(outside); + outside.focus(); + await wrapper.setProps({ open: false }); + expect(document.activeElement).toBe(outside); + }); +}); + +describe('UI primitive theme guard', () => { + it('keeps every source file free of forbidden color and dark-mode literals', () => { + const files = sourceFiles(uiDir).toSorted(); + + expect(files.length).toBeGreaterThanOrEqual(4); + for (const file of files) { + const source = readFileSync(file, 'utf8'); + expect(source, file).not.toMatch(/\bdark:/u); + expect(source, file).not.toMatch(/#[\da-f]{3,8}\b/iu); + expect(source, file).not.toMatch(/\brgba?\s*\(/iu); + } + }); +});