@morze/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Morze Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,455 @@
1
+ # @morze/ui
2
+
3
+ Morze UI — a React component kit shaped like **shadcn/ui (latest)** on **Radix**
4
+ primitives, wearing the convex look from the Morze landing: a 135° gradient, a
5
+ hairline rim, an inset highlight on top and a tone glow underneath.
6
+
7
+ ![Morze UI components](docs/screenshot.png)
8
+
9
+ ```
10
+ background-color: rgb(tone);
11
+ background-image: linear-gradient(135deg, rgba(255,255,255,.07), rgba(0,0,0,.14));
12
+ border: 1px solid rgba(255,255,255,.18);
13
+ box-shadow: 0 3px 10px -4px rgba(tone,.4),
14
+ inset 0 1px 0 rgba(255,255,255,.35),
15
+ inset 0 -1px 0 rgba(0,0,0,.2);
16
+ ```
17
+
18
+ That recipe is applied to **every active element**: buttons, toggles,
19
+ checkboxes, radios, switches, sliders, tabs, the select trigger, badges and the
20
+ active navigation item.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm i @morze/ui
26
+ ```
27
+
28
+ `react` and `react-dom` **19** are peer dependencies: like shadcn after its
29
+ React 19 rewrite, components take `ref` as a plain prop and use no `forwardRef`.
30
+ Tailwind is **not required** — the package ships compiled CSS.
31
+
32
+ ```tsx
33
+ // once, at the app root
34
+ import '@morze/ui/styles.css'
35
+
36
+ import { Button, Switch, MorzeThemeProvider } from '@morze/ui'
37
+
38
+ export default function App() {
39
+ return (
40
+ <MorzeThemeProvider defaultTheme="dark">
41
+ <Button dot>Submit request</Button>
42
+ <Switch defaultChecked />
43
+ </MorzeThemeProvider>
44
+ )
45
+ }
46
+ ```
47
+
48
+ Tokens only, without components: `import '@morze/ui/tokens.css'`.
49
+
50
+ ## Themes
51
+
52
+ Dark is the default, light is opt-in through an attribute (or the
53
+ `.mz-theme-light` / `.mz-theme-dark` classes if attributes are inconvenient):
54
+
55
+ ```html
56
+ <html data-mz-theme="light">
57
+ ```
58
+
59
+ `MorzeThemeProvider` does that for you, remembers the choice in `localStorage`
60
+ and understands `system`:
61
+
62
+ ```tsx
63
+ const { resolvedTheme, setTheme } = useMorzeTheme()
64
+ setTheme('light') // 'dark' | 'light' | 'system'
65
+ ```
66
+
67
+ The first render is always `defaultTheme`; the stored preference is applied in
68
+ an effect after mount, so server and client markup agree and hydration does not
69
+ break. Storage access is wrapped in try/catch — with site data blocked the
70
+ theme simply does not persist.
71
+
72
+ The provider renders a wrapper with the `mz-root` class (background and text
73
+ colour from the tokens). By default `data-mz-theme` is written to `<html>`, so
74
+ portalled dialogs and menus follow the theme too. `target="element"` scopes the
75
+ theme to the wrapper instead.
76
+
77
+ ## Tones
78
+
79
+ Every active component accepts `tone`, which re-points the gradient, the glow
80
+ and the focus ring:
81
+
82
+ ```tsx
83
+ <Button tone="accent">Save</Button>
84
+ <Switch tone="success" defaultChecked />
85
+ <Badge tone="danger" dot>Live</Badge>
86
+ <Progress value={40} tone="warning" />
87
+ ```
88
+
89
+ `primary` (violet) · `accent`/`success` (emerald) · `danger` · `warning` ·
90
+ `info`. A tone is the `--mz-tone-rgb` custom property, so a one-off value works
91
+ too: `style={{ '--mz-tone-rgb': '255, 100, 130' }}` (channels separated by
92
+ commas — the value is substituted into `rgb()`/`rgba()`).
93
+
94
+ ## Shape and type
95
+
96
+ The default shape is **rectangular**: one small radius for controls, a slightly
97
+ larger one for surfaces.
98
+
99
+ | Token | Value | Used by |
100
+ | --- | --- | --- |
101
+ | `--mz-radius-sm` | `6px` | items inside containers: menu items, tabs, checkbox |
102
+ | `--mz-radius` | `8px` | buttons, inputs, select, toggles, badges, alerts |
103
+ | `--mz-radius-lg` | `12px` | cards, dialogs, popovers, menus |
104
+ | `--mz-radius-pill` | `999px` | only what is round by meaning: radio, indicator dots |
105
+
106
+ Fully square corners: `--mz-radius: 0; --mz-radius-sm: 0; --mz-radius-lg: 0`.
107
+
108
+ Fonts are **system stacks** — nothing is downloaded and the kit inherits the
109
+ host application's look. Control labels are plain sentence case.
110
+
111
+ ```css
112
+ :root {
113
+ --mz-primary-rgb: 120, 90, 250; /* brand colour */
114
+ --mz-font-sans: 'Inter', sans-serif;
115
+ --mz-radius: 4px; /* tighter */
116
+ }
117
+ ```
118
+
119
+ To bring back the landing's own look (pills plus mono caps):
120
+
121
+ ```css
122
+ :root {
123
+ --mz-radius-control: var(--mz-radius-pill);
124
+ --mz-label-font: 'JetBrains Mono', ui-monospace, monospace;
125
+ --mz-label-transform: uppercase;
126
+ --mz-label-tracking: 0.08em;
127
+ }
128
+ ```
129
+
130
+ ## States
131
+
132
+ Hover **darkens the colour** and grows the element slightly **in place** —
133
+ nothing shifts position, and there is no shine sweep. Press darkens further and
134
+ shrinks a little.
135
+
136
+ ```css
137
+ :root {
138
+ --mz-hover-scale: 1.02; /* growth on hover */
139
+ --mz-press-scale: 0.98; /* dip on press */
140
+ --mz-hover-mix: 88%; /* fill colour: 88% tone + 12% black */
141
+ --mz-press-mix: 80%;
142
+ }
143
+ ```
144
+
145
+ The tone fill is stored as a flat colour (`--mz-fill`) under a fixed sheen
146
+ gradient (`--mz-fill-sheen`), so `background-color` animates smoothly —
147
+ browsers do not interpolate gradients. Darkening goes through `color-mix()`;
148
+ where that is unsupported the element simply does not darken and everything
149
+ else still works.
150
+
151
+ Only **filled** surfaces darken: `primary`, `destructive`, a pressed toggle, the
152
+ active tab, a checked checkbox, radio or switch. Neutral variants (`secondary`,
153
+ `outline`, `ghost`) and unselected controls lighten instead — darkening would
154
+ sink them into the page.
155
+
156
+ ## Customisation
157
+
158
+ Everything is driven by custom properties; override them after importing the
159
+ styles. The main groups: surfaces (`--mz-bg`, `--mz-surface`, `--mz-elevated`),
160
+ borders, text, tones, the convex recipe (`--mz-convex-*`, `--mz-face*`,
161
+ `--mz-well*`), geometry (`--mz-radius*`), type (`--mz-font-*`, `--mz-label-*`),
162
+ motion and states (`--mz-ease`, `--mz-duration`, `--mz-hover-scale`,
163
+ `--mz-press-scale`, `--mz-hover-mix`, `--mz-press-mix`), focus (`--mz-ring-*`).
164
+
165
+ The full list lives in `dist/morze-ui-tokens.css`.
166
+
167
+ ## Components
168
+
169
+ | Input and actions | Navigation and output | Overlays |
170
+ | --- | --- | --- |
171
+ | `Button` `Toggle` `ToggleGroup` | `Tabs` `Accordion` | `Dialog` |
172
+ | `Checkbox` `RadioGroup` `Switch` | `Card` `Alert` `Badge` | `DropdownMenu` |
173
+ | `Slider` `Input` `Textarea` | `Progress` `Avatar` | `Popover` |
174
+ | `Label` `Field` `Select` | `Separator` `Skeleton` `Spinner` | `Tooltip` |
175
+ | `DataTable` | `Sidebar` | `Sheet` |
176
+
177
+ The API mirrors shadcn/ui: same names, same sub-component composition,
178
+ `asChild`, a `data-slot` on every part — so examples from the shadcn docs work
179
+ with barely any edits.
180
+
181
+ What this kit adds on top:
182
+
183
+ - `Button` — `variant`: `primary` (default) `secondary` `outline` `ghost`
184
+ `destructive` `link`; `size`: `xs` (28px) `sm` (34) `md` (40, default)
185
+ `lg` (48) plus square `icon-xs` `icon-sm` `icon` `icon-lg`; `loading`,
186
+ `dot` (a pulsing morse dot), `tone`. The `sm/md/lg` heights (34/40/48px) are
187
+ shared by buttons, inputs and toggles, so controls line up in a row. With
188
+ `asChild` the loading indicator is grafted inside the child element and the
189
+ blocked state is expressed through `aria-disabled`, since a link has no
190
+ `disabled`.
191
+ - `ToggleGroup` — `appearance`: `segmented` (a sunken well with the active item
192
+ raised), `joined` (one continuous bar), `spaced`.
193
+ - `TabsList` — `variant`: `default` (well) or `line` (underline).
194
+ - `Checkbox` / `Switch` / `Avatar` — `size`: `sm` `md` `lg`.
195
+ - `Input` — the size prop is `inputSize` (`sm` `md` `lg`); the name differs from
196
+ shadcn to avoid clashing with the native `size` attribute on `<input>`.
197
+ - `SelectTrigger` — `size`: `sm` `md` (no `lg`).
198
+ - `Card` — `interactive` adds the hover state; combined with `onClick` the card
199
+ also gets `role="button"`, `tabIndex` and Enter/Space activation.
200
+ - `Spinner` — `label` (default `Loading`) is announced by screen readers;
201
+ `label={null}` makes it purely decorative.
202
+ - `Badge` — `variant`: `solid` `soft` `outline`, plus `dot`.
203
+ - `Field` / `FieldHint` / `FieldError` — form scaffolding.
204
+
205
+ ```tsx
206
+ <ToggleGroup type="single" defaultValue="week" appearance="segmented">
207
+ <ToggleGroupItem value="day">Day</ToggleGroupItem>
208
+ <ToggleGroupItem value="week">Week</ToggleGroupItem>
209
+ </ToggleGroup>
210
+ ```
211
+
212
+ ## Sidebar
213
+
214
+ A navigation column: it collapses into an icon rail, turns into a sliding sheet
215
+ on narrow screens and remembers its state between sessions.
216
+
217
+ ```tsx
218
+ <SidebarProvider>
219
+ <Sidebar collapsible="icon">
220
+ <SidebarHeader>
221
+ <Logo className="mz-sidebar-hide-collapsed" />
222
+ <b className="mz-sidebar-hide-collapsed">Morze ERP</b>
223
+ <SidebarTrigger className="mz-sidebar-push" />
224
+ </SidebarHeader>
225
+ <SidebarContent>
226
+ <SidebarGroup>
227
+ <SidebarGroupLabel>Work</SidebarGroupLabel>
228
+ <SidebarMenu>
229
+ <SidebarMenuItem>
230
+ <SidebarMenuButton isActive tooltip="Orders">
231
+ <OrdersIcon />
232
+ <span>Orders</span>
233
+ </SidebarMenuButton>
234
+ <SidebarMenuBadge>12</SidebarMenuBadge>
235
+ </SidebarMenuItem>
236
+ </SidebarMenu>
237
+ </SidebarGroup>
238
+ </SidebarContent>
239
+ <SidebarFooter>…</SidebarFooter>
240
+ </Sidebar>
241
+
242
+ <SidebarInset>
243
+ {/* the top bar stays on the page background, next to the navigation */}
244
+ <header>…</header>
245
+ <SidebarPanel>{children}</SidebarPanel>
246
+ </SidebarInset>
247
+ </SidebarProvider>
248
+ ```
249
+
250
+ - **Collapsing** — `collapsible`: `icon` (an icon rail, the default),
251
+ `offcanvas` (slides away entirely), `none`. Toggled by `SidebarTrigger` or
252
+ **⌘/Ctrl + B**. There is also an optional `SidebarRail` — an invisible strip
253
+ along the panel edge that toggles it on click; it is not rendered by default,
254
+ add it if you want that gesture.
255
+ - **The rail** hides labels; items with a `tooltip` prop show theirs on the
256
+ right instead. Anything that only makes sense at full width (a wordmark, a
257
+ user name) is marked with `mz-sidebar-hide-collapsed`, otherwise the rail
258
+ clips it. That class is declared with `!important` — it has to beat an inline
259
+ `display` on a logo or an avatar. To push an element to the far end of the
260
+ header use `mz-sidebar-push` rather than an inline `margin-left: auto`: in the
261
+ rail it switches off, otherwise the button drifts off the axis the menu items
262
+ below sit on.
263
+ - **Mobile** — below `mobileBreakpoint` (768px) the panel renders as a `Sheet`
264
+ over the content; a rail would eat what little width is left.
265
+ - **State** lives in `localStorage` (`storageKey`, `null` disables it). The first
266
+ render is always `defaultOpen` and the stored value lands in an effect, so SSR
267
+ and hydration agree. It can also be driven from outside via `open` /
268
+ `onOpenChange`.
269
+ - **Variants** — `variant`:
270
+ - `inset` (default) — the navigation sits directly on the page background and
271
+ the raised surface is the content inside `SidebarPanel`: only the corner
272
+ facing the navigation is rounded, the other three sides are flush with the
273
+ window, and the shadow is cast back towards the sidebar. Put the top bar and
274
+ breadcrumbs in `SidebarInset` **above** the panel, not inside it.
275
+ - `sidebar` — the navigation gets its own surface and border.
276
+ - `floating` — the navigation lifts off the edge as a separate card.
277
+
278
+ The side is `side="left" | "right"` (the panel's rounded corner mirrors
279
+ itself). The panel's radius and padding are `--mz-sidebar-panel-radius` and
280
+ `--mz-sidebar-panel-pad`. The `mz-sidebar-layout--grid` class on
281
+ `SidebarProvider` adds a 48px grid to the background.
282
+
283
+ `SidebarTrigger` lives in the panel's own header: with `collapsible="icon"`
284
+ (the default) the sidebar never disappears completely — the rail stays, and so
285
+ does the button.
286
+
287
+ The exception is **mobile and `offcanvas`**, where the panel leaves together
288
+ with everything inside it, so a second trigger is needed outside. To avoid two
289
+ buttons on desktop, render it conditionally:
290
+
291
+ ```tsx
292
+ function MobileTrigger() {
293
+ const { isMobile } = useSidebar()
294
+ return isMobile ? <SidebarTrigger /> : null
295
+ }
296
+ ```
297
+
298
+ The default height is `100dvh`. When the sidebar lives inside a panel rather
299
+ than owning the page, set `--mz-sidebar-h: 100%` on `SidebarProvider`.
300
+
301
+ **`Sheet`** is exported separately — a dialog anchored to an edge of the screen
302
+ (`side`: `top | right | bottom | left`), and what the mobile sidebar uses.
303
+
304
+ ## DataTable
305
+
306
+ A table for server-driven data: it never sorts or filters anything itself, it
307
+ collects state and hands it over as a single object. One user action, one
308
+ request.
309
+
310
+ ```tsx
311
+ const { query, setQuery } = useTableQuery({
312
+ initial: { sort: [{ id: 'date', dir: 'desc' }], pageSize: 25 },
313
+ urlKey: 'orders.', // filters and sorting in the query string
314
+ })
315
+ const { rows, total, loading, error } = useOrders(query)
316
+
317
+ const columns: DataTableColumn<Order>[] = [
318
+ { id: 'number', header: '#', width: 120, sortable: true, pinned: 'left',
319
+ cell: (row) => <b>{row.number}</b> },
320
+ { id: 'client', header: 'Client', sortable: true,
321
+ filter: { type: 'select', options: clients } },
322
+ { id: 'sum', header: 'Total', align: 'right', sortable: true,
323
+ accessor: (row) => formatMoney(row.sum),
324
+ filter: { type: 'number-range', step: 10_000 } },
325
+ ]
326
+
327
+ <DataTable
328
+ columns={columns} data={rows} total={total} rowKey={(row) => row.id}
329
+ loading={loading} error={error}
330
+ query={query} onQueryChange={setQuery}
331
+ persistKey="orders" // column widths, order, visibility
332
+ selection={selection} onSelectionChange={setSelection}
333
+ bulkActions={() => <Button size="sm">Export</Button>}
334
+ renderExpanded={(row) => <OrderDetails id={row.id} />}
335
+ />
336
+ ```
337
+
338
+ What it does:
339
+
340
+ - **Sorting** — a header click cycles `asc → desc → off`, Shift adds the column
341
+ to a composite sort (an ordered list reaches the backend).
342
+ - **Header filters** — `text`, `select`, `number-range`, `date-range`,
343
+ `boolean`. A value is staged in the popover and committed on Apply: otherwise
344
+ every keystroke would be a request. Active filters are echoed as chips above
345
+ the table.
346
+ - **Default widths** — columns stretch to fill the container, so there is no
347
+ dead space at the right edge. The maths runs off the declared `width` values
348
+ (they act as proportions), re-runs when the container resizes and always
349
+ produces the same result for the same width — resizing the window back and
350
+ forth does not drift the layout. When even the declared widths do not fit,
351
+ they are used as-is and horizontal scrolling kicks in. Turn it off with
352
+ `autoFit={false}`, or per column with `flex: false` (handy for narrow icon or
353
+ action columns). `minWidth` and `maxWidth` are honoured and the surplus is
354
+ redistributed across the rest.
355
+ - **Mouse resize — spreadsheet style**: the column follows the cursor one to
356
+ one, its neighbours do not move, spare space stays at the right, and the
357
+ scrollbar appears when there is none left. Double-clicking the handle fits the
358
+ column to its content; arrows on the focused handle resize from the keyboard.
359
+ During a drag the width is written to a CSS custom property rather than to
360
+ state — not a single row re-render per pixel of travel. A column touched by
361
+ hand is excluded from auto-fit for good.
362
+ - **Pinned columns** left and right, a sticky header, and a hairline on the seam
363
+ between the pinned and scrolling parts.
364
+ - **Row selection** — Shift selects a range, the header checkbox takes the page,
365
+ and the floating bar can escalate to "all N matching" (in that mode a bulk
366
+ action must travel with the query, not with a list of ids).
367
+ - **Columns** — visibility, order (drag and drop plus arrows for the keyboard)
368
+ and pinning; all of it saved to `localStorage` under `persistKey`.
369
+ - **Expandable rows** and **inline cell editing** on double click, with
370
+ optimistic saving and a rollback on failure.
371
+ - **States** — skeletons on the first load, a thin progress line when refetching
372
+ over data already on screen, an empty result and an error with a retry.
373
+ - **Density** `compact | normal | relaxed`.
374
+
375
+ Helpers for your own UI: `useTableQuery`, `useSavedViews`, `useColumnLayout`,
376
+ `useRowSelection`, plus the pure functions `toggleSort`, `setFilter`,
377
+ `serializeSort` / `parseSort`.
378
+
379
+ A table stays a table: on narrow screens it scrolls horizontally with the first
380
+ column pinned, it does not reflow into cards.
381
+
382
+ ## Localisation
383
+
384
+ Component strings default to English and every one of them can be replaced.
385
+ `Dialog`/`Sheet` take `closeLabel`, `Spinner` takes `label`, `Sidebar` takes
386
+ `mobileTitle` / `mobileDescription` and `SidebarTrigger` takes `label`.
387
+
388
+ `DataTable` has more strings than the rest, so it takes a partial `labels`
389
+ object — anything omitted falls back to the English default:
390
+
391
+ ```tsx
392
+ import { DataTable, type DataTableLabels } from '@morze/ui'
393
+
394
+ const de: Partial<DataTableLabels> = {
395
+ columns: 'Spalten',
396
+ apply: 'Anwenden',
397
+ reset: 'Zuruecksetzen',
398
+ empty: 'Nichts gefunden',
399
+ rowsPerPage: 'Zeilen pro Seite',
400
+ selectedCount: (count) => `Ausgewaehlt: ${count}`,
401
+ sortBy: (column) => `Nach "${column}" sortieren`,
402
+ }
403
+
404
+ <DataTable labels={de} locale="de-DE" … />
405
+ ```
406
+
407
+ `locale` controls number formatting; without it the browser's locale is used.
408
+ The complete set of keys is `DataTableLabels`, with defaults in
409
+ `defaultDataTableLabels`.
410
+
411
+ ## Accessibility and behaviour
412
+
413
+ - Behaviour, focus management, keyboard and ARIA come from Radix.
414
+ - Focus is drawn with an `outline` rather than a shadow, so it never fights the
415
+ convex layers; width and offset are `--mz-ring-width` / `--mz-ring-offset`.
416
+ - `@media (prefers-reduced-motion: reduce)` disables animation and the growth.
417
+ - Classes are prefixed with `mz-` and variables with `--mz-`, so the kit does
418
+ not collide with application styles (Tailwind included). The base layer has no
419
+ descendant selectors: your own markup inside a `Card` or `Dialog` keeps its
420
+ box model and typography.
421
+
422
+ ## Next.js
423
+
424
+ The bundle is marked `"use client"` — import components into client parts and
425
+ add the CSS in `app/layout.tsx`.
426
+
427
+ ## Development
428
+
429
+ ```bash
430
+ npm install
431
+ npm run dev # playground with every component (http://localhost:5250)
432
+ npm run typecheck
433
+ npm run test # vitest: component behaviour plus CSS contracts
434
+ npm run build # dist: ESM + CJS + .d.ts + morze-ui.css
435
+ ```
436
+
437
+ `tests/smoke.test.tsx` covers component behaviour and accessibility,
438
+ `tests/css-contract.test.ts` covers the visual layer's contracts (cascade order
439
+ for tones, hover states, isolation from foreign markup, geometry). The second
440
+ file exists because regressions here are cascade regressions: a rule that lands
441
+ later in the bundle and quietly outranks an earlier one.
442
+
443
+ Layout: `src/components/*.tsx` for components (markup and API),
444
+ `src/styles/**` for the visual layer (`tokens.css` → `base.css` →
445
+ `components/*` → `tones.css`). JS is built with tsup, CSS with lightningcss
446
+ (`scripts/build-css.mjs`).
447
+
448
+ ## Publishing
449
+
450
+ ```bash
451
+ npm publish --access public
452
+ ```
453
+
454
+ The `@morze` scope has to exist in the npm organisation (or point
455
+ `publishConfig` at a private registry).