Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 114 additions & 73 deletions AGENTS.md

Large diffs are not rendered by default.

33 changes: 32 additions & 1 deletion apps/catalog/app/components/app-header.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PublicInspectionReport } from '@toolpath/part-contracts'
import { forgetPart, rememberPart } from 'shared/part-session'
Expand Down Expand Up @@ -82,3 +82,34 @@ describe('the tabs', () => {
expect(screen.getByRole('link', { name: 'Parts' })).toHaveAttribute('href', '/parts')
})
})

/**
* **One setting with two states, so the kit's `Toggle`** (Paul, 2026-09-11:
* "the mm/in toggle should use the toggle component from @toolpath/ui"). It was
* two `Chip`s side by side, which is two buttons that happen to be drawn next
* to each other — the kit's control slides an indicator between the two and
* takes the keyboard with it.
*
* The group around it is load-bearing: the kit makes a two-item toggle a
* `role="switch"` and takes no name of its own, so without it the header offers
* a switch that says only "mm".
*/
describe('the unit control', () => {
it('is the kit toggle, named, with the current unit selected', () => {
render(<AppHeader unit="millimeters" onUnit={vi.fn()} toolCount={42} />)

const units = screen.getByRole('group', { name: 'Units' })
expect(within(units).getByRole('switch')).toBeVisible()
expect(within(units).getByRole('button', { name: 'mm' })).toHaveAttribute('data-selected')
expect(within(units).getByRole('button', { name: 'in' })).toHaveAttribute('data-unselected')
})

it('asks for the other unit when the other one is pressed', () => {
const onUnit = vi.fn()
render(<AppHeader unit="millimeters" onUnit={onUnit} toolCount={42} />)

fireEvent.click(screen.getByRole('button', { name: 'in' }))

expect(onUnit).toHaveBeenCalledWith('inches')
})
})
36 changes: 27 additions & 9 deletions apps/catalog/app/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NavLink, useNavigate, useParams, useSearchParams } from 'react-router'
import { Badge, IconButton, cn } from '@toolpath/ui'
import { Chip, ChipGroup } from 'components/chip'
import { Badge, IconButton, Toggle, cn } from '@toolpath/ui'
import { Chip } from 'components/chip'
import { ToolpathLogo } from 'components/toolpath-logo'
import { UNIT_ABBREVIATION, UNIT_SYSTEMS, type UnitSystem } from '@toolpath/tool-support'
import { MoonIcon, SunIcon, UploadSimpleIcon } from '@phosphor-icons/react'
Expand Down Expand Up @@ -78,13 +78,31 @@ export const AppHeader = ({ unit, onUnit, toolCount, onUploadPart }: AppHeaderPr
>
{theme === 'dark' ? <SunIcon aria-hidden="true" /> : <MoonIcon aria-hidden="true" />}
</IconButton>
<ChipGroup label="Units">
{UNIT_SYSTEMS.map((each) => (
<Chip key={each} pressed={each === unit} onClick={() => onUnit(each)}>
{UNIT_ABBREVIATION[each]}
</Chip>
))}
</ChipGroup>
{/*
**The kit's `Toggle`, not two chips** (Paul, 2026-09-11). Millimetres
or inches is one setting with two states, which is the control the kit
exports for exactly this — it slides an indicator between them and
handles the keyboard, where a pair of chips is two buttons that happen
to be drawn next to each other.

Labelled by the group around it, because the kit makes a two-item
toggle a `role="switch"` and takes no name of its own: without it the
header offers a switch that says only "mm".
*/}
<div role="group" className="mb-0.5" aria-label="Units">
<Toggle
size="sm"
value={unit}
onValueChange={(next) => onUnit(next as UnitSystem)}
className="text-2xs font-semibold dark:bg-black/25"
>
{UNIT_SYSTEMS.map((each) => (
<Toggle.Item key={each} value={each}>
{UNIT_ABBREVIATION[each]}
</Toggle.Item>
))}
</Toggle>
</div>
</div>
<nav className="flex gap-2 border-t border-zinc-900 px-6">
{/*
Expand Down
114 changes: 95 additions & 19 deletions apps/catalog/app/components/column-filter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
RangeFilter,
TermFilter,
compareOf,
menuRoom,
optionsMatching,
type Bound,
type Kind,
Expand Down Expand Up @@ -230,29 +229,78 @@ describe('the shape a stored bound has', () => {
})
})

/**
* How tall a box opened off a button is, and which way it opens.
*
* One rule for the filter menus and the column picker both: the picker ran off
* the bottom of the screen with its last columns unreachable (Paul,
* 2026-09-10), which is the defect the Type filter had a day earlier.
*/
describe('the room a menu opens into', () => {
it('takes the room under the button and opens downwards', () => {
expect(menuRoom({ top: 100, bottom: 130 }, 900)).toEqual({ upwards: false, height: 758 })
describe('the column picker', () => {
/**
* **The drag says where the row will land before it lands** (Paul,
* 2026-09-11: "the list should show a blue line (2px horizontal) where the
* item will be dropped to help the user see where it will go").
*
* Which edge is `shared/column-order.ts` § `dropEdge`, and its own tests tie
* that to where `movedTo` actually puts the row. What this pins is the wire:
* that a drag over a row draws the line, on the edge the rule names, on that
* row and no other — and that letting go anywhere puts it away.
*/
const picker = (order: ReadonlyArray<string>, onReorder = vi.fn()) => {
render(
<ColumnPicker
columns={order.map((code) => ({ code, label: code }))}
shown={[...order]}
onToggle={vi.fn()}
onReorder={onReorder}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Which columns to show' }))
const rows = screen.getByRole('group', { name: 'Columns' }).children
return {
rows,
lines: () => Array.from(document.querySelectorAll('[data-drop-edge]')),
grab: (code: string) =>
fireEvent.dragStart(screen.getByRole('button', { name: `Move ${code}` })),
release: (code: string) =>
fireEvent.dragEnd(screen.getByRole('button', { name: `Move ${code}` })),
}
}

it('draws one line, on the row under the pointer, while a column is dragged', () => {
const { rows, lines, grab } = picker(['a', 'b', 'c', 'd'])

expect(lines()).toHaveLength(0)

grab('a')
fireEvent.dragOver(rows[2]!)

expect(lines()).toHaveLength(1)
expect(rows[2]!.querySelector('[data-drop-edge]')).not.toBeNull()
})

it('opens upwards where what is left under the button is a strip', () => {
expect(menuRoom({ top: 700, bottom: 730 }, 900)).toEqual({ upwards: true, height: 688 })
/** Down lands after the row, up lands before it — `movedTo` decides which. */
it('puts the line under the row dragging down and over it dragging up', () => {
const { rows, lines, grab, release } = picker(['a', 'b', 'c', 'd'])

grab('a')
fireEvent.dragOver(rows[2]!)
expect(lines()[0]).toHaveAttribute('data-drop-edge', 'below')

release('a')
grab('d')
fireEvent.dragOver(rows[1]!)
expect(lines()[0]).toHaveAttribute('data-drop-edge', 'above')
})

/** A strip above and a strip below still opens downwards, and overhangs. */
it('never squeezes itself below the least height worth reading', () => {
expect(menuRoom({ top: 40, bottom: 70 }, 200)).toEqual({ upwards: false, height: 220 })
it('draws nothing over the row being dragged, and nothing once it is let go', () => {
const { rows, lines, grab, release } = picker(['a', 'b', 'c'])

grab('b')
fireEvent.dragOver(rows[1]!)
expect(lines()).toHaveLength(0)

fireEvent.dragOver(rows[0]!)
expect(lines()).toHaveLength(1)

release('b')
expect(lines()).toHaveLength(0)
})
})

describe('the column picker', () => {
it('keeps the pencil at the table header touch target size', () => {
render(
<ColumnPicker
Expand All @@ -268,6 +316,12 @@ describe('the column picker', () => {
/**
* The list scrolls inside the room the screen leaves it rather than running
* off the bottom of the page with its last columns out of reach.
*
* `--available-height` is the kit menu's own measurement of what its
* positioner found, which is what replaced a height this component used to
* work out for itself (Paul, 2026-09-11: "why aren't these menus just using
* the menu component from @toolpath/ui?"). A class rather than an inline
* style, because the number is the positioner's to write.
*/
it('scrolls inside a height the screen bounds', () => {
render(
Expand All @@ -282,7 +336,29 @@ describe('the column picker', () => {

const list = screen.getByRole('group', { name: 'Columns' })
expect(list).toHaveClass('overflow-y-auto')
expect(list.style.maxHeight).not.toBe('')
expect(list).toHaveClass('max-h-[var(--available-height)]')
})

/**
* **The box is the kit's, and so is the way out of it.** A picker that drew
* its own absolutely-positioned box was cut off by the card it stood in, and
* every part of the answer — the portal, the placing, Escape, a press
* outside — is what `Menu.Popover` is.
*/
it('opens the kit menu rather than a box of its own', () => {
render(
<ColumnPicker
columns={[{ code: 'DC', label: 'Diameter' }]}
shown={['DC']}
onToggle={vi.fn()}
/>,
)

fireEvent.click(screen.getByRole('button', { name: 'Which columns to show' }))

expect(
screen.getByRole('group', { name: 'Columns' }).closest('[data-base-ui-portal]'),
).not.toBe(null)
})
})

Expand Down
Loading