@hackcove/ui-react 0.0.0-stage → 0.8.1
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 +21 -0
- package/MIGRATING.md +116 -0
- package/README.md +400 -2
- package/dist/AppShell.d.ts +97 -0
- package/dist/AppShell.js +106 -0
- package/dist/Avatar.d.ts +49 -0
- package/dist/Avatar.js +65 -0
- package/dist/AvatarStack.d.ts +29 -0
- package/dist/AvatarStack.js +26 -0
- package/dist/Badge.d.ts +14 -0
- package/dist/Badge.js +11 -0
- package/dist/Breadcrumbs.d.ts +28 -0
- package/dist/Breadcrumbs.js +30 -0
- package/dist/BulletList.d.ts +4 -0
- package/dist/BulletList.js +5 -0
- package/dist/Button.d.ts +78 -0
- package/dist/Button.js +133 -0
- package/dist/ButtonGroup.d.ts +15 -0
- package/dist/ButtonGroup.js +10 -0
- package/dist/Callout.d.ts +24 -0
- package/dist/Callout.js +28 -0
- package/dist/Card.d.ts +95 -0
- package/dist/Card.js +134 -0
- package/dist/CardBadge.d.ts +7 -0
- package/dist/CardBadge.js +12 -0
- package/dist/CardParts.d.ts +25 -0
- package/dist/CardParts.js +25 -0
- package/dist/Checkbox.d.ts +29 -0
- package/dist/Checkbox.js +36 -0
- package/dist/Chevron.d.ts +5 -0
- package/dist/Chevron.js +6 -0
- package/dist/Cluster.d.ts +51 -0
- package/dist/Cluster.js +38 -0
- package/dist/Code.d.ts +4 -0
- package/dist/Code.js +5 -0
- package/dist/CodeBlock.d.ts +27 -0
- package/dist/CodeBlock.js +12 -0
- package/dist/CodeExample.d.ts +17 -0
- package/dist/CodeExample.js +13 -0
- package/dist/CodeText.d.ts +7 -0
- package/dist/CodeText.js +21 -0
- package/dist/ColourSchemeToggle.d.ts +34 -0
- package/dist/ColourSchemeToggle.js +50 -0
- package/dist/Container.d.ts +22 -0
- package/dist/Container.js +13 -0
- package/dist/CopyButton.d.ts +17 -0
- package/dist/CopyButton.js +13 -0
- package/dist/CopyLine.d.ts +9 -0
- package/dist/CopyLine.js +7 -0
- package/dist/DateCalendar.d.ts +10 -0
- package/dist/DateCalendar.js +45 -0
- package/dist/DateField.d.ts +87 -0
- package/dist/DateField.js +100 -0
- package/dist/DateFieldCalendar.d.ts +6 -0
- package/dist/DateFieldCalendar.js +35 -0
- package/dist/Dialog.d.ts +26 -0
- package/dist/Dialog.js +18 -0
- package/dist/Disclosure.d.ts +15 -0
- package/dist/Disclosure.js +12 -0
- package/dist/Divider.d.ts +4 -0
- package/dist/Divider.js +5 -0
- package/dist/Drawer.d.ts +29 -0
- package/dist/Drawer.js +22 -0
- package/dist/EmptyState.d.ts +18 -0
- package/dist/EmptyState.js +14 -0
- package/dist/Field.d.ts +21 -0
- package/dist/Field.js +19 -0
- package/dist/FieldDialog.d.ts +28 -0
- package/dist/FieldDialog.js +34 -0
- package/dist/FileDrop.d.ts +17 -0
- package/dist/FileDrop.js +12 -0
- package/dist/FilterGroup.d.ts +7 -0
- package/dist/FilterGroup.js +6 -0
- package/dist/FormRow.d.ts +42 -0
- package/dist/FormRow.js +33 -0
- package/dist/Icon.d.ts +20 -0
- package/dist/Icon.js +13 -0
- package/dist/IconButton.d.ts +57 -0
- package/dist/IconButton.js +51 -0
- package/dist/Input.d.ts +22 -0
- package/dist/Input.js +22 -0
- package/dist/InputShell.d.ts +16 -0
- package/dist/InputShell.js +26 -0
- package/dist/Kbd.d.ts +6 -0
- package/dist/Kbd.js +5 -0
- package/dist/KeyValueList.d.ts +23 -0
- package/dist/KeyValueList.js +19 -0
- package/dist/Lightbox.d.ts +28 -0
- package/dist/Lightbox.js +44 -0
- package/dist/ListRow.d.ts +109 -0
- package/dist/ListRow.js +92 -0
- package/dist/Markdown.d.ts +9 -0
- package/dist/Markdown.js +43 -0
- package/dist/MetaList.d.ts +23 -0
- package/dist/MetaList.js +14 -0
- package/dist/OutlineRegion.d.ts +43 -0
- package/dist/OutlineRegion.js +27 -0
- package/dist/PageFade.d.ts +26 -0
- package/dist/PageFade.js +39 -0
- package/dist/PageHeader.d.ts +24 -0
- package/dist/PageHeader.js +14 -0
- package/dist/PageLayouts.d.ts +130 -0
- package/dist/PageLayouts.js +86 -0
- package/dist/Pill.d.ts +53 -0
- package/dist/Pill.js +34 -0
- package/dist/ProgressBar.d.ts +32 -0
- package/dist/ProgressBar.js +32 -0
- package/dist/RadioGroup.d.ts +48 -0
- package/dist/RadioGroup.js +39 -0
- package/dist/SearchInput.d.ts +8 -0
- package/dist/SearchInput.js +40 -0
- package/dist/SegmentedControl.d.ts +20 -0
- package/dist/SegmentedControl.js +31 -0
- package/dist/Select.d.ts +5 -0
- package/dist/Select.js +9 -0
- package/dist/SplitBar.d.ts +27 -0
- package/dist/SplitBar.js +15 -0
- package/dist/Stack.d.ts +70 -0
- package/dist/Stack.js +47 -0
- package/dist/StackedBar.d.ts +56 -0
- package/dist/StackedBar.js +35 -0
- package/dist/StatTile.d.ts +21 -0
- package/dist/StatTile.js +22 -0
- package/dist/StatusPages.d.ts +38 -0
- package/dist/StatusPages.js +57 -0
- package/dist/Steps.d.ts +17 -0
- package/dist/Steps.js +14 -0
- package/dist/StickyBar.d.ts +12 -0
- package/dist/StickyBar.js +10 -0
- package/dist/Table.d.ts +142 -0
- package/dist/Table.js +138 -0
- package/dist/Tabs.d.ts +40 -0
- package/dist/Tabs.js +49 -0
- package/dist/TextLink.d.ts +51 -0
- package/dist/TextLink.js +37 -0
- package/dist/Textarea.d.ts +12 -0
- package/dist/Textarea.js +8 -0
- package/dist/Timeline.d.ts +27 -0
- package/dist/Timeline.js +29 -0
- package/dist/Video.d.ts +13 -0
- package/dist/Video.js +9 -0
- package/dist/colourScheme.d.ts +21 -0
- package/dist/colourScheme.js +53 -0
- package/dist/control.d.ts +31 -0
- package/dist/control.js +38 -0
- package/dist/cx.d.ts +12 -0
- package/dist/cx.js +14 -0
- package/dist/highlight.d.ts +16 -0
- package/dist/highlight.js +292 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +66 -0
- package/dist/isoDate.d.ts +5 -0
- package/dist/isoDate.js +16 -0
- package/dist/lift.harness.d.ts +2 -0
- package/dist/lift.harness.js +3 -0
- package/dist/link.d.ts +50 -0
- package/dist/link.js +25 -0
- package/dist/modal.d.ts +11 -0
- package/dist/modal.js +41 -0
- package/dist/props.d.ts +14 -0
- package/dist/props.js +1 -0
- package/dist/shownDate.d.ts +45 -0
- package/dist/shownDate.js +88 -0
- package/dist/size.d.ts +6 -0
- package/dist/size.js +1 -0
- package/dist/switcher.d.ts +88 -0
- package/dist/switcher.js +71 -0
- package/dist/typography.d.ts +123 -0
- package/dist/typography.js +120 -0
- package/dist/ui.d.ts +10 -0
- package/dist/ui.js +27 -0
- package/package.json +91 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jacobus Langner
|
|
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/MIGRATING.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Migrating @hackcove/ui-react
|
|
2
|
+
|
|
3
|
+
## 0.7 → 0.8: `@hackcove/ui` is now `@hackcove/ui-react`
|
|
4
|
+
|
|
5
|
+
Nothing else changed: 0.8.0 is the 0.7.1 components under a new name, so the React package pairs
|
|
6
|
+
with the React Native one that comes later (`@hackcove/ui-react-native`). There is no alias package
|
|
7
|
+
and no re-export, so the old name stays at 0.7.1.
|
|
8
|
+
|
|
9
|
+
1. Swap the dependency: `bun remove @hackcove/ui && bun add @hackcove/ui-react`.
|
|
10
|
+
2. Find and replace the import specifier `"@hackcove/ui"` with `"@hackcove/ui-react"` (and any
|
|
11
|
+
`@hackcove/ui/…` path with `@hackcove/ui-react/…`).
|
|
12
|
+
3. Change the Tailwind `@source` path to `node_modules/@hackcove/ui-react/dist`.
|
|
13
|
+
|
|
14
|
+
## 0.6 → 0.6.1: ListRow active on a panel, notices in a column, OutlineRegion
|
|
15
|
+
|
|
16
|
+
Nothing breaks; a consumer changes only what it had built for itself.
|
|
17
|
+
|
|
18
|
+
- `ListRow active` now fills the row one step deeper than hover (`bg-slate-100`), so it shows on a
|
|
19
|
+
`bg-slate-50` surface such as a `FullBleedLayout` panel. A local row drawn only to get a visible
|
|
20
|
+
focus is now `<ListRow size="sm" divided active={focused}>`.
|
|
21
|
+
- `Callout banner` is for the top of a `Card flush` only. A notice in a page's column (a
|
|
22
|
+
`FullBleedLayout`, padded or not) is a plain `Callout`, first in the column's `Stack`:
|
|
23
|
+
`<FullBleedLayout padded …><Stack gap="sm"><Callout tone="danger" role="alert">…</Callout>…</Stack></FullBleedLayout>`.
|
|
24
|
+
- New: `RegionFrame` and `OutlineRegion` mark areas of a picture. Draw the picture as
|
|
25
|
+
`<RegionFrame src={…} alt={…}>` (any `<img>` prop, such as `onLoad` or `loading`, passes on) and put
|
|
26
|
+
`<OutlineRegion rect={{ x, y, width, height }} within={{ width, height }} label="…" hatched />` inside
|
|
27
|
+
it, in the picture's own pixels. It replaces a hand-drawn box with an arbitrary colour.
|
|
28
|
+
|
|
29
|
+
## 0.5 → 0.6: one Text role, paired parts folded
|
|
30
|
+
|
|
31
|
+
`Body` is now `Text`, and `Text strong` takes over from `BodyStrong`. `Subtitle`, `Empty` and
|
|
32
|
+
`ModalHeading` were near-copies of other parts and are gone with no alias: each is one of the
|
|
33
|
+
remaining parts with a prop. TypeScript names every use; the swaps are mechanical.
|
|
34
|
+
|
|
35
|
+
| Removed or renamed | Write instead |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `<Body>…</Body>` | `<Text>…</Text>` (same props: `as`, `color`, `tone`, `numeric`, `mono`, `truncate`, `preserveNewlines`) |
|
|
38
|
+
| `<BodyStrong>…</BodyStrong>` | `<Text strong>…</Text>` |
|
|
39
|
+
| `<Subtitle>…</Subtitle>` | `<Text as="p">…</Text>` |
|
|
40
|
+
| `<Empty>…</Empty>` | `<Text as="p" color="muted">…</Text>` |
|
|
41
|
+
| `<ModalHeading>…</ModalHeading>` | `<SectionHeading>…</SectionHeading>` |
|
|
42
|
+
|
|
43
|
+
A block of `Text` stops at a readable line length (`max-w-[72ch]`), as `Body` did. `Subtitle` and
|
|
44
|
+
`BodyStrong` had no such measure, so a long line in a wide column now wraps at about 72 characters.
|
|
45
|
+
`PageHeader` and `Dialog` draw their own subtitle and description, and keep the full width.
|
|
46
|
+
|
|
47
|
+
The small labels of `Card`, `FilterGroup` and `StatTile` now render as an `Overline`. The `Card`
|
|
48
|
+
title looks the same; a `FilterGroup` legend is semibold (it was medium) and a `StatTile` label's
|
|
49
|
+
letter spacing is `tracking-wide` (it was `tracking-wider`).
|
|
50
|
+
|
|
51
|
+
### Paired parts folded into one (HC-169)
|
|
52
|
+
|
|
53
|
+
Each pair below was one look drawn by two exports. The survivor takes a prop, the other is gone with
|
|
54
|
+
no alias, and the class-string helpers are no longer exported: a router link is drawn through
|
|
55
|
+
`render`, never by copying a class. A swap keeps the look unless its row says otherwise.
|
|
56
|
+
|
|
57
|
+
| Removed or renamed | Write instead |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `<ButtonLink href="/new">New</ButtonLink>` | `<Button href="/new">New</Button>` (same `variant`, `size`, `tone`, `external`) |
|
|
60
|
+
| `<ButtonLink render={(p) => <Link to="/new" {...p} />}>New</ButtonLink>` | `<Button render={(p) => <Link to="/new" {...p} />}>New</Button>` |
|
|
61
|
+
| `<DatePicker label="Due" value={d} onChange={setD} />` | `<DateField calendar label="Due" value={d} onChange={setD} />` (`defaultOpen` opens the calendar on mount) |
|
|
62
|
+
| `type DatePickerProps` | `type DateFieldProps` |
|
|
63
|
+
| `<InlineLinks>{links}</InlineLinks>` | `<MetaList nowrap>{links}</MetaList>` |
|
|
64
|
+
| `<InlineLinks separator="•">{links}</InlineLinks>` | `<MetaList nowrap separator="•">{links}</MetaList>` (`"none"` as before) |
|
|
65
|
+
| `type InlineLinksSeparator` | `type MetaListSeparator` |
|
|
66
|
+
| `<NavTabs label="Main" items={nav} />` | `<Tabs variant="pill" label="Main" items={nav} />` (each item `{ label, href \| render, active? }`, as before) |
|
|
67
|
+
| `<NavTabs orientation="vertical" label="Docs" items={nav} />` | `<Tabs variant="pill" orientation="vertical" label="Docs" items={nav} />` |
|
|
68
|
+
| `type NavTab` | none: let the `items` array infer, or type it as `Tab[]` only for buttons |
|
|
69
|
+
| `navTabClass(active)` on a router link | a link item with `render={(p) => <Link to="/x" {...p} />}` |
|
|
70
|
+
| `<PageTitle title="Builds" subtitle="…" />` | `<PageHeader title="Builds" subtitle="…" />` (adds `PageHeader`'s bottom margin, none as a `Stack` child) |
|
|
71
|
+
| `<ToggleChip pressed={on} onClick={flip}>Open</ToggleChip>` | `<Button variant="outline" size="sm" pressed={on} onClick={flip}>Open</Button>` (the chip keeps its colours, neutral off and filled on; only the corner radius changes, to a `Button`'s; `dot` has no swap, name the category in the label) |
|
|
72
|
+
| `buttonClass(variant, opts)` on a router link | `<Button variant={variant} render={(p) => <Link to="/x" {...p} />}>…</Button>` |
|
|
73
|
+
| `crumbLinkClass` on a router link | a crumb `{ label, href }` or `{ label, render }` |
|
|
74
|
+
| `<Link className={rowLinkClass}>` in a row's first cell | `<TextLink stretch render={(p) => <Link to="/x" {...p} />}>…</TextLink>` |
|
|
75
|
+
| `<Link className={linkClass}>` | `<TextLink render={(p) => <Link to="/x" {...p} />}>…</TextLink>` |
|
|
76
|
+
| `<Link className={cx(linkClass, linkStretchClass)}>` | `<TextLink stretch render={(p) => <Link to="/x" {...p} />}>…</TextLink>` |
|
|
77
|
+
| `<html className={cx(schemeClass(s), appShellRootClass)}>` | `<html className={appShellRootClassFor(s)}>` (or `appShellRootClassFor("light")` for no scheme) |
|
|
78
|
+
|
|
79
|
+
`SegmentedControl` stays. `startPageTransition` stays exported: `PageFade` pages call it.
|
|
80
|
+
|
|
81
|
+
## 0.4 → 0.5: one size scale
|
|
82
|
+
|
|
83
|
+
Every `size` is now `sm`, `md` or `lg`, and `md` is the default (README, "Sizes"). TypeScript finds
|
|
84
|
+
each renamed value for you; the changed defaults it cannot, so read the second table and pin the old
|
|
85
|
+
value where the old look was the point.
|
|
86
|
+
|
|
87
|
+
### Renamed values
|
|
88
|
+
|
|
89
|
+
| Component | Old | New |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `TextLink` | `size="xs"` | `size="sm"` |
|
|
92
|
+
| `TextLink` | `size="sm"` | `size="md"` |
|
|
93
|
+
| `SubsectionHeading` | `size="base"` | `size="md"` |
|
|
94
|
+
| `Stack`, `Section`, `ProseLayout` gap | `gap="section"` | `gap="xl"` |
|
|
95
|
+
|
|
96
|
+
### Changed defaults
|
|
97
|
+
|
|
98
|
+
| Component | Old default | New default | To keep the old look |
|
|
99
|
+
| --- | --- | --- | --- |
|
|
100
|
+
| `SubsectionHeading` | `sm` | `md` (`text-base/6 font-semibold`) | `size="sm"` |
|
|
101
|
+
| `Section` gap | `sm` (`gap-4`) | `md` (`gap-6`) | `gap="sm"` |
|
|
102
|
+
| `Cluster` gap | `sm` (`gap-4`) | `xs` (`gap-2`) | `gap="sm"` |
|
|
103
|
+
| `ProgressBar` | `sm` | `md` | `size="sm"` |
|
|
104
|
+
| `SplitBar`, `StackedBar` | `lg` | `md` | `size="lg"` |
|
|
105
|
+
| `Avatar` `md` | 28px | 32px (`size-8`) | none; `sm` is 24px |
|
|
106
|
+
| `IconButton`, `ColourSchemeToggle` | 36px | 38px, the `md` control height | `size="sm"` (26px) for a row |
|
|
107
|
+
| `NavTabs`, `Tabs variant="pill"` | 32px | 38px, the `md` control height | `size="sm"` |
|
|
108
|
+
|
|
109
|
+
### New sizes
|
|
110
|
+
|
|
111
|
+
- `lg` on `Button`, `ButtonLink`, `Input` (also `type="search"`), `Select`, `Textarea`, `Checkbox`,
|
|
112
|
+
`Card`, `ListRow`, `FileDrop` and `TextLink`; `lg` on `Dialog` is 640px wide.
|
|
113
|
+
- A `size` prop on `RadioGroup`, `DatePicker`, `DateField`, `IconButton`, `ColourSchemeToggle`,
|
|
114
|
+
`SegmentedControl`, `NavTabs` and `Tabs variant="pill"`.
|
|
115
|
+
- `ToggleChip` takes `size` and defaults to `sm`, its look before; `md` and `lg` match a `Button`.
|
|
116
|
+
- `Size` is exported; `ControlSize` is now an alias of it.
|
package/README.md
CHANGED
|
@@ -1,3 +1,401 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hackcove/ui-react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Hack Cove React components, written against Tailwind v4 utilities. They carry no theme: pair them
|
|
4
|
+
with a `@hackcove/theme-*` package such as `@hackcove/theme-catppuccin-mocha` and put its scope
|
|
5
|
+
class on `<html>` (`.dark` for Mocha). With no theme they render in Tailwind's own default colours,
|
|
6
|
+
a plain light look; it works, but it is not a look Hack Cove designs or checks.
|
|
7
|
+
|
|
8
|
+
Status: `0.x`, published to npm. License: MIT.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bun add @hackcove/ui-react
|
|
14
|
+
# peers: react >= 18, react-dom >= 18, tailwindcss ^4
|
|
15
|
+
# optional type peers: @types/react, @types/react-dom (install them in a TypeScript app)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
One runtime dependency comes with it: `react-aria-components` (and `@internationalized/date`),
|
|
19
|
+
for `DateField`'s calendar. It brings no CSS; the classes are ours.
|
|
20
|
+
|
|
21
|
+
Tailwind skips `node_modules`, so tell it to scan the build for the utilities the components use,
|
|
22
|
+
in the stylesheet that imports Tailwind (the path is relative to that file):
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
@import "tailwindcss";
|
|
26
|
+
@source "../node_modules/@hackcove/ui-react/dist";
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { Card, PageHeading, Pill, Text } from "@hackcove/ui-react";
|
|
33
|
+
|
|
34
|
+
export const Example = () => (
|
|
35
|
+
<Card title="Status" count={2}>
|
|
36
|
+
<PageHeading>All good</PageHeading>
|
|
37
|
+
<Text>Two things shipped.</Text> <Pill tone="emerald">ok</Pill>
|
|
38
|
+
</Card>
|
|
39
|
+
);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## No styling props
|
|
43
|
+
|
|
44
|
+
No component takes `className` or `style`, as an own prop or through the HTML props it spreads
|
|
45
|
+
(`data-*`, `aria-*`, `id`, `title` and event props still pass). `Badge` pins to a corner with
|
|
46
|
+
`corner`, `ListRow` draws its line with `divided`; a look the component lacks is a prop or variant
|
|
47
|
+
to propose, not a class to add. A router link is drawn through `render` (see Router links), never
|
|
48
|
+
by copying a component's classes.
|
|
49
|
+
|
|
50
|
+
Every exported component passes `data-*` and `aria-*` through to its root element (an e2e hook such as
|
|
51
|
+
`data-testid`, or `aria-describedby`), so there is no need to wrap one in an element of your own to
|
|
52
|
+
carry them; `passthrough.test.tsx` fails for an export that does not. A component with no single
|
|
53
|
+
root (`DateField`) puts `data-*` on its wrapper and `aria-describedby` on the field.
|
|
54
|
+
|
|
55
|
+
## Sizes
|
|
56
|
+
|
|
57
|
+
One scale runs through the library: `Size` is `"sm" | "md" | "lg"`, and `md` is the default
|
|
58
|
+
wherever a component takes `size`. The controls (`Button` (also as a link), `IconButton`,
|
|
59
|
+
`ColourSchemeToggle`, `Input` (also `type="search"`), `Select`, `Textarea`, `Checkbox`, `RadioGroup`,
|
|
60
|
+
`DateField` (also with `calendar`), `SegmentedControl`, `Tabs variant="pill"`)
|
|
61
|
+
share heights at each step (`sm` 26px, `md` 38px, `lg` 46px, the same on a `Select` and a `DateField` as on an `Input`; a Playwright check measures them side by side in every theme), so a row of mixed controls at one size lines up. `Card`, `ListRow`,
|
|
62
|
+
`FileDrop`, `Dialog`, `Drawer`, `Avatar`, `SubsectionHeading`, `TextLink` and the bars take the
|
|
63
|
+
same three names. Only `Avatar` (`xl`, `2xl`) and the `Stack` gap (`xs`, `xl`) go beyond them.
|
|
64
|
+
|
|
65
|
+
One default is not `md`, on purpose: `Cluster`'s gap is `xs` (a row is a label and its chips,
|
|
66
|
+
which sit tight). `TextLink` with no `size`
|
|
67
|
+
takes the size of the text around it. Upgrading: `MIGRATING.md`, shipped in the package (`node_modules/@hackcove/ui-react/MIGRATING.md`).
|
|
68
|
+
|
|
69
|
+
## What is exported
|
|
70
|
+
|
|
71
|
+
- Primitives: `Card` (a bordered panel; `title` and `count` make a small heading, and `status` (a `Pill` or any node) sits right after them, where `actions` sits at the far end; `flush` drops the padding and clips the corners, for a table that runs edge to edge; `bar` moves the title, count and `actions` into a tinted strip over the body; `dashed` is an empty-state shell with centred muted text, and `dashed="filled"` the same on a surface fill with more padding; `badge` pins a `Pill` to the top-right corner over the content, on a panel or a link card, where it is part of the link's name; `attention` draws the strong warn edge (`border-amber-600`) round a card that needs a look, with or without `tone="warn"`, and does nothing on a `dashed` card; `footer` puts a tinted strip under a hairline across the bottom, for the card's own actions or a total: it takes the padding off the root and gives it to the body, so the strip runs edge to edge and stays at the foot of a card stretched in a `CardGrid`; not on a `dashed` card or a card that is a link; `as` is `section` (default), `article` or `div`; `id`, `data-*` and `aria-*` go on the root (on a link card too: the link inside takes only its own `aria-label`, `aria-labelledby` and `aria-current`); a titled panel is named by its title through `aria-labelledby`, unless you pass your own `aria-label` or `aria-labelledby`; a Card with an `id` is an anchor target, scrolled clear of the sticky header and outlined in violet while it is the `:target`), `EpicHeader`, `Pill`, `groupByEpic`, `Markdown`, `Badge` (a small red count for sitting on an icon; `count` above 99 reads `99+`, `corner` pins it to the top-right of its `relative` parent; an `IconButton` takes `badge` and does this itself).
|
|
72
|
+
- Data: `StatTile` (one headline figure: an uppercase `label` over a large tabular `value`, already formatted, an optional `hint` under it; `tone` is `neutral` (default), `positive` or `negative`, and `accent` adds a bar across the top in the same tone), `ProgressBar` (a thin bar with a filled share of `max` (default 100); `label` names it for a screen reader, which also hears the percentage; `tone` is `accent` (default), `positive`, `warn` or `negative`), `SplitBar` (a bar split between `a` and `b` by their shares; `names` says whose is whose to a screen reader; `toneA` (default `negative`) and `toneB` (default `positive`)), `StackedBar` (any number of `segments`, each `{ label, value, tone }`, sized by its share of the sum; `legend` draws a `Legend` under it) and `Legend` (a swatch, a `label` and an optional `detail` per item, the key to a chart's or a row stripe's colours). The three bars take `size`: `sm` 6px, `md` 8px (default), `lg` 12px.
|
|
73
|
+
- Status pages: `NotFound` (404), `ErrorPage` (500 or any failure; `detail` is error text shown in a `CodeBlock`, never a stack trace, and `onRetry` adds "Try again"), `Offline` (`onRetry` too), `Maintenance` and `Forbidden` (403). Each is a whole page with default copy: `title` and `children` replace the heading and the body, `action` is the primary action, and `homeHref`, or `renderHome` for the app's own router `<Link>`, adds a "Back to home" button. With neither there is no home link.
|
|
74
|
+
- Layout (see Layout): `AppShell`, `AppShellBrand`, `appShellRootClassFor`, `Container`, `PageHeader`, `Stack`, `Section`, `Cluster`, `ClusterGrow`, and the page layouts `ListLayout`, `DetailLayout`, `FormLayout`, `ProseLayout`, `CardGrid`, `SidebarLayout`, `FullBleedLayout`, `CenteredLayout`.
|
|
75
|
+
- Typography roles (each pins size + weight, takes a `color` and an `as`): `Display`, `Lead`,
|
|
76
|
+
`PageHeading`, `SectionHeading`, `SubsectionHeading`, `Overline`, `Text`, `Caption`. Opt-in props, none changing today's look: `SectionHeading rule`
|
|
77
|
+
(a hairline under it, with its spacing), `SubsectionHeading size` (`sm`, `md` default, `lg`) and `truncate` (one line with an ellipsis),
|
|
78
|
+
`Text strong` (font-medium in the primary ink, for emphasis), `Text preserveNewlines` (keeps a
|
|
79
|
+
note's line breaks). The three inline roles (`Text`, `Caption`, `Overline`) also take `tone` (`muted`, `positive`, `warn`, `danger` or `info`: the same
|
|
80
|
+
text colours as a `TableCell` tone, winning over `color`; pair the colour with a word, as in "Saved."),
|
|
81
|
+
`numeric` (tabular figures), `mono` (the monospace face) and `truncate` (one line, cut with an
|
|
82
|
+
ellipsis, the full text in a `title`, so its content is text).
|
|
83
|
+
- Controls: `Button` (`variant` is `primary` (default), `outline`, `ghost`, `link` or `dashed`, the link having no box: text with an underline on hover, for an in-line action, and the dashed one a dashed outline in muted text that fills its container, like `Card dashed`, for an "add another" row, with a leading icon as a child, ignoring `tone`; `size="sm"` is compact, `text-xs`, for table rows and toolbars, `size="lg"` is `text-base` with more padding; `tone="danger"` is red, for delete and clear, and `tone="positive"` green, for accept and approve; `disabledReason` disables it, greys it flat and makes the reason its `title`; `busy` disables it with `aria-busy` and keeps its colour, showing `busyLabel` in place of the children; `pressed` makes it a toggle with `aria-pressed`, on `outline` and `ghost` neutral when off and filled in the tone's primary colour when on, so an `outline` `size="sm"` Button with `pressed` is a filter chip; with `href`, or `render` to draw the app's own router `<Link>`, it is a link with the same look, `external` opening a new tab), `ButtonGroup` (a wrapping row of buttons; `end` is one item pushed to the far side, `label` makes it a named `role="group"`), `IconButton` (a square button holding one icon; `label` is required and becomes its `aria-label` and `title`; `variant` is `outline` (default) or `ghost`, `tone` is `default` or `danger`, `size` matches a `Button`'s height; `badge` puts a red count on its corner, not shown at 0; with `href` or `render` it is a link, `external` opening a new tab), `Icon` (one bare `<svg>` sized to fill a box: `size` `sm` 14px, `md` 16px (default) or `lg` 20px; `tone` is a text tone or `inherit` (default, the text colour around it); `aria-hidden` unless `label` names it as `role="img"`), `ColourSchemeToggle` (a light/dark switch on `<html>`; `id`, `data-*` and other button props go on the button, `store={false}` skips `localStorage`; see Light and dark), `Input` (a text input; `icon` adds a leading icon inside the field; `prefix` is short text inside the border before the value, a currency symbol, on a plain field only; `type="search"` drops the browser's search look and shows a "Clear search" button while the field has a value), `Textarea` (a multi-line text input, `rows` defaults to 3), `Select` (a native `<select>` in the control look; takes `<option>` children and keeps the browser's arrow). `Input`, `Select` and `Textarea` take `size` (`md` default, `sm`: `text-xs`, less padding, for dense forms and table rows, or `lg`: `text-base`, more padding) and `width` (`full` default, the fixed `xs` 5rem, `sm` 7rem, `md` 9rem, `lg` 12rem, `fill`: grows to the room left in a flex row and can shrink to nothing, so the buttons beside it stay on the line, or `auto`: no width class, as wide as the browser makes it); a fixed width replaces `w-full`, so there is no class to collide with. `Input` also takes `align` (`start` default, or `end` to right-align a numeric value). `size` replaces the native `size` attribute, so a multi-row `<select size={4}>` is not reachable through `Select`. `Checkbox` (a native checkbox with its label; other props go on the input; `size="sm"` is `text-xs` with a smaller box and `size="lg"` `text-base` with a larger one, `align="start"` puts the box on the first line of a label that wraps, where the default `center` centres it on all of them, and with no `label` it is a bare box for a table cell, named by `aria-label`; `description` is a muted line under the label, the box's `aria-describedby`). `RadioGroup` and `Radio` (one choice from a few options: the group has a `label`, `value`/`onChange` or `defaultValue`, `name`, `isDisabled`, `isRequired`, `size` (`sm`, `md` default, `lg`, as `Checkbox`) and an `error` shown like `Field`'s with `role="alert"`; each `Radio` has a `value`, a `label`, an optional `description` and `isDisabled`; one tab stop, the arrow keys move and choose). `Disclosure` (a summary button with a chevron and `aria-expanded` over a body that opens and closes; `summary`, `children`, `defaultOpen`; it keeps its own state and replaces `<details>`). `FileDrop` (a dashed drop zone, a `<label>` around a file input that covers it, so a click opens the dialog and a dropped file lands on the input; `children` is the prompt, `accept`, `multiple`, `name`, `disabled` and `onChange` on the input). `CopyButton` (a small text button that writes `text` to the clipboard and swaps `label` ("copy") for `copiedLabel` ("copied"); every other button prop, `disabled` included, goes on the `<button>`). `CopyLine` (one line of mono text in a box with a `CopyButton` beside it; `disabled` disables the button; other props go on the root `<div>`). `Field` (a label above a control, `htmlFor` names the control's id; a `hint` sits under it, and an `error` replaces the hint with a `role="alert"` line; `span="full"` runs it across both columns of a `FormGrid`). `FormGrid` (two columns of `Field`s from `md`, one below it), `FormRow` (an inline-edit row: `FormRowField`s side by side, an `action` after them, and one `hint` or `error` under the whole row; see Layout) and `FormRowField` (a compact label over one control, `span` 1 to 4). `Callout` (a tinted notice box; `tone` is `neutral`, `info` (default), `positive`, `warn` or `danger`; an optional `title` sits in bold above the body; `actions` puts a button or link at the end of the row; `banner` makes it a flat strip, bottom border only and no radius, for the top of a `Card flush`). `ListRow` (a full-width row that is a `<button>`, or a link with `href` or `render`, with a hover fill, a left-aligned label and `trailing` content; `divided` draws a hairline above the row, to separate rows in a `Card flush`; `expanded` makes it the header of a collapsible section: `aria-expanded` and a chevron, the caller owning the state and the section; `leading` is content before the label (an `Avatar`), and `leadingWidth="thumbnail"` gives it a fixed width so rows with and without a picture line up; `size` sets the padding (`sm` dense, `md` default, `lg`); `focused` draws the focus ring on the row the keyboard cursor is on, for a list moved with the arrow keys while focus stays on the list; `selected` marks the row in view with a violet fill and `aria-current="true"`; a link row (`href` or `render`) can hold its own link, button or control in `trailing`, which sits above the row's link and takes its own click; `static` makes it a plain `<div>`, for a row that holds its own buttons where the row would otherwise be a `<button>`, and adds `actions`, buttons after `trailing`, and `revealOnHover`, which shows them only on hover or focus inside the row and always on a touch screen; `active` marks the row the pointer or the keyboard is on, with the hover fill and the `revealOnHover` actions shown, for a list that follows both through the row's `onPointerEnter`, `onPointerLeave` and `onFocus`, which every row passes through). `Tabs` (underline tabs for switching a view in place: a row over a bottom rule, the current tab underlined in the accent; `items` are `{ value, label }` buttons, with `value` the current one's `value` and `onChange(value)` the caller's state, one tab stop and the arrow keys, Home and End moving between enabled tabs, or `{ label, href | render, active?, external? }` links in a `<nav>` with `aria-current="page"` on the current one (`active`, or the item whose `value` is `value`); `label` names the group; a button tab takes `disabled` and `controls`, the id of its `role="tabpanel"`, which is the caller's; `variant="pill"` fills the current tab, with no rule, for `PageHeader`'s `tabs` slot, an `AppShell` `nav` or a rail; only a pill row takes `size` and `orientation`, and `orientation="vertical"` stacks it in one column, the content of a `SidebarLayout` rail; `disabled` on the row disables every tab, e.g. until hydration). `SegmentedControl` (a joined pill group, one option filled; the same `items`, `value`, `onChange`, `disabled` and `label` as `Tabs`, as a `radiogroup` with `aria-checked` on each `radio`). On both an item takes `data-*` attributes (typed `data-${string}`) onto its button or link; on `Tabs` and `SegmentedControl` an item takes `count` (a small muted number after the label, part of its name: "Shots 12") and `title`; a `disabled` button option stays in the page as `aria-disabled`, so its `title` is still the hover tooltip and is announced as the reason, it is out of the tab order and the arrow keys, Home and End skip it. `Dialog` (a modal on the browser's `<dialog>`, opened on mount, so render it only while open: `title` is its name, `description` a line under it, `footer` the right-aligned actions; Esc, the backdrop or a `<form method="dialog">` call `onClose`; `size` is `sm` (320px, a list), `md` (440px, default, a form) or `lg` (640px, a wider form or a table)). `FieldDialog` (a `Dialog` with one text field and Cancel and submit buttons, every string passed in: `title`, `description`, `label`, `inputLabel`, `placeholder`, `hint`, `emptyReason`, `submit` and `busyLabel`; `onSubmit` gets the trimmed value and a resolved promise closes it, a rejection shows its message). `Drawer` (a modal slide-over on the right edge, on a native `<dialog>` opened on mount like `Dialog`, so render it only while open: `title` is its name, a close button sits beside it, `children` scroll between the header and an optional pinned `footer`; Esc, the scrim and the close button call `onClose`; `size` is `sm`, `md` (default) or `lg`; `closeLabel` names the close button). `Breadcrumbs` (a `<nav aria-label="Breadcrumb">` over an ordered list; `crumbs` are nodes, or `{ label, href | render, external? }` for one that links; the last is the current page and is never a link and gets `aria-current="page"`, and each crumb truncates). `Table` (a list of records in a rounded, bordered card around a full-width `<table>` that scrolls sideways on a narrow screen; `caption` names it for assistive tech and is not drawn; compose it from `TableHead`, `TableBody`, `TableFoot`, `TableRow`, `TableHeaderCell` (`align` `start` or `end`; `srLabel` is a visually hidden column name, in place of children, for a column of row actions: "Actions") and `TableCell` (`align`, `tone` `default`, `strong` or `muted`, or the Callout tones `neutral`, `info`, `positive`, `warn` or `danger` for an amount that means something, in the tone's text colour and with no fill, so pair the colour with a sign or a word; `numeric` for tabular figures, `mono` for a small mono face, `truncate` to cap a long value's width, cut it with an ellipsis and put the full text in a `title`, so its content is text (strings and numbers); `secondary` for a second, muted line under the content; `colSpan` passes through, so a `TableCell` with `colSpan` in a row of its own is a full-width panel, an empty state or a note); `TableFoot` (the `<tfoot>` for totals and subtotals: its cells are strong by default and each row has a firmer line above it, in a card and in a `plain` table alike); `TableGroupRow` (a full-width group header in the body: `label` spans `colSpan` columns, the table's column count, which is required; optional `trailing` content sits at the far end, and the group's rows follow it); `tone` on a `TableRow` in the body marks the row with a stripe on its leading edge over a soft fill: `neutral`, `info`, `positive`, `warn` or `danger`, as `Callout`, or `muted`, which only fades the row (a transfer, a row that is not the point); a bold subtotal is not a tone; `stripe` (a text tone) draws the leading stripe alone, with no fill, and is never combined with `tone`; `variant="plain"` on `Table` drops the card border, fill and shadow, the head strip and the row hover and keeps the row dividers, for a table inside a card of its own; every table element passes `id`, `data-*` and `aria-*` through, and none takes a `className` or `style`). `Timeline` (a named `<ol>` of events; `label` is its `aria-label`, `empty` is drawn instead when there are no items) with `TimelineItem` (a `title`, an already-formatted `time`, the `children` as a note, and `tone`, a Callout tone for the marker dot, `neutral` by default). `KeyValueList` (a `<dl>` of label/value rows; each `KeyValue` takes a `label` and its value as children, the value right-aligned in tabular figures; `align="start"` puts the values left-aligned in one column beside the labels instead, using CSS subgrid: in Chrome before 117 a row stacks its label over its value). `Avatar` (a round badge of the initials of `name`, titled with the name and `aria-hidden`; with `src` (the app's own local image path, never a remote one) it is a round, cropped `<img alt={name}>` instead; `size` is `sm`, `md` (default), `lg`, `xl` (56px) or `2xl` (96px); `ring` adds a page-colour border and a negative margin for an overlapping stack; the colour is one of seven stock background classes picked by a hash of the name, remapped by each theme, and `avatarColourClass(name)` returns it to colour something else the same way; `initialsOf(name)` is the two letters). `TextLink` (an inline text link in the accent colour; `href` is required unless `render` draws the app's own router `<Link>`, `external` adds `target="_blank"` and `rel="noreferrer"`, `stretch` covers the nearest `relative` ancestor so a whole row or card is the click target, `size` (`sm`, `md` or `lg`) pins the type size where it would otherwise follow the text around it, `mono` sets the monospace face, `tone` (`warn`, `danger`, `positive`, `info` or `muted`) gives it a meaning's colour in place of the accent, every other anchor prop passes through). `MetaList` (a wrapping row of facts or links in muted small text, joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; branch · SHA · date, or a footer's Privacy · Terms; an item is text, a `Code` or a `TextLink`; `nowrap` keeps each item on one line, for links; `mono` sets the row in the monospace face, for ids and paths). `PageFade` (the page's content area, named for the View Transitions API so a route change cross-fades it while the frame around it stays put; wrap what changes between pages, once per page, or give each a `name` (a CSS identifier) when there are two; it fades when the navigation runs in a view transition, through `startPageTransition(update)` (flushes a synchronous state change inside `document.startViewTransition`) or a router's own option such as TanStack Router `viewTransition`, and otherwise, in a browser without View Transitions, or under `prefers-reduced-motion: reduce`, it swaps with no fade; it renders a small `<style>`, with no colour in it, and the div is a stacking context, so portal an overlay that must rise above the frame). `EmptyState` (what a page or list shows when it has nothing yet: a centred `title`, the `children` as the explanation under it, and `actions` in a centred row; a named `<section>`; `level` is the title's heading level, 2 (default) to 6, and does not change its look; for one muted line inside a card, `Text as="p" color="muted"`). `Steps` (a numbered `<ol>` of `Step`s, each a `title` and the `children` as a detail line, the numbers counted by CSS in a violet disc; for a "get started" list, not for progress). `Divider` (a hairline `<hr>` in the theme's line colour; spacing comes from the layout around it). `BulletList` (a `<ul>` with discs, an indent and a small gap; children are `<li>`s). `Code` (inline code: mono, on a subtle background). `CodeBlock` (a static multi-line block of code, no client JS: the text is `children` (a string) or `lines` (one entry per line), `language` (`bash`, `css`, `tsx`, `ts` or `json`) colours it and `text`, the default, leaves it in one colour; `label` is a caption naming the language or file, long lines wrap; other props go on the root `<figure>`). `CodeExample` (one framed example: the rendered `children` over the `code` that makes them, coloured as TSX, in one bordered box, with an optional `caption` as its last band; no client JS). `Video` (a full-width, rounded `<video>` with controls that loads metadata only; `src` is required, `poster` and a WebVTT `captions` track are optional, children are the fallback). `Kbd` (a keyboard key in mono; its border takes the surrounding text colour, so it reads on a filled button too). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `outline` `sm` `Button`s with `pressed`). `DateField` (see Dates).
|
|
84
|
+
- Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
|
|
85
|
+
|
|
86
|
+
## Dates
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { DateField, fromISODate, toISODate, type CalendarDate } from "@hackcove/ui-react";
|
|
90
|
+
|
|
91
|
+
<DateField
|
|
92
|
+
calendar
|
|
93
|
+
label="Date of birth"
|
|
94
|
+
description="DD/MM/YYYY"
|
|
95
|
+
defaultValue={fromISODate("1990-05-17")}
|
|
96
|
+
maxValue={fromISODate("2026-10-23")}
|
|
97
|
+
onChange={(date: CalendarDate | null) => save(toISODate(date))}
|
|
98
|
+
name="dob"
|
|
99
|
+
isRequired
|
|
100
|
+
/>;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- `DateField` is a segmented DD/MM/YYYY field (always en-GB, whatever the browser's locale).
|
|
104
|
+
`calendar` adds a calendar button. The popover has ‹ › month steps, month and year dropdowns,
|
|
105
|
+
Monday-first weeks and a Clear action. `defaultOpen` opens it on mount.
|
|
106
|
+
- Without `calendar` it is typed only, for a date the person knows by heart.
|
|
107
|
+
- It takes `label`, `description`, `errorMessage`, `value` / `defaultValue` / `onChange`,
|
|
108
|
+
`minValue`, `maxValue`, `isDisabled`, `isRequired` and `name` (the form posts `YYYY-MM-DD`).
|
|
109
|
+
Values are `CalendarDate`s (from `@internationalized/date`, re-exported as a type).
|
|
110
|
+
- `toISODate(date)` gives `"1990-05-17"` (`""` for `null`); `fromISODate(text)` gives a
|
|
111
|
+
`CalendarDate`, or `null` for a malformed or impossible date such as `"1990-02-31"`.
|
|
112
|
+
- `onChange` reports what the field shows: the date once it is complete, exists and is within
|
|
113
|
+
`minValue` / `maxValue`. When a valid date is edited into one that is not, it reports `null` once
|
|
114
|
+
and `name` posts `""`, so a controlled parent holds `null` until the date is fixed (holding it does
|
|
115
|
+
not clear what was typed). Fixing it reports the date again. A `value` the parent sets is shown,
|
|
116
|
+
not reported back.
|
|
117
|
+
- With `isRequired`, a partly filled date blocks the form and shows "Enter the whole date." once
|
|
118
|
+
the field loses focus.
|
|
119
|
+
- A date before `minValue` or after `maxValue` stays as typed (it is never clamped) and shows the
|
|
120
|
+
error once the field loses focus.
|
|
121
|
+
- An impossible date such as 31/02/1990 shows "February 1990 has 28 days." and stays exactly as
|
|
122
|
+
typed, focus leaving included, until it is fixed. `errorMessage` replaces either message.
|
|
123
|
+
- `error` is an error from outside the field, a server's say: it shows in place of the description,
|
|
124
|
+
in red with `role="alert"` (as `Field`'s `error` does), and marks the field invalid. It wins over
|
|
125
|
+
the built-in messages; clear it when the date changes. `isInvalid` marks the field invalid with no
|
|
126
|
+
message of its own; pair it with `errorMessage`.
|
|
127
|
+
|
|
128
|
+
## Router links
|
|
129
|
+
|
|
130
|
+
`@hackcove/ui-react` has no router dependency. `Tabs` and `SegmentedControl` (an item with `href` or `render`), `Breadcrumbs` (a crumb), `TextLink`,
|
|
131
|
+
`ListRow`, `Pill`, `IconButton` and `Button` take a `render` function that draws the app's own `<Link>`, handed the component's
|
|
132
|
+
classes (and `aria-current` on the active tab) to spread on it. The call site carries no `className`:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
<Tabs
|
|
136
|
+
variant="pill"
|
|
137
|
+
label="Main"
|
|
138
|
+
items={[
|
|
139
|
+
{ label: "Quotes", active: true, render: (props) => <Link to="/quotes" {...props} /> },
|
|
140
|
+
{ label: "Traces", href: "https://traces.example.com", external: true },
|
|
141
|
+
]}
|
|
142
|
+
/>
|
|
143
|
+
<Button variant="ghost" render={(props) => <Link to="/quotes" {...props} />}>Back</Button>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
With `render`, `href` is not used. The props `render` is handed are the exported type `LinkRenderProps`:
|
|
147
|
+
`className` and `children`, plus `aria-current`, `target`, `rel` and `title` when there is one. Each
|
|
148
|
+
optional field is `?: T` and an absent one is left out, never set to `undefined`, so the spread
|
|
149
|
+
compiles on a router `<Link>` that declares its props the same way under `exactOptionalPropertyTypes`
|
|
150
|
+
(TanStack Router's does). Any other anchor prop you gave the component (`id`, `download`, a handler)
|
|
151
|
+
is handed over too. The class strings are not exported: `render` is the one way to draw a router
|
|
152
|
+
link.
|
|
153
|
+
|
|
154
|
+
## Layout
|
|
155
|
+
|
|
156
|
+
One content edge for every page, so nothing moves sideways between pages or viewport sizes.
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { AppShell, PageHeader, Section, Stack, Tabs, appShellRootClassFor } from "@hackcove/ui-react";
|
|
160
|
+
|
|
161
|
+
// Your root layout. `appShellRootClassFor` adds `overflow-y-scroll`: it reserves the scrollbar, so
|
|
162
|
+
// a short page and a long one centre their content at the same x. AppShell cannot set it itself.
|
|
163
|
+
<html className={appShellRootClassFor("light")}>
|
|
164
|
+
<body>
|
|
165
|
+
<AppShell brand={<Logo />} nav={<Tabs variant="pill" label="Main" items={nav} />} footer="…">
|
|
166
|
+
<PageHeader title="Builds" subtitle="Every build" actions={<Button>New</Button>} />
|
|
167
|
+
<Stack>
|
|
168
|
+
<Section title="Recent">…</Section>
|
|
169
|
+
</Stack>
|
|
170
|
+
</AppShell>
|
|
171
|
+
</body>
|
|
172
|
+
</html>;
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- **`AppShell`** is the page frame: a sticky header that is always one `h-14` row (a nav that does
|
|
176
|
+
not fit scrolls sideways, it never wraps, and the edge with more to scroll to fades out until it
|
|
177
|
+
is reached), an optional `subnav` second row (same rule), `main`
|
|
178
|
+
with fixed `py-8`, and an optional `footer`. The header row is filled by slots, left to right:
|
|
179
|
+
`brand` (logo and name, at the brand type with the logo gap), `nav` (pill `Tabs` of links), `header` (a
|
|
180
|
+
free node, the escape hatch) and `controls` (end-aligned, `gap-2`, centred): no wrapper and
|
|
181
|
+
no styling. Nothing in `controls` shrinks, and a full-width control there (a `Select` at its
|
|
182
|
+
default `width="full"`) sizes to its content, so a `Select` next to a `ColourSchemeToggle` leaves
|
|
183
|
+
the toggle its square. `hideChromeInPrint` is opt-in: the header and footer are hidden in print and main
|
|
184
|
+
drops its padding (the page margin is then your `@page`); without it a print is unchanged. `bleed` drops main's `Container` for a full-bleed
|
|
185
|
+
page; the header keeps its own, so put a `Container` back around anything that should line up.
|
|
186
|
+
- **`AppShellBrand`** is the `brand` slot as one link: `logo` and `label` inside the same `<a>`, so
|
|
187
|
+
the whole brand goes home (the label names the link; the logo is hidden from assistive tech).
|
|
188
|
+
`logoSize` is `sm` (20px), `md` (24px, the default, an `Avatar size="sm"`) or `lg` (32px); an
|
|
189
|
+
`<img>` or `<svg>` logo fills it. With `render` it draws your router's `<Link>`, as `Tabs`,
|
|
190
|
+
`Breadcrumbs` and `TextLink` do:
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
<AppShell brand={<AppShellBrand logo={<Avatar name="Quotes" size="sm" />} label="Quotes"
|
|
194
|
+
render={(props) => <Link to="/" {...props} />} />} …>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A plain node in `brand` still works. Stories: `Layout/AppShell › LinkedBrand`, `RouterBrand`.
|
|
198
|
+
- **`appShellRootClassFor(scheme, schemes?)`** is what goes on `<html>` when the server knows the
|
|
199
|
+
scheme: that scheme's class and `overflow-y-scroll`, joined, so there is no string concatenation:
|
|
200
|
+
`<html className={appShellRootClassFor(scheme, schemes)}>`. With no scheme, pass `"light"`.
|
|
201
|
+
`schemeClass` stays exported.
|
|
202
|
+
- **`Container`** is the only place the content width (`max-w-6xl`) and side padding
|
|
203
|
+
(`px-4 sm:px-6`) are defined. **Pages never set their own width or outer padding**: no `max-w-*`,
|
|
204
|
+
no `mx-auto`, no `px-*` on a page. AppShell already gives them the edge.
|
|
205
|
+
- **`PageHeader`** is the top of every page: optional `breadcrumbs`, the `title` (a `PageHeading`),
|
|
206
|
+
`subtitle`, `actions` (right of the title from `sm`, under it on a phone) and `tabs` under it. Its
|
|
207
|
+
fixed bottom margin puts every page's first block at the same y.
|
|
208
|
+
- **`Stack`** spaces blocks in a column: `gap="xs"` (`gap-2`) between lines of one text block (a
|
|
209
|
+
heading, the line under it), `sm` (`gap-4`) inside a group, `md` (`gap-6`, the default) between
|
|
210
|
+
blocks, `lg` (`gap-8`) between groups, `xl` (`gap-12`, `sm:gap-16`) between a page's top-level blocks. Use it instead of `space-y-*`. `width` caps the column:
|
|
211
|
+
`narrow` (`max-w-3xl`, a reading measure), `wide` (`max-w-5xl`), `full` (`w-full`) or `default`
|
|
212
|
+
(nothing). A capped Stack starts at the Container's left edge and is never re-centred.
|
|
213
|
+
`as` is `div` (default), `section`, `form`, `ul` or `ol`, and nothing else. A `form` Stack takes
|
|
214
|
+
the form props (`onSubmit`, `action`, `method`, `noValidate`, `aria-*`, `data-*`). A `ul` or `ol`
|
|
215
|
+
Stack has no markers and its children are `<li>`s (the Stack does not wrap them, as with
|
|
216
|
+
`BulletList` and `Timeline`; `Text as="li"` works).
|
|
217
|
+
**`Section`** is a `<section>` Stack (`md` by default, like `Stack`) with an optional `SectionHeading` `title`,
|
|
218
|
+
`actions` beside it and the same `width`.
|
|
219
|
+
- **`Cluster`** spaces items in a row, the counterpart of `Stack`: `gap` is `xs` (`gap-2`, the default), `sm`
|
|
220
|
+
(`gap-4`) or `md` (`gap-6`), `align` the cross axis (`center` (default), `baseline`,
|
|
221
|
+
`start` or `end`), `justify` the main axis (`start` (default), `between` or `end`), and `wrap`
|
|
222
|
+
lets the row flow onto more lines on a narrow screen (off by default). Wrap the one item that
|
|
223
|
+
should take the room left in **`ClusterGrow`**, an input beside its buttons say: it can shrink to
|
|
224
|
+
nothing, so the items beside it stay on the line. An empty `ClusterGrow` is a spacer that pushes
|
|
225
|
+
what follows it to the end. `ButtonGroup` stays the row for buttons; `Cluster` is any other row.
|
|
226
|
+
It is a separate part, not `Stack direction="row"`, because a row has no `lg`, `xl` or
|
|
227
|
+
`width` and a column has no `align` or `justify`.
|
|
228
|
+
- **`FormGrid`** is the two-column form: `Field`s in two columns from `md`, one below. A `Field`
|
|
229
|
+
with `span="full"` (a textarea, a long hint) runs across both. **`FormRow`** is an inline-edit
|
|
230
|
+
row of **`FormRowField`**s (a compact `text-xs` label over a control; `span` 1, 2, 3 or 4 is its
|
|
231
|
+
share of the row, so 2, 1 and 1 give a half and two quarters) with an optional `action` after
|
|
232
|
+
them (a save button, kept at its own width). Every control sits on the bottom edge, so the action
|
|
233
|
+
lines up with them, and under `md` the fields and the action stack. A `FormRow` is always the full
|
|
234
|
+
width of a `FormGrid`. Its `hint` or `error` (which replaces the hint, `role="alert"`) sits under
|
|
235
|
+
the whole row; a `FormRowField` has no note of its own, so the controls beside each other stay
|
|
236
|
+
level. Closed spans, no `col-span` or width to pass.
|
|
237
|
+
|
|
238
|
+
### Page layouts
|
|
239
|
+
|
|
240
|
+
Small compositions of the above: they take slots, own no data, and set no page width or outer
|
|
241
|
+
padding. Each takes the `PageHeader` props (`title`, `subtitle`, `actions`, `breadcrumbs`, `tabs`)
|
|
242
|
+
except the last four.
|
|
243
|
+
|
|
244
|
+
- **`ListLayout`**: use this when a page is a filterable list. `filters` sits under the header;
|
|
245
|
+
`children` is the list, replaced by `empty` when `isEmpty`.
|
|
246
|
+
- **`DetailLayout`**: use this when a page is one thing with facts beside it. `children` take two
|
|
247
|
+
thirds from `lg` and `aside` one third; below `lg` the aside stacks under the main column.
|
|
248
|
+
- **`FormLayout`**: use this for a create or edit page. A `max-w-3xl` `<form>` that starts at the
|
|
249
|
+
Container's left edge (it is not re-centred, so the title does not jump), with an `actions` row at
|
|
250
|
+
the end. Other props (`onSubmit`, `action`) go on the `<form>`.
|
|
251
|
+
- **`ProseLayout`**: use this for a text page (privacy, terms, a CV, an about page). One `narrow`
|
|
252
|
+
(`max-w-3xl`) column that starts at the Container's left edge (not re-centred, so the title does
|
|
253
|
+
not jump): the header, then `children` in a `Stack` (`gap` prop, `md` by default). Unlike
|
|
254
|
+
`FormLayout` the header sits inside the column, so its `actions` stay beside the text.
|
|
255
|
+
- **`CardGrid`**: use this for stat tiles or a gallery of cards. `columns` is `2`, `3` (default) or
|
|
256
|
+
`4`: one column on a phone, two from `sm`, `columns` from `lg`. `columns="auto"` instead fits as
|
|
257
|
+
many per row as the width allows, each at least `minWidth` wide: `md` (17rem, default) or `sm`
|
|
258
|
+
(12rem); `minWidth` is only for `auto`. `align="start"` keeps each card its own height; the
|
|
259
|
+
default stretches every card to the tallest in its row. `as="ul"` makes it a list, each child
|
|
260
|
+
wrapped in an `<li>`.
|
|
261
|
+
- **`SidebarLayout`**: use this when a `rail` of navigation sits beside the content. Render it in a
|
|
262
|
+
plain `<AppShell>`, no `bleed`: it sits in the Container like every other layout, so the rail
|
|
263
|
+
starts at the header logo's x and the canvas ends at the header's right edge. The rail has no fill
|
|
264
|
+
and no border (pill `Tabs` with `orientation="vertical"` is its content); from `md` it is `md:w-56` beside
|
|
265
|
+
the canvas, and below `md` it stacks above it. A long rail sets `railLabel`: below `md` the rail
|
|
266
|
+
then folds, closed, behind a disclosure of that name, so a phone shows the page first.
|
|
267
|
+
- **`FullBleedLayout`**: use this for a viewer that needs the whole width. Render it in
|
|
268
|
+
`<AppShell bleed>`; `children` fill the width, and an optional `panel` takes `lg:w-72` on the
|
|
269
|
+
right and stacks below under `lg`. It adds no padding, so a viewer fills the width; set `padded`
|
|
270
|
+
for a body that is not a viewer, which gives the canvas the other layouts' page padding. A `bar`
|
|
271
|
+
(a viewer's title and actions) runs full width above both on the surface with a bottom line, and
|
|
272
|
+
sticks under the shell's header as the page scrolls; `padded` does not pad it.
|
|
273
|
+
|
|
274
|
+
**`FullBleedLayout` deliberately breaks the shared left edge from `lg` up.** An edge-to-edge
|
|
275
|
+
viewer needs the viewport's edges, so it cannot sit inside the Container the header uses. At
|
|
276
|
+
1280 px the header logo and every other layout start at x=88, `FullBleedLayout`'s `children` at
|
|
277
|
+
x=0 (unless `padded`). Below `lg` it stacks and agrees with the other layouts. To avoid the shift
|
|
278
|
+
between pages, give a whole section of an app the same layout rather than mixing it with
|
|
279
|
+
`ListLayout` pages that a user moves between; the header, which stays inside the Container, is
|
|
280
|
+
the constant. The title's y also
|
|
281
|
+
differs from `ListLayout` only when one page passes `breadcrumbs` and the other does not:
|
|
282
|
+
`PageHeader` puts them above the title in every layout.
|
|
283
|
+
- **`CenteredLayout`**: use this for sign-in, landing, not-found and error pages. A `max-w-md` card
|
|
284
|
+
centred at a fixed offset from the top; the one layout that does not share the left edge.
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
<AppShell header={header}>
|
|
288
|
+
<ListLayout title="Quotes" actions={<Button>New</Button>} filters={<FilterGroup …/>}
|
|
289
|
+
isEmpty={quotes.length === 0} empty={<Text as="p" color="muted">No quotes yet.</Text>}>
|
|
290
|
+
{quotes.map(…)}
|
|
291
|
+
</ListLayout>
|
|
292
|
+
</AppShell>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Light and dark
|
|
296
|
+
|
|
297
|
+
A dark theme does its colour work under `.dark`; this only decides whether `.dark` is on `<html>`.
|
|
298
|
+
Two states, light and dark. The stored choice wins; with nothing stored the system's
|
|
299
|
+
`prefers-color-scheme` decides. Which dark theme is loaded stays your CSS import.
|
|
300
|
+
|
|
301
|
+
1. **Inline `colourSchemeScript` in `<head>`**, before the stylesheet, so the class is right at
|
|
302
|
+
first paint and neither scheme flashes the other. It is a few hundred bytes of plain JavaScript:
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
import { colourSchemeScript } from "@hackcove/ui-react";
|
|
306
|
+
|
|
307
|
+
<head>
|
|
308
|
+
<script dangerouslySetInnerHTML={{ __html: colourSchemeScript }} />
|
|
309
|
+
</head>;
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
In Astro, use `<script is:inline set:html={colourSchemeScript} />`.
|
|
313
|
+
|
|
314
|
+
2. **Drop the hard-coded `class="dark"`** from `<html>`. The script sets and removes it (and
|
|
315
|
+
`color-scheme`, so native controls and scrollbars follow). For a server-rendered `<html>`, add
|
|
316
|
+
`suppressHydrationWarning` (React) because the script changes its `class` before React hydrates.
|
|
317
|
+
|
|
318
|
+
3. **Place the toggle** wherever the header wants it:
|
|
319
|
+
|
|
320
|
+
```tsx
|
|
321
|
+
import { ColourSchemeToggle } from "@hackcove/ui-react";
|
|
322
|
+
|
|
323
|
+
<ColourSchemeToggle onChange={(scheme) => track(scheme)} />;
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
It is an `IconButton` showing the scheme you would switch *to* (a sun on a dark page, a moon on a
|
|
327
|
+
light one), labelled "Switch to light theme" / "Switch to dark theme". A click applies the scheme
|
|
328
|
+
to `<html>` and stores it under `localStorage["hackcove:colour-scheme"]`; a storage error (private
|
|
329
|
+
mode) is swallowed and the page still switches. It reads the current state from the `dark` class
|
|
330
|
+
when it mounts, so it agrees with the script and renders the same on the server and the client.
|
|
331
|
+
It draws both icons and a small `<style>` shows one from the class on `<html>`, so a dark visitor
|
|
332
|
+
with no cookie and no `initial` sees the sun from the first frame, not the server's light-page moon;
|
|
333
|
+
the button's name catches up on hydration.
|
|
334
|
+
|
|
335
|
+
4. **A light theme with a class of its own, or a scheme the server knows.** An app that picks the
|
|
336
|
+
scheme from a cookie and whose light theme has a scope class passes both:
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
import { ColourSchemeToggle, schemeClass } from "@hackcove/ui-react";
|
|
340
|
+
|
|
341
|
+
const schemes = { light: "my-light", dark: "dark" }; // default: { light: "", dark: "dark" }
|
|
342
|
+
|
|
343
|
+
<html className={schemeClass(scheme, schemes)}>
|
|
344
|
+
<ColourSchemeToggle schemes={schemes} initial={scheme} onChange={(next) => saveCookie(next)} />
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Leave `colourSchemeScript` out here: it knows only the `dark` class and the stored choice, so it would fight the server. `schemes` is the class each scheme puts on `<html>`; a click swaps them. `initial` is the scheme the
|
|
348
|
+
server rendered: the server markup, the hydrating client and the `<html>` class (from
|
|
349
|
+
`schemeClass`) then agree, with no flash. Persist the choice from `onChange`; the toggle still
|
|
350
|
+
writes `localStorage`, which an app reading a cookie simply ignores. With neither prop nothing
|
|
351
|
+
changes: `dark` on and off, light on the server.
|
|
352
|
+
|
|
353
|
+
**Persisting to a cookie instead of `localStorage`.** Pass `store={false}` (default `true`) and write the
|
|
354
|
+
choice from `onChange`; the toggle then touches no storage at all:
|
|
355
|
+
|
|
356
|
+
```tsx
|
|
357
|
+
<ColourSchemeToggle
|
|
358
|
+
store={false}
|
|
359
|
+
initial={scheme} // read from the cookie on the server
|
|
360
|
+
onChange={(next) => {
|
|
361
|
+
document.cookie = `scheme=${next}; path=/; max-age=31536000; samesite=lax`;
|
|
362
|
+
// or POST it to the server, which sets the cookie
|
|
363
|
+
}}
|
|
364
|
+
/>
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The toggle takes `id`, `data-*` and the rest of a button's props, which go on its button. It has no
|
|
368
|
+
`aria-pressed`: its label already says what a click does ("Switch to dark theme"), and a pressed state on a
|
|
369
|
+
button whose label changes would say it twice.
|
|
370
|
+
|
|
371
|
+
Without React, `applyColourScheme(scheme, root?, schemes?)`, `readColourScheme(fallback?)`,
|
|
372
|
+
`storeColourScheme(scheme)` and `schemeClass(scheme, schemes?)` are exported too; `ColourScheme` is
|
|
373
|
+
`"light" | "dark"` and `ColourSchemeClasses` is the `schemes` shape.
|
|
374
|
+
|
|
375
|
+
## Palette
|
|
376
|
+
|
|
377
|
+
`palette.json` (in the repo, not in the package) lists the colour utilities the components may use,
|
|
378
|
+
by role.
|
|
379
|
+
|
|
380
|
+
| Role | For |
|
|
381
|
+
| ---------- | --------------------------------------------------------------------- |
|
|
382
|
+
| `canvas` | The page behind the cards: `AppShell` paints it. |
|
|
383
|
+
| `surface` | Cards and panels: the main background. |
|
|
384
|
+
| `subtle` | A step off the surface: table stripes, hover rows. |
|
|
385
|
+
| `inset` | A step deeper: wells, chips, pressed fills. |
|
|
386
|
+
| `line` | Borders and dividers. |
|
|
387
|
+
| `ink` | Text, from primary down to muted, plus input placeholders. |
|
|
388
|
+
| `accent` | Primary actions, links, focus rings and controls; text on the accent. |
|
|
389
|
+
| `positive` | Success: fill, text and border. |
|
|
390
|
+
| `warn` | Caution: fill, text and border. |
|
|
391
|
+
| `danger` | Errors and destructive actions: fill, text and border. |
|
|
392
|
+
| `info` | Neutral notes: fill, text and border. |
|
|
393
|
+
|
|
394
|
+
A theme remaps every utility in `palette.json`; a component uses no colour utility outside it.
|
|
395
|
+
|
|
396
|
+
## Develop
|
|
397
|
+
|
|
398
|
+
From the workspace root: `bunx nx run-many -t typecheck lint test build -p ui-react` and
|
|
399
|
+
`bunx nx pack-check ui-react` (the tarball is `dist/` plus `package.json`, `README.md`, `LICENSE`; it also typechecks the tarball in a
|
|
400
|
+
consumer app: `scripts/consumer-types.ts`).
|
|
401
|
+
The stories live in `apps/storybook`.
|