Skip to content

Commit ced4070

Browse files
authored
Feat/mui migration (#157)
* feat(ui): move Strata onto graphite-ui 0.6.0-rc.1 and its Material UI buttons Installs graphite-ui 0.6.0-rc.1 with its @mui/material and Emotion peers. Strata's Button, ButtonLink, ButtonAnchor, IconButton, IconLink and TextButton keep their props and now render graphite-ui's MUI-based Button and IconButton, so every call site gets the new buttons. * feat(ui): move Strata's action buttons onto the Material UI buttons Text-button actions in comments, icon picking, load errors, category chips, database views, properties and the meal plan use TextButton, icon-only ones use graphite-ui's small IconButton, and FormDialog's danger submit, the AM/PM toggle, the crash page's home link, the file picker and the open-original bookmark link use MUI buttons. Menu triggers, inline prose links and purpose-built controls keep their classes. * feat(ui): move Strata's plain text fields onto the Material UI fields The comment box, quick-add task, property and option names, the weather place search and address lines, the shortcut fields and the capture note use graphite-ui's TextField and TextAreaField, with their accessible names as visible labels. Checkboxes, radios, hidden and file inputs and purpose-built inputs (note title, cell editor, currency search, share counts) are unchanged. * fix(ui): keep Strata's buttons content-width and the weather search on its own line MUI buttons stretched to the full row inside grid layouts, so the button wrappers, the file picker and the meal plan's add buttons get w-fit back. The weather place search starts at 10rem and wraps above its buttons in narrow widgets instead of shrinking to a sliver. * feat(ui): finish moving Strata onto Material UI and drop duplicate components Menu triggers use triggerVariant; checkboxes and radios use graphite-ui Checkbox and Radio; message boxes are MUI Alerts; prose links are MUI Links; chips, toggle groups, list buttons and inputs use MUI. Strata's IconButton wrapper, buttonClass, unreadBadge and the hand-built CurrencyField and DayOfMonthField pickers give way to graphite-ui's IconButton, MUI Badge, Select and GridListbox, and TimeField's trigger is graphite-ui's PickerField. Removes the CSS for the old chips, note tools and icon picker. * chore(deps): graphite-ui 1.0.0
1 parent 75196e5 commit ced4070

68 files changed

Lines changed: 1421 additions & 804 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/frontend/package.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@
2222
"vite": "8.3.0"
2323
},
2424
"dependencies": {
25+
"@emotion/react": "11.14.0",
26+
"@emotion/styled": "11.14.1",
2527
"@excalidraw/excalidraw": "0.18.1",
2628
"@hocuspocus/provider": "4.7.0",
2729
"@mozilla/readability": "0.6.0",
28-
"@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.5.1/project-graphite-ui-0.5.1.tgz",
30+
"@mui/material": "9.5.0",
31+
"@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v1.0.0/project-graphite-ui-1.0.0.tgz",
2932
"@tiptap/core": "3.31.4",
3033
"@tiptap/extension-collaboration": "3.31.4",
3134
"@tiptap/extension-collaboration-caret": "3.31.4",
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Icon, Menu, type MenuItem } from '@project-graphite/ui';
22

33
export function ActionsMenu({ items, name }: { items: MenuItem[]; name: string }) {
4-
return <Menu items={items} label={`${name} actions`} trigger={<Icon name="more" size={18} />} triggerClassName="icon-button" triggerLabel={`Actions for ${name}`} />;
4+
return <Menu items={items} label={`${name} actions`} trigger={<Icon name="more" size={18} />} triggerLabel={`Actions for ${name}`} triggerVariant="icon" />;
55
}

‎app/frontend/src/components/AgendaEntryLink.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import MuiLink from '@mui/material/Link';
12
import { useState, type CSSProperties, type ReactNode } from 'react';
23
import { Link } from 'react-router';
34
import { Dialog, LinesSkeleton, type TagColor } from '@project-graphite/ui';
@@ -57,9 +58,9 @@ function CalendarEventDialog({ entry, onClose }: { entry: AgendaEntry; onClose:
5758
)}
5859
<p className="m-0 text-sm text-faint">
5960
From {details.data.calendar.name} · {details.data.calendar.host}.{' '}
60-
<Link className="text-button" to="/agenda/calendars">
61+
<MuiLink component={Link} to="/agenda/calendars" underline="always">
6162
Manage calendars
62-
</Link>
63+
</MuiLink>
6364
</p>
6465
</div>
6566
)}
Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { ReactNode } from 'react';
2-
import { Link } from 'react-router';
3-
import { Icon, type IconName } from '@project-graphite/ui';
1+
import Badge from '@mui/material/Badge';
2+
import MuiIconButton from '@mui/material/IconButton';
3+
import { Icon, Button as UiButton, type IconName } from '@project-graphite/ui';
44

5-
export const buttonClass = (primary = false) => `${primary ? 'primary-button' : 'secondary-button'} w-fit shrink-0 px-3 py-2 text-sm`;
5+
const look = (large: boolean, primary: boolean) => ({ size: large ? 'medium' : 'small', variant: primary ? 'primary' : 'secondary' }) as const;
66

77
export function Button({
88
disabled,
@@ -24,52 +24,43 @@ export function Button({
2424
submit?: boolean;
2525
}) {
2626
return (
27-
<button
27+
<UiButton
28+
{...look(large, primary)}
2829
aria-pressed={pressed}
29-
className={large ? (primary ? 'primary-button' : 'secondary-button') : buttonClass(primary)}
30+
className="w-fit shrink-0"
3031
disabled={disabled}
32+
icon={icon}
3133
onClick={onClick}
3234
type={submit ? 'submit' : 'button'}
3335
>
34-
{icon && <Icon name={icon} size={16} />}
3536
{label}
36-
</button>
37+
</UiButton>
3738
);
3839
}
3940

40-
const linkClass = (large: boolean, primary: boolean) => `${large ? `${primary ? 'primary-button' : 'secondary-button'} w-fit` : buttonClass(primary)} no-underline`;
41-
4241
export function ButtonLink({ icon, label, large = false, primary = false, state, to }: { icon?: IconName; label: string; large?: boolean; primary?: boolean; state?: unknown; to: string }) {
4342
return (
44-
<Link className={linkClass(large, primary)} state={state} to={to}>
45-
{icon && <Icon name={icon} size={16} />}
43+
<UiButton {...look(large, primary)} {...{ state }} className="w-fit shrink-0" href={to} icon={icon}>
4644
{label}
47-
</Link>
45+
</UiButton>
4846
);
4947
}
5048

5149
export function ButtonAnchor({ href, label, large = false, primary = false }: { href: string; label: string; large?: boolean; primary?: boolean }) {
5250
return (
53-
<a className={linkClass(large, primary)} href={href} rel="noopener noreferrer" target="_blank">
51+
<UiButton {...look(large, primary)} {...{ rel: 'noopener noreferrer', target: '_blank' }} className="w-fit shrink-0" href={href} LinkComponent="a">
5452
{label}
55-
</a>
56-
);
57-
}
58-
59-
export function IconButton({ disabled, icon, label, onClick }: { disabled?: boolean; icon: IconName; label: string; onClick: () => void }) {
60-
return (
61-
<button aria-label={label} className="icon-button" disabled={disabled} onClick={onClick} title={label} type="button">
62-
<Icon name={icon} size={18} />
63-
</button>
53+
</UiButton>
6454
);
6555
}
6656

67-
export function IconLink({ children, icon, label, to }: { children?: ReactNode; icon: IconName; label: string; to: string }) {
57+
export function IconLink({ badge, icon, label, to }: { badge?: number; icon: IconName; label: string; to: string }) {
6858
return (
69-
<Link aria-label={label} className="icon-button" title={label} to={to}>
70-
<Icon name={icon} size={18} />
71-
{children}
72-
</Link>
59+
<MuiIconButton aria-label={label} href={to} title={label}>
60+
<Badge badgeContent={badge} color="primary" max={99}>
61+
<Icon name={icon} size={18} />
62+
</Badge>
63+
</MuiIconButton>
7364
);
7465
}
7566

@@ -87,9 +78,8 @@ export function TextButton({
8778
onClick: () => void;
8879
}) {
8980
return (
90-
<button className={`text-button inline-flex items-center gap-1.5 text-sm ${danger ? 'text-danger' : ''}`} disabled={disabled} onClick={onClick} type="button">
91-
{icon && <Icon name={icon} size={14} />}
81+
<UiButton className={`w-fit ${danger ? 'text-danger' : ''}`} disabled={disabled} icon={icon} onClick={onClick} size="small" variant="text">
9282
{label}
93-
</button>
83+
</UiButton>
9484
);
9585
}

‎app/frontend/src/components/CategoriesEditor.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import Alert from '@mui/material/Alert';
12
import { useState } from 'react';
2-
import { ConfirmDialog, Select, tagColors, TextField, type TagColor } from '@project-graphite/ui';
3+
import { ConfirmDialog, IconButton, Select, type TagColor, tagColors, TextField } from '@project-graphite/ui';
34
import { useAuth } from '../auth';
45
import { categoryName, type SpendingCategory } from '../spending';
56
import { useAction } from '../useAction';
6-
import { Button, IconButton } from './Button';
7+
import { Button } from './Button';
78
import { FormDialog } from './FormDialog';
89

910
interface Row extends SpendingCategory {
@@ -104,9 +105,9 @@ export function CategoriesEditor({ categories, onClose, onSaved, spaceId }: { ca
104105
</ul>
105106
<Button disabled={rows.length >= 40} label="Add a category" onClick={() => setRows((current) => [...current, { key: Math.max(...current.map((row) => row.key)) + 1, name: '', colour: 'gray' }])} />
106107
{error && (
107-
<p className="error-message m-0" role="alert">
108+
<Alert className="m-0" severity="error">
108109
{error}
109-
</p>
110+
</Alert>
110111
)}
111112
</FormDialog>
112113
);

‎app/frontend/src/components/CategoryChips.tsx‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import Chip from '@mui/material/Chip';
2+
import FormLabel from '@mui/material/FormLabel';
13
import { useState } from 'react';
24
import { categoryName, type SpendingCategory } from '../spending';
5+
import { TextButton } from './Button';
36

47
const shownFirst = 6;
58

@@ -10,24 +13,20 @@ export function CategoryChips({ categories, label = 'Category', onChange, value
1013

1114
return (
1215
<fieldset className="m-0 grid gap-2 border-0 p-0">
13-
<legend className="field-label mb-2 p-0">{label}</legend>
16+
<FormLabel className="mb-2" component="legend">{label}</FormLabel>
1417
<div className="flex flex-wrap gap-2">
1518
{shown.map((category) => (
16-
<button
19+
<Chip
1720
aria-pressed={category.name === value}
18-
className={`category-chip ${category.name === value ? 'category-chip-chosen' : ''}`}
21+
icon={<span aria-hidden="true" className={`swatch-dot ml-2 tag-${category.colour}`} />}
1922
key={category.name}
23+
label={categoryName(category.name)}
2024
onClick={() => onChange(category.name)}
21-
type="button"
22-
>
23-
<span aria-hidden="true" className={`swatch-dot tag-${category.colour}`} />
24-
{categoryName(category.name)}
25-
</button>
25+
variant={category.name === value ? 'filled' : 'outlined'}
26+
/>
2627
))}
2728
{categories.length > shown.length && (
28-
<button className="text-button text-sm" onClick={() => setAll(true)} type="button">
29-
More…
30-
</button>
29+
<TextButton label="More…" onClick={() => setAll(true)} />
3130
)}
3231
</div>
3332
</fieldset>

‎app/frontend/src/components/CrashBoundary.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Component, type ReactNode } from 'react';
2-
import { ErrorState } from '@project-graphite/ui';
3-
import { buttonClass } from './Button';
2+
import { Button, ErrorState } from '@project-graphite/ui';
43

54
export class CrashBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
65
state = { failed: false };
@@ -14,9 +13,9 @@ export class CrashBoundary extends Component<{ children: ReactNode }, { failed:
1413
return (
1514
<ErrorState
1615
action={
17-
<a className={`${buttonClass()} no-underline`} href="/">
16+
<Button href="/" LinkComponent="a" size="small">
1817
Go home
19-
</a>
18+
</Button>
2019
}
2120
onRetry={() => window.location.reload()}
2221
title="This page hit a problem"
Lines changed: 14 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
2-
import { Icon, useDismiss } from '@project-graphite/ui';
1+
import { Select } from '@project-graphite/ui';
32
import { allCurrencies, currencyName } from '../choices';
4-
import { popoverPlace } from './DayOfMonthField';
53

64
const famous = ['USD', 'EUR', 'EGP'];
75

@@ -24,120 +22,19 @@ export function CurrencyField({
2422
onChange?: (value: string) => void;
2523
value?: string;
2624
}) {
27-
const id = useId();
28-
const [own, setOwn] = useState(defaultValue ?? '');
29-
const current = value ?? own;
30-
const [open, setOpen] = useState(false);
31-
const [query, setQuery] = useState('');
32-
const [active, setActive] = useState(0);
33-
const root = useRef<HTMLDivElement>(null);
34-
const trigger = useRef<HTMLButtonElement>(null);
35-
const search = useRef<HTMLInputElement>(null);
36-
const panel = useRef<HTMLDivElement>(null);
37-
const [place, setPlace] = useState<CSSProperties>();
38-
useDismiss(open, root, () => setOpen(false));
39-
useLayoutEffect(() => {
40-
if (!open) return;
41-
setPlace({ ...popoverPlace(trigger.current!), width: Math.max(trigger.current!.offsetWidth, 288) });
42-
panel.current?.showPopover?.();
43-
if (!window.matchMedia('(pointer: coarse)').matches) search.current?.focus();
44-
}, [open]);
45-
46-
const shown = useMemo(() => {
47-
const words = query.trim().toLowerCase();
48-
if (!words) return [...(noneLabel ? [''] : []), ...new Set([...famous, ...(current ? [current] : [])])];
49-
const matches = codes.filter((code) => `${code} ${currencyName(code)}`.toLowerCase().includes(words));
50-
return [...matches.filter((code) => code.toLowerCase().startsWith(words)), ...matches.filter((code) => !code.toLowerCase().startsWith(words))];
51-
}, [codes, current, noneLabel, query]);
52-
53-
function choose(next: string) {
54-
if (value === undefined) setOwn(next);
55-
if (next !== current) onChange?.(next);
56-
setOpen(false);
57-
trigger.current?.focus();
58-
}
59-
60-
function onKey(event: KeyboardEvent) {
61-
if (event.key === 'Escape') {
62-
event.preventDefault();
63-
setOpen(false);
64-
trigger.current?.focus();
65-
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
66-
event.preventDefault();
67-
setActive((index) => Math.min(shown.length - 1, Math.max(0, index + (event.key === 'ArrowDown' ? 1 : -1))));
68-
} else if (event.key === 'Enter') {
69-
event.preventDefault();
70-
const code = shown[active];
71-
if (code !== undefined) choose(code);
72-
}
73-
}
74-
7525
return (
76-
<div className="field-label" ref={root}>
77-
<span id={`${id}-label`}>{label}</span>
78-
<button
79-
aria-expanded={open}
80-
aria-haspopup="listbox"
81-
aria-labelledby={`${id}-label`}
82-
className="select-trigger field-control"
83-
onClick={() => {
84-
setQuery('');
85-
setActive(0);
86-
setOpen(!open);
87-
}}
88-
ref={trigger}
89-
role="combobox"
90-
type="button"
91-
>
92-
<span className={`truncate ${current || noneLabel ? '' : 'text-faint'}`}>{current ? `${current} · ${currencyName(current)}` : (noneLabel ?? 'Choose…')}</span>
93-
<span className="chevron">
94-
<Icon name="chevron-down" size={16} />
95-
</span>
96-
</button>
97-
<input name={name} type="hidden" value={current} />
98-
{hint ? (
99-
<span className="field-hint" id={`${id}-hint`}>
100-
{hint}
101-
</span>
102-
) : (
103-
<span aria-hidden="true" className="field-slot" />
104-
)}
105-
{open && (
106-
<div className="popover-panel listbox-panel" onKeyDown={onKey} popover="manual" ref={panel} style={place}>
107-
<div className="listbox-search">
108-
<Icon name="search" size={16} />
109-
<input
110-
aria-label="Search currencies"
111-
autoComplete="off"
112-
onChange={(event) => {
113-
setQuery(event.currentTarget.value);
114-
setActive(0);
115-
}}
116-
placeholder="Search all currencies"
117-
ref={search}
118-
value={query}
119-
/>
120-
</div>
121-
<div className="listbox-options" role="listbox">
122-
{shown.map((code, index) => (
123-
<div
124-
aria-selected={code === current}
125-
className="listbox-option"
126-
data-active={index === active}
127-
key={code || 'none'}
128-
onClick={() => choose(code)}
129-
onPointerMove={() => index !== active && setActive(index)}
130-
role="option"
131-
>
132-
<span className="min-w-0 flex-1 truncate">{code || noneLabel}</span>
133-
{code && <span className="listbox-hint">{currencyName(code)}</span>}
134-
{code === current && <Icon name="check-mark" size={16} />}
135-
</div>
136-
))}
137-
{shown.length === 0 && <p className="listbox-empty">Nothing matches “{query}”.</p>}
138-
</div>
139-
</div>
140-
)}
141-
</div>
26+
<Select
27+
defaultValue={defaultValue}
28+
hint={hint}
29+
label={label}
30+
name={name}
31+
onChange={onChange}
32+
options={[
33+
...(noneLabel ? [{ value: '', label: noneLabel }] : []),
34+
...[...new Set([...famous, ...codes])].map((code) => ({ value: code, label: `${code} · ${currencyName(code)}` })),
35+
]}
36+
searchable
37+
value={value}
38+
/>
14239
);
14340
}

0 commit comments

Comments
 (0)