blunt-ui 0.3.3 → 0.3.5
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/README.md +249 -125
- package/dist/index.cjs +32 -29
- package/dist/index.js +368 -360
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# blunt-ui
|
|
2
2
|
|
|
3
|
-
React
|
|
3
|
+
A React component library I built for myself and started using across projects. Neo-brutalism aesthetic — thick borders, offset shadows, high contrast. No animations, no gradients, no magic. Just components that do what they say.
|
|
4
|
+
|
|
5
|
+
Built with React, TypeScript, and styled-components.
|
|
4
6
|
|
|
5
7
|
**Live demo:** https://blunt-ui.vercel.app/
|
|
6
8
|
|
|
@@ -8,43 +10,129 @@ React + TypeScript + styled-components. Thick borders, offset shadows, no fluff.
|
|
|
8
10
|
|
|
9
11
|
**npm:** https://www.npmjs.com/package/blunt-ui
|
|
10
12
|
|
|
11
|
-
##
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install blunt-ui
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Wrap your app with `ThemeProvider` and `GlobalStyles` once:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { ThemeProvider, GlobalStyles } from "blunt-ui";
|
|
23
|
+
|
|
24
|
+
<ThemeProvider>
|
|
25
|
+
<GlobalStyles />
|
|
26
|
+
<App />
|
|
27
|
+
</ThemeProvider>;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If you're using `useToast` or `useConfirm`, add their providers here too — more on those below.
|
|
31
|
+
|
|
32
|
+
## Running locally
|
|
12
33
|
|
|
13
34
|
```bash
|
|
14
35
|
npm install
|
|
15
|
-
npm run storybook # component explorer
|
|
16
|
-
npm run dev # landing page
|
|
36
|
+
npm run storybook # component explorer, port 6006
|
|
37
|
+
npm run dev # landing page
|
|
17
38
|
npm test
|
|
18
39
|
npm run build
|
|
19
40
|
```
|
|
20
41
|
|
|
21
|
-
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Components
|
|
45
|
+
|
|
46
|
+
### Badge
|
|
47
|
+
|
|
48
|
+
Small inline label for statuses, tags, counts — wherever you need a bit of color-coded context.
|
|
49
|
+
|
|
50
|
+
Variants: `primary`, `neutral`, `success`, `error`, `warning`, `info`. Sizes: `sm` (default), `md`.
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
<Badge variant="success">Active</Badge>
|
|
54
|
+
<Badge variant="error" size="sm">Failed</Badge>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Button
|
|
22
58
|
|
|
23
|
-
|
|
59
|
+
Three variants: `primary` (filled), `secondary` (muted fill), `outline` (border only). Sizes: `sm`, `md`, `lg`.
|
|
24
60
|
|
|
25
61
|
```tsx
|
|
26
62
|
<Button variant="primary" onClick={handleSave}>Save</Button>
|
|
27
63
|
<Button variant="outline" isLoading={submitting}>Submit</Button>
|
|
28
64
|
```
|
|
29
65
|
|
|
30
|
-
`isLoading` disables the button and
|
|
66
|
+
`isLoading` disables the button and swaps the label for "Loading..." — no spinner, keeps the layout stable. The `as` prop lets you swap the underlying element, which is useful when you want a `<Link>` from your router but with button styles.
|
|
31
67
|
|
|
32
|
-
|
|
68
|
+
### Input
|
|
33
69
|
|
|
34
|
-
|
|
70
|
+
Standard text input with a label, optional helper text, error state, left/right icon slots, and a clearable option.
|
|
35
71
|
|
|
36
72
|
```tsx
|
|
37
73
|
<Input label="Email" type="email" error="Enter a valid email" />
|
|
38
74
|
<Input label="Search" clearable leftElement={<SearchIcon />} onClear={() => setValue("")} />
|
|
39
75
|
```
|
|
40
76
|
|
|
41
|
-
|
|
77
|
+
`error` accepts a string (shows a message below) or `true` (just turns the border red). Same pattern used across all form inputs.
|
|
42
78
|
|
|
43
|
-
|
|
79
|
+
### Textarea
|
|
44
80
|
|
|
45
|
-
|
|
81
|
+
Multi-line input. Same props as Input — `label`, `helperText`, `error`, `fullWidth`. Variants: `default`, `outlined`. Sizes: `sm`, `md`, `lg`.
|
|
46
82
|
|
|
47
|
-
|
|
83
|
+
```tsx
|
|
84
|
+
<Textarea label="Notes" helperText="Max 500 chars" />
|
|
85
|
+
<Textarea label="Bio" variant="outlined" error="Required" rows={4} />
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Select
|
|
89
|
+
|
|
90
|
+
Styled native `<select>`. I kept it native because custom dropdowns are more trouble than they're worth for most use cases. Same sizes and error handling as Input.
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
<Select
|
|
94
|
+
options={[{ value: "frontend", label: "Frontend dev" }]}
|
|
95
|
+
placeholder="Pick one"
|
|
96
|
+
value={value}
|
|
97
|
+
onChange={(e) => setValue(e.target.value)}
|
|
98
|
+
clearable
|
|
99
|
+
onClear={() => setValue("")}
|
|
100
|
+
/>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Editable
|
|
104
|
+
|
|
105
|
+
Click-to-edit inline text. Renders as plain text, turns into an input when clicked. Enter confirms, Escape cancels, blur also confirms.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
<Editable defaultValue="Untitled" onSubmit={(v) => save(v)} />
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
For controlled mode, pass `value` + `onChange`:
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
<Editable
|
|
115
|
+
value={title}
|
|
116
|
+
onChange={setTitle}
|
|
117
|
+
onSubmit={(v) => api.rename(v)}
|
|
118
|
+
placeholder="Click to add a title"
|
|
119
|
+
/>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Field
|
|
123
|
+
|
|
124
|
+
A read-only label + value pair. Useful for detail views or anywhere you're displaying data rather than collecting it.
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
<Field label="Status" value="Active" />
|
|
128
|
+
<Field label="Profile" value="View profile" href="/profile" />
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Pass `href` and the value becomes a link.
|
|
132
|
+
|
|
133
|
+
### Modal
|
|
134
|
+
|
|
135
|
+
Opens in a portal on `document.body`. Traps focus, locks body scroll, closes on Escape or backdrop click. Sizes: `sm`, `md`, `lg`, `fullscreen`.
|
|
48
136
|
|
|
49
137
|
```tsx
|
|
50
138
|
<Modal
|
|
@@ -66,33 +154,11 @@ Sizes: `sm`, `md`, `lg`, `fullscreen`.
|
|
|
66
154
|
</Modal>
|
|
67
155
|
```
|
|
68
156
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
A card with a clickable header that shows/hides its content. Supports controlled and uncontrolled modes, an optional subtitle, a slot for header actions, and an `accentColor` prop to tint the border and chevron.
|
|
72
|
-
|
|
73
|
-
```tsx
|
|
74
|
-
<CollapsibleCard title="blunt-ui" subtitle="Subtitle" defaultOpen>
|
|
75
|
-
React component library in neo-brutalism style.
|
|
76
|
-
</CollapsibleCard>
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Pass `open` + `onToggle` for controlled mode:
|
|
80
|
-
|
|
81
|
-
```tsx
|
|
82
|
-
<CollapsibleCard
|
|
83
|
-
title="Project"
|
|
84
|
-
open={isOpen}
|
|
85
|
-
onToggle={setIsOpen}
|
|
86
|
-
accentColor="#f97316"
|
|
87
|
-
headerActions={<Badge variant="primary">npm</Badge>}
|
|
88
|
-
>
|
|
89
|
-
{children}
|
|
90
|
-
</CollapsibleCard>
|
|
91
|
-
```
|
|
157
|
+
The `footer` slot is where you put action buttons — it renders at the bottom of the modal, separated from the content.
|
|
92
158
|
|
|
93
|
-
|
|
159
|
+
### Toast
|
|
94
160
|
|
|
95
|
-
|
|
161
|
+
Auto-dismisses after 4 seconds. Set `duration={0}` if you need it to stay until the user closes it manually.
|
|
96
162
|
|
|
97
163
|
Variants: `success`, `error`, `warning`, `info`. Positions: `bottom-right`, `bottom-left`, `top-right`, `top-left`.
|
|
98
164
|
|
|
@@ -105,28 +171,15 @@ Variants: `success`, `error`, `warning`, `info`. Positions: `bottom-right`, `bot
|
|
|
105
171
|
/>
|
|
106
172
|
```
|
|
107
173
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
Styled native select, works the same as Input for sizes, variants, and error handling. Pass `clearable` to add an X button.
|
|
111
|
-
|
|
112
|
-
```tsx
|
|
113
|
-
<Select
|
|
114
|
-
options={[{ value: "frontend", label: "frontend dev" }]}
|
|
115
|
-
placeholder="pick one"
|
|
116
|
-
value={value}
|
|
117
|
-
onChange={(e) => setValue(e.target.value)}
|
|
118
|
-
clearable
|
|
119
|
-
onClear={() => setValue("")}
|
|
120
|
-
/>
|
|
121
|
-
```
|
|
174
|
+
In practice you'll probably want `useToast` instead — see hooks below.
|
|
122
175
|
|
|
123
|
-
|
|
176
|
+
### Form
|
|
124
177
|
|
|
125
|
-
`Form` is
|
|
178
|
+
`Form` is a flex column wrapper that calls `e.preventDefault()` for you. `FormField` handles the label, error message, helper text, and `htmlFor` wiring — so you don't have to manage IDs manually.
|
|
126
179
|
|
|
127
180
|
```tsx
|
|
128
181
|
<Form onSubmit={handleSubmit}>
|
|
129
|
-
<FormField label="
|
|
182
|
+
<FormField label="Email" error={errors.email} required>
|
|
130
183
|
<Input
|
|
131
184
|
type="email"
|
|
132
185
|
value={email}
|
|
@@ -135,55 +188,59 @@ Styled native select, works the same as Input for sizes, variants, and error han
|
|
|
135
188
|
name="email"
|
|
136
189
|
/>
|
|
137
190
|
</FormField>
|
|
138
|
-
<Button type="submit">
|
|
191
|
+
<Button type="submit">Submit</Button>
|
|
139
192
|
</Form>
|
|
140
193
|
```
|
|
141
194
|
|
|
142
|
-
|
|
195
|
+
Pair with `useForm` for validation — they're designed to work together.
|
|
196
|
+
|
|
197
|
+
### Link
|
|
143
198
|
|
|
144
|
-
|
|
199
|
+
Styled anchor. Variants: `default` and `subtle` (less prominent, for secondary links). Pass `external` and it adds `target="_blank"` and `rel="noopener noreferrer"` so you don't have to remember.
|
|
145
200
|
|
|
146
201
|
```tsx
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
</ToastProvider>
|
|
202
|
+
<Link href="/docs">Documentation</Link>
|
|
203
|
+
<Link href="https://example.com" external variant="subtle">External link</Link>
|
|
150
204
|
```
|
|
151
205
|
|
|
206
|
+
### CollapsibleCard
|
|
207
|
+
|
|
208
|
+
A card with a header you can click to show/hide the content. Works uncontrolled out of the box — just use `defaultOpen` and forget about it. For controlled mode, pass `open` + `onToggle`.
|
|
209
|
+
|
|
152
210
|
```tsx
|
|
153
|
-
|
|
211
|
+
<CollapsibleCard title="Details" subtitle="Optional subtitle" defaultOpen>
|
|
212
|
+
Content goes here.
|
|
213
|
+
</CollapsibleCard>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`accentColor` tints the left border and the chevron icon. `headerActions` is a slot for extra stuff in the header — a badge, a button, whatever.
|
|
154
217
|
|
|
155
|
-
|
|
156
|
-
|
|
218
|
+
```tsx
|
|
219
|
+
<CollapsibleCard
|
|
220
|
+
title="Project"
|
|
221
|
+
open={isOpen}
|
|
222
|
+
onToggle={setIsOpen}
|
|
223
|
+
accentColor="#f97316"
|
|
224
|
+
headerActions={<Badge variant="primary">New</Badge>}
|
|
225
|
+
>
|
|
226
|
+
{children}
|
|
227
|
+
</CollapsibleCard>
|
|
157
228
|
```
|
|
158
229
|
|
|
159
|
-
|
|
230
|
+
### Spinner
|
|
160
231
|
|
|
161
|
-
|
|
232
|
+
Loading indicator. Sizes: `sm`, `md`, `lg`. Weights: `thin`, `normal`, `bold`. You can override the color.
|
|
162
233
|
|
|
163
234
|
```tsx
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
initialValues: { email: "", password: "" },
|
|
167
|
-
validate: (v) => ({
|
|
168
|
-
email: !v.email.trim() ? "required" : undefined,
|
|
169
|
-
password: v.password.length < 8 ? "min 8 chars" : undefined,
|
|
170
|
-
}),
|
|
171
|
-
onSubmit: (values) => {
|
|
172
|
-
/* only called when everything is valid */
|
|
173
|
-
},
|
|
174
|
-
onError: () => {
|
|
175
|
-
toast.error("fix the errors first");
|
|
176
|
-
},
|
|
177
|
-
});
|
|
235
|
+
<Spinner />
|
|
236
|
+
<Spinner size="lg" weight="thin" color="#f97316" label="Loading data..." />
|
|
178
237
|
```
|
|
179
238
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
## Table
|
|
239
|
+
`label` sets an `aria-label` for screen readers but doesn't render visibly.
|
|
183
240
|
|
|
184
|
-
|
|
241
|
+
### Table
|
|
185
242
|
|
|
186
|
-
|
|
243
|
+
Read-only table with sorting and pagination built in. Define columns, pass data — that's the basic case.
|
|
187
244
|
|
|
188
245
|
```tsx
|
|
189
246
|
const columns = [
|
|
@@ -203,63 +260,128 @@ const columns = [
|
|
|
203
260
|
/>;
|
|
204
261
|
```
|
|
205
262
|
|
|
206
|
-
**Sorting** — add `sortable: true` to
|
|
263
|
+
**Sorting** — add `sortable: true` to a column. Client-side by default. To sort server-side, pass `sort` + `onSortChange` — the table just shows the indicator and tells you what changed, you handle the data.
|
|
207
264
|
|
|
208
|
-
**Pagination** — set `pageSize` and the table slices the data automatically. For server-side pagination, also pass `totalRows` so it knows how many pages
|
|
265
|
+
**Pagination** — set `pageSize` and the table slices the data automatically. For server-side pagination, also pass `totalRows` (so it knows how many pages exist) and control `page` + `onPageChange` yourself. Use `onChange` if you need both sort and page changes in one callback.
|
|
209
266
|
|
|
210
|
-
**Other
|
|
267
|
+
**Other props** — `loading` shows skeleton rows while data loads. `stickyHeader` keeps the header visible on scroll. `caption` adds an accessible `<caption>`.
|
|
211
268
|
|
|
212
|
-
Color props: `borderColor`, `headerColor`, `rowColor`, `stripeColor`.
|
|
269
|
+
Color props if you need to match a specific theme: `borderColor`, `headerColor`, `rowColor`, `stripeColor`.
|
|
213
270
|
|
|
214
|
-
|
|
271
|
+
### ConfirmDialog
|
|
215
272
|
|
|
216
|
-
|
|
273
|
+
A specialized modal for destructive actions. Two variants: `default` and `danger` (the confirm button turns red). Wrap this in `useConfirm` if you're triggering it programmatically.
|
|
217
274
|
|
|
218
275
|
```tsx
|
|
219
|
-
|
|
220
|
-
{
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
{ value: "done", label: "Done" },
|
|
230
|
-
],
|
|
231
|
-
},
|
|
232
|
-
];
|
|
276
|
+
<ConfirmDialog
|
|
277
|
+
open={isOpen}
|
|
278
|
+
title="Delete item"
|
|
279
|
+
message="This can't be undone."
|
|
280
|
+
variant="danger"
|
|
281
|
+
confirmLabel="Delete"
|
|
282
|
+
onConfirm={handleDelete}
|
|
283
|
+
onCancel={() => setIsOpen(false)}
|
|
284
|
+
/>
|
|
285
|
+
```
|
|
233
286
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
287
|
+
### DatePicker
|
|
288
|
+
|
|
289
|
+
Calendar-based date input. Sizes: `sm`, `md`, `lg`. Supports `minDate`, `maxDate`, and `clearable`.
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
<DatePicker value={date} onChange={setDate} placeholder="Pick a date" />
|
|
293
|
+
<DatePicker value={date} onChange={setDate} minDate={new Date()} clearable />
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`formatDate` controls how the selected date is displayed in the input field:
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
<DatePicker
|
|
300
|
+
value={date}
|
|
301
|
+
onChange={setDate}
|
|
302
|
+
formatDate={(d) => d.toLocaleDateString("pl-PL")}
|
|
303
|
+
/>
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## Hooks
|
|
309
|
+
|
|
310
|
+
### useToast
|
|
311
|
+
|
|
312
|
+
Wrap your app with `ToastProvider` once, then call `useToast()` anywhere you need to fire a notification.
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
// main.tsx or App.tsx
|
|
316
|
+
<ToastProvider>
|
|
317
|
+
<App />
|
|
318
|
+
</ToastProvider>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
const { toast } = useToast();
|
|
323
|
+
|
|
324
|
+
toast.success("Saved!");
|
|
325
|
+
toast.error("Something went wrong.");
|
|
326
|
+
toast.warning("Are you sure?");
|
|
327
|
+
toast.info("New version available.");
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### useConfirm
|
|
331
|
+
|
|
332
|
+
Same pattern as `useToast` — provider at the top, hook wherever you need it. Returns a promise so you can `await` the user's decision.
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
<ConfirmProvider>
|
|
336
|
+
<App />
|
|
337
|
+
</ConfirmProvider>
|
|
241
338
|
```
|
|
242
339
|
|
|
243
|
-
|
|
340
|
+
```tsx
|
|
341
|
+
const confirm = useConfirm();
|
|
342
|
+
|
|
343
|
+
const handleDelete = async () => {
|
|
344
|
+
const ok = await confirm({
|
|
345
|
+
title: "Delete item",
|
|
346
|
+
message: "This can't be undone.",
|
|
347
|
+
variant: "danger",
|
|
348
|
+
confirmLabel: "Delete",
|
|
349
|
+
});
|
|
350
|
+
if (ok) deleteItem();
|
|
351
|
+
};
|
|
352
|
+
```
|
|
244
353
|
|
|
245
|
-
|
|
354
|
+
Much cleaner than managing open/close state for every confirm dialog in your app.
|
|
246
355
|
|
|
247
|
-
|
|
356
|
+
### useForm
|
|
248
357
|
|
|
249
|
-
|
|
358
|
+
Handles values, touched state, validation, and errors. Errors only show after a field has been touched or a submit was attempted — no red borders on page load.
|
|
250
359
|
|
|
251
|
-
|
|
360
|
+
```tsx
|
|
361
|
+
const { values, errors, handleChange, handleBlur, handleSubmit, reset } =
|
|
362
|
+
useForm({
|
|
363
|
+
initialValues: { email: "", password: "" },
|
|
364
|
+
validate: (v) => ({
|
|
365
|
+
email: !v.email.trim() ? "Required" : undefined,
|
|
366
|
+
password: v.password.length < 8 ? "Min 8 characters" : undefined,
|
|
367
|
+
}),
|
|
368
|
+
onSubmit: (values) => {
|
|
369
|
+
// only called when all validations pass
|
|
370
|
+
},
|
|
371
|
+
onError: () => {
|
|
372
|
+
toast.error("Fix the errors first.");
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
```
|
|
252
376
|
|
|
253
|
-
|
|
377
|
+
Each input's `name` needs to match a key in `initialValues`. `reset()` puts everything back to the initial state.
|
|
254
378
|
|
|
255
|
-
|
|
379
|
+
### useTable
|
|
256
380
|
|
|
257
|
-
|
|
381
|
+
A small helper for server-side table state. Tracks the current sort and page so you can pass them to a fetch call, then hands everything back to `<Table>` as controlled props.
|
|
258
382
|
|
|
259
383
|
```tsx
|
|
260
|
-
const { sort, page, onSortChange, onPageChange } = useTable({
|
|
261
|
-
defaultPage: 1,
|
|
262
|
-
});
|
|
384
|
+
const { sort, page, onSortChange, onPageChange } = useTable({ defaultPage: 1 });
|
|
263
385
|
|
|
264
386
|
useEffect(() => {
|
|
265
387
|
fetchProducts({ sort, page });
|
|
@@ -277,8 +399,10 @@ useEffect(() => {
|
|
|
277
399
|
/>;
|
|
278
400
|
```
|
|
279
401
|
|
|
280
|
-
|
|
402
|
+
Changing the sort resets the page to 1 automatically — saves you from ending up on page 5 of a different sort order. Pass `defaultSort` if you need a column pre-sorted on first load.
|
|
403
|
+
|
|
404
|
+
---
|
|
281
405
|
|
|
282
406
|
## Design tokens
|
|
283
407
|
|
|
284
|
-
|
|
408
|
+
Everything lives in `src/consts.ts` — colors, spacing, font sizes, border radius. All components reference these, so updating a single token changes the look across the whole library.
|