@harismawan/stamp-ui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import styled from 'styled-components';
4
+ import { UploadCloud, File as FileIcon, X } from 'lucide-react';
5
+ const Wrap = styled.div `
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: ${(p) => p.theme.space[3]};
9
+ width: 100%;
10
+ `;
11
+ const Zone = styled.div `
12
+ display: flex;
13
+ flex-direction: column;
14
+ align-items: center;
15
+ justify-content: center;
16
+ gap: ${(p) => p.theme.space[2]};
17
+ padding: ${(p) => p.theme.space[7]} ${(p) => p.theme.space[5]};
18
+ text-align: center;
19
+ font-family: ${(p) => p.theme.font.body};
20
+ color: ${(p) => p.theme.colors.text};
21
+ background: ${(p) => p.$dragging ? p.theme.colors.primarySoft : p.theme.colors.surface};
22
+ border: 2px solid ${(p) => p.theme.colors.border};
23
+ border-radius: ${(p) => p.theme.radii.md};
24
+ box-shadow: ${(p) => (p.$dragging ? p.theme.shadow.stamp : p.theme.shadow.none)};
25
+ cursor: ${(p) => (p.$disabled ? 'not-allowed' : 'pointer')};
26
+ opacity: ${(p) => (p.$disabled ? 0.6 : 1)};
27
+ transition: box-shadow 80ms ${(p) => p.theme.easing.out},
28
+ background 80ms ${(p) => p.theme.easing.out};
29
+
30
+ &:focus {
31
+ outline: none;
32
+ box-shadow: ${(p) => p.theme.shadow.stamp};
33
+ }
34
+ `;
35
+ const Prompt = styled.span `
36
+ font-size: 0.9375rem;
37
+ font-weight: 700;
38
+ `;
39
+ const Hint = styled.span `
40
+ font-size: 0.8125rem;
41
+ font-weight: 600;
42
+ color: ${(p) => p.theme.colors.textSubtle};
43
+ `;
44
+ const HiddenInput = styled.input `
45
+ position: absolute;
46
+ width: 1px;
47
+ height: 1px;
48
+ padding: 0;
49
+ margin: -1px;
50
+ overflow: hidden;
51
+ clip: rect(0, 0, 0, 0);
52
+ white-space: nowrap;
53
+ border: 0;
54
+ `;
55
+ const FileList = styled.ul `
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: ${(p) => p.theme.space[2]};
59
+ list-style: none;
60
+ margin: 0;
61
+ padding: 0;
62
+ `;
63
+ const FileRow = styled.li `
64
+ display: flex;
65
+ align-items: center;
66
+ gap: ${(p) => p.theme.space[2]};
67
+ padding: ${(p) => p.theme.space[2]} ${(p) => p.theme.space[3]};
68
+ font-family: ${(p) => p.theme.font.body};
69
+ font-size: 0.875rem;
70
+ font-weight: 600;
71
+ color: ${(p) => p.theme.colors.text};
72
+ background: ${(p) => p.theme.colors.surfaceMuted};
73
+ border: 2px solid ${(p) => p.theme.colors.border};
74
+ border-radius: ${(p) => p.theme.radii.sm};
75
+ `;
76
+ const FileName = styled.span `
77
+ flex: 1;
78
+ min-width: 0;
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ white-space: nowrap;
82
+ `;
83
+ const FileSize = styled.span `
84
+ flex-shrink: 0;
85
+ font-size: 0.8125rem;
86
+ font-weight: 600;
87
+ color: ${(p) => p.theme.colors.textSubtle};
88
+ `;
89
+ const RemoveButton = styled.button `
90
+ display: inline-flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ flex-shrink: 0;
94
+ width: 22px;
95
+ height: 22px;
96
+ padding: 0;
97
+ margin: 0;
98
+ color: ${(p) => p.theme.colors.textMuted};
99
+ background: transparent;
100
+ border: none;
101
+ border-radius: ${(p) => p.theme.radii.xs};
102
+ cursor: pointer;
103
+ transition: color 80ms ${(p) => p.theme.easing.out},
104
+ background 80ms ${(p) => p.theme.easing.out};
105
+
106
+ &:hover {
107
+ color: ${(p) => p.theme.colors.text};
108
+ background: ${(p) => p.theme.colors.surfaceSunken};
109
+ }
110
+
111
+ &:focus-visible {
112
+ outline: 2px solid ${(p) => p.theme.colors.accent};
113
+ outline-offset: 1px;
114
+ }
115
+ `;
116
+ /** Humanize a byte count into B / KB / MB. */
117
+ export function formatFileSize(bytes) {
118
+ if (bytes < 1024)
119
+ return `${bytes} B`;
120
+ const kb = bytes / 1024;
121
+ if (kb < 1024)
122
+ return `${kb.toFixed(kb < 10 ? 1 : 0)} KB`;
123
+ const mb = kb / 1024;
124
+ return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
125
+ }
126
+ /**
127
+ * Loose `accept` match: each token is either a MIME type (with optional
128
+ * wildcard like `image/*`) or a file extension (`.pdf`). Empty/undefined
129
+ * accept matches everything.
130
+ */
131
+ function matchesAccept(file, accept) {
132
+ if (accept == null || accept.trim() === '')
133
+ return true;
134
+ const tokens = accept
135
+ .split(',')
136
+ .map((t) => t.trim().toLowerCase())
137
+ .filter((t) => t.length > 0);
138
+ if (tokens.length === 0)
139
+ return true;
140
+ const name = file.name.toLowerCase();
141
+ const type = file.type.toLowerCase();
142
+ return tokens.some((token) => {
143
+ if (token.startsWith('.')) {
144
+ return name.endsWith(token);
145
+ }
146
+ if (token.endsWith('/*')) {
147
+ const base = token.slice(0, token.indexOf('/'));
148
+ return type.startsWith(`${base}/`);
149
+ }
150
+ return type === token;
151
+ });
152
+ }
153
+ export const FileUpload = ({ value, defaultValue = [], onChange, accept, multiple = false, maxSize, maxFiles, disabled = false, onReject, label = 'Drag files here or click to browse', id, }) => {
154
+ const reactId = React.useId();
155
+ const inputId = id ?? reactId;
156
+ const inputRef = React.useRef(null);
157
+ const [dragging, setDragging] = React.useState(false);
158
+ const isControlled = value !== undefined;
159
+ const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
160
+ const files = isControlled ? value : uncontrolled;
161
+ const commit = React.useCallback((next) => {
162
+ if (!isControlled)
163
+ setUncontrolled(next);
164
+ onChange?.(next);
165
+ }, [isControlled, onChange]);
166
+ const ingest = React.useCallback((incoming) => {
167
+ if (disabled || incoming.length === 0)
168
+ return;
169
+ const rejections = [];
170
+ // For single mode, a new accepted file replaces whatever is held, so the
171
+ // running "accepted" set starts empty; for multi we merge onto current.
172
+ const accepted = multiple ? [...files] : [];
173
+ for (const file of incoming) {
174
+ if (maxSize != null && file.size > maxSize) {
175
+ rejections.push({ file, reason: 'too-large' });
176
+ continue;
177
+ }
178
+ if (!matchesAccept(file, accept)) {
179
+ rejections.push({ file, reason: 'wrong-type' });
180
+ continue;
181
+ }
182
+ if (!multiple) {
183
+ // Replace: only the last accepted file survives.
184
+ accepted.length = 0;
185
+ accepted.push(file);
186
+ continue;
187
+ }
188
+ if (maxFiles != null && accepted.length >= maxFiles) {
189
+ rejections.push({ file, reason: 'too-many' });
190
+ continue;
191
+ }
192
+ accepted.push(file);
193
+ }
194
+ if (rejections.length > 0)
195
+ onReject?.(rejections);
196
+ // Single mode: if nothing passed validation, keep the current selection
197
+ // rather than wiping it with an empty commit.
198
+ if (!multiple && accepted.length === 0)
199
+ return;
200
+ const changed = accepted.length !== files.length ||
201
+ accepted.some((f, i) => f !== files[i]);
202
+ if (changed)
203
+ commit(accepted);
204
+ }, [disabled, multiple, files, maxSize, accept, maxFiles, onReject, commit]);
205
+ const handleInputChange = (e) => {
206
+ const list = e.target.files;
207
+ if (list != null)
208
+ ingest(Array.from(list));
209
+ // Reset so selecting the same file again re-fires change.
210
+ e.target.value = '';
211
+ };
212
+ const openPicker = () => {
213
+ if (disabled)
214
+ return;
215
+ inputRef.current?.click();
216
+ };
217
+ const handleKeyDown = (e) => {
218
+ if (disabled)
219
+ return;
220
+ if (e.key === 'Enter' || e.key === ' ') {
221
+ e.preventDefault();
222
+ openPicker();
223
+ }
224
+ };
225
+ const handleDragOver = (e) => {
226
+ if (disabled)
227
+ return;
228
+ e.preventDefault();
229
+ setDragging(true);
230
+ };
231
+ const handleDragEnter = (e) => {
232
+ if (disabled)
233
+ return;
234
+ e.preventDefault();
235
+ setDragging(true);
236
+ };
237
+ const handleDragLeave = (e) => {
238
+ if (disabled)
239
+ return;
240
+ e.preventDefault();
241
+ // dragleave fires when the cursor crosses into a child element (icon /
242
+ // prompt / hint). Only clear the highlight when the pointer has actually
243
+ // left the zone, not when it moves onto a descendant.
244
+ const next = e.relatedTarget;
245
+ if (next != null && e.currentTarget.contains(next))
246
+ return;
247
+ setDragging(false);
248
+ };
249
+ const handleDrop = (e) => {
250
+ e.preventDefault();
251
+ setDragging(false);
252
+ if (disabled)
253
+ return;
254
+ const list = e.dataTransfer?.files;
255
+ if (list != null)
256
+ ingest(Array.from(list));
257
+ };
258
+ const remove = (target) => {
259
+ if (disabled)
260
+ return;
261
+ commit(files.filter((f) => f !== target));
262
+ };
263
+ const hint = multiple
264
+ ? maxFiles != null
265
+ ? `Up to ${maxFiles} files`
266
+ : 'Multiple files allowed'
267
+ : 'Single file';
268
+ return (_jsxs(Wrap, { children: [_jsxs(Zone, { role: "button", tabIndex: disabled ? undefined : 0, "aria-disabled": disabled || undefined, "aria-controls": inputId, "$dragging": dragging, "$disabled": disabled, onClick: openPicker, onKeyDown: handleKeyDown, onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, children: [_jsx(UploadCloud, { size: 28, strokeWidth: 2.5, "aria-hidden": "true" }), _jsx(Prompt, { children: label }), _jsx(Hint, { children: hint })] }), _jsx(HiddenInput, { ref: inputRef, id: inputId, type: "file", accept: accept, multiple: multiple, disabled: disabled, tabIndex: -1, onChange: handleInputChange }), files.length > 0 ? (_jsx(FileList, { children: files.map((file, i) => (_jsxs(FileRow, { children: [_jsx(FileIcon, { size: 16, strokeWidth: 2.5, "aria-hidden": "true" }), _jsx(FileName, { children: file.name }), _jsx(FileSize, { children: formatFileSize(file.size) }), _jsx(RemoveButton, { type: "button", "aria-label": `Remove ${file.name}`, disabled: disabled, onClick: () => remove(file), children: _jsx(X, { size: 16, strokeWidth: 3, "aria-hidden": "true" }) })] }, `${file.name}-${file.size}-${i}`))) })) : null] }));
269
+ };
@@ -0,0 +1,28 @@
1
+ import * as React from 'react';
2
+ export interface TagInputProps {
3
+ value?: string[];
4
+ defaultValue?: string[];
5
+ onChange?: (tags: string[]) => void;
6
+ placeholder?: string;
7
+ disabled?: boolean;
8
+ max?: number;
9
+ validate?: (tag: string) => boolean;
10
+ /** @default false */
11
+ allowDuplicates?: boolean;
12
+ /** @default ['Enter', ','] */
13
+ delimiters?: string[];
14
+ id?: string;
15
+ /**
16
+ * Accessible name for the inline text input. Kept independent of
17
+ * `placeholder` so the textbox always has a stable name.
18
+ * @default 'Add tags'
19
+ */
20
+ 'aria-label'?: string;
21
+ /**
22
+ * Accessible name for the wrapping `role="group"`, so the grouping
23
+ * semantics convey purpose to assistive technology.
24
+ * @default 'Tags'
25
+ */
26
+ groupLabel?: string;
27
+ }
28
+ export declare const TagInput: React.FC<TagInputProps>;
@@ -0,0 +1,112 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import styled from 'styled-components';
4
+ import { Tag } from './Tag';
5
+ const Wrap = styled.div `
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ align-items: center;
9
+ gap: ${(p) => p.theme.space[1]};
10
+ width: 100%;
11
+ min-width: 0;
12
+ background: ${(p) => p.theme.colors.surface};
13
+ color: ${(p) => p.theme.colors.text};
14
+ border: 2px solid ${(p) => p.theme.colors.border};
15
+ border-radius: ${(p) => p.theme.radii.md};
16
+ padding: 7px 10px;
17
+ transition: box-shadow 80ms ${(p) => p.theme.easing.out};
18
+ opacity: ${(p) => (p.$disabled ? 0.6 : 1)};
19
+ cursor: ${(p) => (p.$disabled ? 'not-allowed' : 'text')};
20
+
21
+ &:focus-within {
22
+ box-shadow: ${(p) => p.theme.shadow.stamp};
23
+ }
24
+ `;
25
+ const InlineInput = styled.input `
26
+ flex: 1;
27
+ min-width: 80px;
28
+ font-family: inherit;
29
+ font-size: 1rem;
30
+ color: ${(p) => p.theme.colors.text};
31
+ background: transparent;
32
+ border: none;
33
+ padding: 4px 2px;
34
+
35
+ &::placeholder {
36
+ color: ${(p) => p.theme.colors.textSubtle};
37
+ }
38
+
39
+ &:focus {
40
+ outline: none;
41
+ }
42
+
43
+ &:disabled {
44
+ cursor: not-allowed;
45
+ }
46
+ `;
47
+ export const TagInput = ({ value, defaultValue, onChange, placeholder, disabled = false, max, validate, allowDuplicates = false, delimiters = ['Enter', ','], id: idProp, 'aria-label': ariaLabel = 'Add tags', groupLabel = 'Tags', }) => {
48
+ const reactId = React.useId();
49
+ const baseId = idProp ?? reactId;
50
+ const isControlled = value !== undefined;
51
+ const [uncontrolled, setUncontrolled] = React.useState(() => value ?? defaultValue ?? []);
52
+ const tags = isControlled ? (value ?? []) : uncontrolled;
53
+ const [draft, setDraft] = React.useState('');
54
+ const inputRef = React.useRef(null);
55
+ const commit = (next) => {
56
+ if (!isControlled)
57
+ setUncontrolled(next);
58
+ onChange?.(next);
59
+ };
60
+ const canAdd = (tag) => {
61
+ if (tag === '')
62
+ return false;
63
+ if (max != null && tags.length >= max)
64
+ return false;
65
+ if (validate != null && !validate(tag))
66
+ return false;
67
+ if (!allowDuplicates && tags.includes(tag))
68
+ return false;
69
+ return true;
70
+ };
71
+ const addTag = () => {
72
+ const tag = draft.trim();
73
+ if (!canAdd(tag))
74
+ return;
75
+ commit([...tags, tag]);
76
+ setDraft('');
77
+ };
78
+ const removeAt = (index) => {
79
+ if (disabled)
80
+ return;
81
+ commit(tags.filter((_, i) => i !== index));
82
+ };
83
+ const isDelimiterKey = (e) => {
84
+ return delimiters.includes(e.key);
85
+ };
86
+ const handleKeyDown = (e) => {
87
+ if (disabled)
88
+ return;
89
+ if (isDelimiterKey(e)) {
90
+ // Prevent the literal comma (or any printable delimiter) from being typed.
91
+ e.preventDefault();
92
+ addTag();
93
+ return;
94
+ }
95
+ if (e.key === 'Backspace' && draft === '' && tags.length > 0) {
96
+ e.preventDefault();
97
+ commit(tags.slice(0, -1));
98
+ }
99
+ };
100
+ const focusInput = () => {
101
+ if (!disabled)
102
+ inputRef.current?.focus();
103
+ };
104
+ return (_jsxs(Wrap, { role: "group", id: baseId, "aria-label": groupLabel, "$disabled": disabled, onMouseDown: (e) => {
105
+ // Clicking blank wrapper area focuses the input without stealing focus
106
+ // from an interactive child (e.g. a chip remove button).
107
+ if (e.target === e.currentTarget) {
108
+ e.preventDefault();
109
+ focusInput();
110
+ }
111
+ }, children: [tags.map((tag, index) => (_jsx(Tag, { onRemove: disabled ? undefined : () => removeAt(index), children: tag }, `${tag}-${index}`))), _jsx(InlineInput, { ref: inputRef, id: `${baseId}-input`, type: "text", value: draft, placeholder: placeholder, disabled: disabled, "aria-label": ariaLabel, onChange: (e) => setDraft(e.target.value), onKeyDown: handleKeyDown })] }));
112
+ };
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+ export interface TreeNode {
3
+ id: string;
4
+ label: React.ReactNode;
5
+ icon?: React.ReactNode;
6
+ children?: TreeNode[];
7
+ disabled?: boolean;
8
+ }
9
+ export interface TreeViewProps {
10
+ nodes: TreeNode[];
11
+ expandedIds?: string[];
12
+ defaultExpandedIds?: string[];
13
+ onExpandedChange?: (ids: string[]) => void;
14
+ selectedId?: string | null;
15
+ defaultSelectedId?: string | null;
16
+ onSelect?: (id: string) => void;
17
+ id?: string;
18
+ }
19
+ export declare function TreeView({ nodes, expandedIds, defaultExpandedIds, onExpandedChange, selectedId, defaultSelectedId, onSelect, id: idProp, }: TreeViewProps): import("react/jsx-runtime").JSX.Element;