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.
Files changed (4) hide show
  1. package/README.md +249 -125
  2. package/dist/index.cjs +32 -29
  3. package/dist/index.js +368 -360
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # blunt-ui
2
2
 
3
- React + TypeScript + styled-components. Thick borders, offset shadows, no fluff. 11 components, 3 hooks.
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
- ## Getting started
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 on port 6006
16
- npm run dev # landing page with link to Storybook
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
- ## Button
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
- Variants: `primary`, `secondary`, `outline`. Sizes: `sm`, `md`, `lg`.
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 shows "Loading...". The `as` prop swaps the element, handy for router links with button styles.
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
- ## Input
68
+ ### Input
33
69
 
34
- Label, helper text, error message, left/right icon slots, and an optional clear button.
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
- Pass a string to `error` to show a message, or `true` for just the red border.
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
- ## Modal
79
+ ### Textarea
44
80
 
45
- Opens in a portal on `document.body`. Traps focus, locks body scroll, and closes on Escape or backdrop click by default.
81
+ Multi-line input. Same props as Input `label`, `helperText`, `error`, `fullWidth`. Variants: `default`, `outlined`. Sizes: `sm`, `md`, `lg`.
46
82
 
47
- Sizes: `sm`, `md`, `lg`, `fullscreen`.
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
- ## CollapsibleCard
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
- ## Toast
159
+ ### Toast
94
160
 
95
- Pops up in a portal, auto-dismisses after 4 seconds. Set `duration={0}` to keep it until the user closes it.
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
- ## Select
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
- ## Form
176
+ ### Form
124
177
 
125
- `Form` is just a flex column wrapper that handles `preventDefault`. `FormField` adds the label, error message, helper text, and wires up the `htmlFor` automatically.
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="email" error={errors.email} required>
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">submit</Button>
191
+ <Button type="submit">Submit</Button>
139
192
  </Form>
140
193
  ```
141
194
 
142
- ## useToast
195
+ Pair with `useForm` for validation — they're designed to work together.
196
+
197
+ ### Link
143
198
 
144
- Add `ToastProvider` once at the top of your app, then use `useToast()` anywhere inside it.
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
- <ToastProvider>
148
- <App />
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
- const { toast } = useToast();
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
- toast.success("saved!");
156
- toast.error("something went wrong");
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
- ## useForm
230
+ ### Spinner
160
231
 
161
- Handles values, validation, and errors so you don't have to wire it all up yourself. Errors only show after the user has touched a field or tried to submit.
232
+ Loading indicator. Sizes: `sm`, `md`, `lg`. Weights: `thin`, `normal`, `bold`. You can override the color.
162
233
 
163
234
  ```tsx
164
- const { values, errors, handleChange, handleBlur, handleSubmit, reset } =
165
- useForm({
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
- The `name` on each input needs to match the key in `initialValues`. Use `reset()` to clear everything back to the start.
181
-
182
- ## Table
239
+ `label` sets an `aria-label` for screen readers but doesn't render visibly.
183
240
 
184
- Read-only table with sorting and pagination. You define columns, pass data, done. Sorting and pagination work in both uncontrolled mode (component handles state internally) and controlled mode (you own the state, useful when data comes from an API).
241
+ ### Table
185
242
 
186
- Sizes: `sm`, `md`, `lg`.
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 any column. By default the table sorts client-side. If you want to handle it yourself (e.g. send a query param to your backend), pass `sort` + `onSortChange` and it becomes controlled — the table just shows the sort indicator and tells you when it changed, you bring the sorted data.
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 there are, and control `page` + `onPageChange` yourself. If you need both sort and page in one callback, use `onChange` instead.
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 stuff** — `loading` replaces rows with skeleton cells while data is loading. `stickyHeader` keeps the header in view when the table scrolls. `caption` adds a proper `<caption>` for accessibility.
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
- ## DataTable
271
+ ### ConfirmDialog
215
272
 
216
- Editable table good for things like spreadsheet-style input or inline CRUD. Each column can be editable or not, and you can mix text inputs with dropdowns.
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
- const columns = [
220
- { key: "name", header: "Name", editable: true },
221
- { key: "qty", header: "Qty", editable: true, width: "80px" },
222
- {
223
- key: "status",
224
- header: "Status",
225
- editable: true,
226
- options: [
227
- { value: "todo", label: "To do" },
228
- { value: "in_progress", label: "In progress" },
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
- <DataTable
235
- columns={columns}
236
- defaultData={[{ name: "Widget", qty: "1", status: "todo" }]}
237
- onChange={(rows) => console.log(rows)}
238
- deletable
239
- addRowLabel="Add item"
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
- **Editing** — click a text cell to start editing, Enter to confirm, Escape to cancel. For select cells (columns with `options`), one click opens the dropdown directly; picking an option saves immediately. Tab and Shift+Tab move between editable cells. Tab past the last cell adds a new row automatically.
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
- **Select columns** add `options: [{ value, label }]` to a column. The cell stores the `value` but displays the `label`. The column still needs `editable: true`.
354
+ Much cleaner than managing open/close state for every confirm dialog in your app.
246
355
 
247
- **Controlled vs uncontrolled** — same pattern as the rest of the library. Pass `defaultData` and forget about it, or pass `data` + `onChange` if you need to keep the data in your own state.
356
+ ### useForm
248
357
 
249
- **Per-cell editability** `editable` can be a function `(row, rowIndex) => boolean` if you need some cells to be editable based on row content.
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
- `deletable` adds a remove button per row. `newRowFactory` lets you control what an empty new row looks like (useful if you need generated IDs or default values).
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
- Color props: `borderColor`, `headerColor`.
377
+ Each input's `name` needs to match a key in `initialValues`. `reset()` puts everything back to the initial state.
254
378
 
255
- ## useTable
379
+ ### useTable
256
380
 
257
- Helper hook for when your table data comes from a server. It keeps track of the current sort and page so you can use them in a fetch call, and passes them back to `<Table>` as controlled props.
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
- When the user changes the sort column, the page resets to 1 automatically — that's usually what you want so you don't end up on page 5 of a different sort order. Pass `defaultSort` if you need a column sorted on first load.
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
- Colors, spacing, font sizes, and border radius live in `src/consts.ts`. All components pull from there, so changing a token updates everything at once.
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.