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); + } + }); +});