WindowFrame
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/window-frame.jsonA desktop product of the line draws its own title bar. With Tauri’s
decorations: false the system draws nothing, so everything it used to do is
the page’s: dragging the window by its bar, double-click to maximise, the
three buttons, and the edges you grab to resize. Each is small. The reason to
take them on at all is that a system title bar over an application title bar
costs a strip of every laptop screen for nothing — scheda made the trade
first, kilna copied it, and this is the copy.
TitleBar is the bar, assembled; ResizeEdges goes once at the root beside
it.
import { ResizeEdges, TitleBar } from '@/components/ui/window-frame'import { Tabs, TabsList, TabsTab } from '@/components/ui/tabs'
function Shell() { return ( <> <ResizeEdges /> <TitleBar labels={t('window', { returnObjects: true })} mark={<ProductMark />} actions={<Bell />}> <Tabs value={active} onValueChange={select}> <TabsList variant="bar" aria-label={t('openDocuments')}> {documents.map((doc) => ( <TabsTab key={doc.id} value={doc.id} onClose={() => close(doc.id)} closeLabel={t('close', { name: doc.name })}> {doc.name} </TabsTab> ))} </TabsList> </Tabs> </TitleBar> <Outlet /> </> )}The bar holds, left to right: the product’s mark, where the system put the
icon; whatever the window shows at the top — its open documents as
Tabs with variant="bar", a trail, or a title;
a stretch that exists only to be grabbed; the product’s own actions; and the
three buttons. Inside AppShell it goes in
the titlebar slot, whose row is --spacing-titlebar tall.
The stretch never closes. A window with twenty documents open would otherwise have no bar left to drag by, so the handle keeps a minimum width and the tabs scroll instead.
A centre is held at the window’s centre. center takes what belongs to
the whole window rather than to either end — the search box, most often. A
search placed between the two ends of a row moves every time a tab opens, and a
thing the hand reaches for without looking has to stay where it was. So with a
centre the bar is three columns, two equal sides around the middle, and each
side keeps a handle of its own. Without one it stays a single row and the
documents keep the whole width.
<TitleBar labels={labels} mark={<ProductMark />} center={<SearchField aria-label={t('search')} value={query} onValueChange={setQuery} shortcut={{ id: 'search', label: t('search'), keys: 'Mod+K' }} />}> …</TitleBar>For a bar the assembled one does not fit, the parts are exported:
WindowButtons, useTitleBarGestures() to spread on your own bar, and
useMaximized() for anything else that changes shape with the window.
Everything in the bar that is not a control is a handle. The gestures ignore a press that lands on a button, a link, a field, a menu, a tab or a dialog — the control has already handled it — and treat the rest of the bar as the thing you move the window by.
A drag starts on the first movement, not on the press. startDragging
hands the window to the system, which is what keeps snap layouts and
drag-to-edge working, but from that moment the webview stops seeing the
mouse. Calling it on pointerdown ate the second click of every double click,
and maximising never happened. So the press waits for four pixels of movement,
and dblclick, which the browser is the one qualified to detect, maximises.
The maximise button follows the window, not the last click. A window can
be maximised without our buttons — a drag to the top edge, the keyboard, a
snap layout — so useMaximized() asks the window and listens for resizes.
The button’s label switches to restore from the same answer, and
ResizeEdges draws nothing while it is true: a maximised window has no edges
to drag, and a strip left along the top would take the clicks meant for the
title bar.
The edges are geometry, not design. Eight strips of five pixels (ten at
the corners), positioned by inline style, invisible, above everything. They
are fixed to the viewport, which is where a window’s edges are; pass
className="absolute" to put them on a box instead, as the stand does.
Close asks; it does not destroy. Tauri’s close() emits
closeRequested before anything happens, so an unsaved-work guard listening
for that request sees the button exactly as it sees the system’s own close.
There is no onClose to override it with, on purpose: a second way to close
is a second place for the guard to be missed.
Outside Tauri it renders and does nothing. Every call goes through a check
for the Tauri bridge (window.__TAURI_INTERNALS__), so a browser, a test or
a storybook gets the chrome without a thrown error on the first click. The
labels are the only thing it needs from you, and all four are required so
they can be translated.
Props
Read from the types in ui/window-frame.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.
WindowButtons
The window controls, in the order Windows puts them.
| Prop | Type | Default | |
|---|---|---|---|
labelsrequired | { minimize: string; maximize: string; restore: string; close: string; } | What each button is called. Required, and deliberately without a default: a string this component invents is a string the product cannot translate. restore replaces maximize while the window is maximised. | |
className | string |
ResizeEdges
Invisible strips along the window's edges.
| Prop | Type | Default | |
|---|---|---|---|
className | string | Merged into every strip. fixed to the viewport by default, which is where a window's edges are; absolute puts them on the nearest positioned box instead, for a frame drawn inside a page. |
TitleBar
A frameless window's title bar, assembled.
| Prop | Type | Default | |
|---|---|---|---|
labelsrequired | { minimize: string; maximize: string; restore: string; close: string; } | The window buttons' names; see WindowButtons. | |
mark | ReactNode | The product's mark, at the left edge, where the system put the icon. | |
children | ReactNode | What the window shows at the top: its open documents as <Tabs><TabsList variant="bar">, a trail, or a title. | |
actions | ReactNode | The product's own controls, between the handle and the window buttons. | |
center | ReactNode | Something that belongs to the whole window rather than to either end - the search box, most often - held at the window's centre. | |
className | string |
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 25 rules applied and passed, and none failed. The accessibility report has every item.
Left to a person, where axe could not decide: color-contrast.
History
Each release that changed ui/window-frame.tsx - the file a product copies - newest first.
- v0.39.0
- feat let the frame go on paper, and keep a panel or a chart on one sheet
354d583
- feat let the frame go on paper, and keep a panel or a chart on one sheet
- v0.33.0
- feat a collapsed rail, a centred title bar, described sections
b91b8d6
- feat a collapsed rail, a centred title bar, described sections
- v0.31.0
- feat a window's open documents in its title bar
f9f5ef2
- feat a window's open documents in its title bar
- v0.29.0
- feat name the window's own chrome, so four products stop guessing
afc3ac0
- feat name the window's own chrome, so four products stop guessing
- v0.26.0
- feat add the frameless window's own chrome
0eee252
- feat add the frameless window's own chrome