@harismawan/stamp-ui 0.1.0 → 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.
- package/README.md +3 -2
- package/dist/GlobalStyles.js +2 -5
- package/dist/components/Button.js +2 -4
- package/dist/components/Checkbox.d.ts +6 -0
- package/dist/components/Checkbox.js +12 -4
- package/dist/components/Combobox.d.ts +30 -0
- package/dist/components/Combobox.js +352 -0
- package/dist/components/Command.d.ts +20 -0
- package/dist/components/Command.js +273 -0
- package/dist/components/DataTable.d.ts +37 -0
- package/dist/components/DataTable.js +229 -0
- package/dist/components/DatePicker.d.ts +15 -0
- package/dist/components/DatePicker.js +199 -0
- package/dist/components/DateRangePicker.d.ts +19 -0
- package/dist/components/DateRangePicker.js +255 -0
- package/dist/components/FileUpload.d.ts +33 -0
- package/dist/components/FileUpload.js +269 -0
- package/dist/components/TagInput.d.ts +28 -0
- package/dist/components/TagInput.js +112 -0
- package/dist/components/TreeView.d.ts +19 -0
- package/dist/components/TreeView.js +282 -0
- package/dist/components/internal/calendar.d.ts +52 -0
- package/dist/components/internal/calendar.js +301 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/package.json +7 -1
|
@@ -0,0 +1,282 @@
|
|
|
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 { ChevronRight } from 'lucide-react';
|
|
5
|
+
const Root = styled.div `
|
|
6
|
+
font-family: ${(p) => p.theme.font.body};
|
|
7
|
+
color: ${(p) => p.theme.colors.text};
|
|
8
|
+
`;
|
|
9
|
+
const Group = styled.div `
|
|
10
|
+
/* role="group" container for a branch's children. */
|
|
11
|
+
`;
|
|
12
|
+
// The treeitem element itself. It owns focus (roving tabIndex) so the focus
|
|
13
|
+
// ring lives here, but the visual row layout lives in the presentational Row
|
|
14
|
+
// nested inside it. This lets the treeitem OWN its child role="group".
|
|
15
|
+
const Item = styled.div `
|
|
16
|
+
outline: none;
|
|
17
|
+
border-radius: ${(p) => p.theme.radii.sm};
|
|
18
|
+
|
|
19
|
+
&:focus-visible {
|
|
20
|
+
box-shadow: ${(p) => p.theme.shadow.stamp};
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
const Row = styled.div `
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: ${(p) => p.theme.space[2]};
|
|
27
|
+
padding: ${(p) => p.theme.space[2]} ${(p) => p.theme.space[3]};
|
|
28
|
+
padding-left: calc(
|
|
29
|
+
${(p) => p.theme.space[3]} + ${(p) => p.$level - 1} * ${(p) => p.theme.space[4]}
|
|
30
|
+
);
|
|
31
|
+
border-radius: ${(p) => p.theme.radii.sm};
|
|
32
|
+
font-size: 0.95rem;
|
|
33
|
+
font-weight: ${(p) => (p.$selected ? 800 : 600)};
|
|
34
|
+
background: ${(p) => (p.$selected ? p.theme.colors.primarySoft : 'transparent')};
|
|
35
|
+
color: ${(p) => p.theme.colors.text};
|
|
36
|
+
cursor: ${(p) => (p.$disabled ? 'not-allowed' : 'pointer')};
|
|
37
|
+
opacity: ${(p) => (p.$disabled ? 0.5 : 1)};
|
|
38
|
+
user-select: none;
|
|
39
|
+
transition: background 80ms ${(p) => p.theme.easing.out};
|
|
40
|
+
`;
|
|
41
|
+
const Toggle = styled.span `
|
|
42
|
+
display: inline-flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
flex: none;
|
|
46
|
+
width: 18px;
|
|
47
|
+
height: 18px;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
color: ${(p) => p.theme.colors.textMuted};
|
|
50
|
+
transition: transform 80ms ${(p) => p.theme.easing.out};
|
|
51
|
+
transform: rotate(${(p) => (p.$expanded ? '90deg' : '0deg')});
|
|
52
|
+
`;
|
|
53
|
+
// Reserves the same horizontal space as the chevron for leaf nodes so labels
|
|
54
|
+
// line up with their sibling branches.
|
|
55
|
+
const TogglePlaceholder = styled.span `
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
flex: none;
|
|
58
|
+
width: 18px;
|
|
59
|
+
height: 18px;
|
|
60
|
+
`;
|
|
61
|
+
const IconSlot = styled.span `
|
|
62
|
+
display: inline-flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
flex: none;
|
|
65
|
+
`;
|
|
66
|
+
const Label = styled.span `
|
|
67
|
+
flex: 1;
|
|
68
|
+
min-width: 0;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
text-overflow: ellipsis;
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
`;
|
|
73
|
+
export function TreeView({ nodes, expandedIds, defaultExpandedIds, onExpandedChange, selectedId, defaultSelectedId, onSelect, id: idProp, }) {
|
|
74
|
+
const reactId = React.useId();
|
|
75
|
+
const baseId = idProp ?? reactId;
|
|
76
|
+
const expandedControlled = expandedIds !== undefined;
|
|
77
|
+
const selectedControlled = selectedId !== undefined;
|
|
78
|
+
const [expandedUncontrolled, setExpandedUncontrolled] = React.useState(() => defaultExpandedIds ?? []);
|
|
79
|
+
const [selectedUncontrolled, setSelectedUncontrolled] = React.useState(() => defaultSelectedId ?? null);
|
|
80
|
+
const expanded = expandedControlled ? expandedIds : expandedUncontrolled;
|
|
81
|
+
const selected = selectedControlled ? selectedId : selectedUncontrolled;
|
|
82
|
+
const expandedSet = React.useMemo(() => new Set(expanded), [expanded]);
|
|
83
|
+
// Flatten the tree into the order treeitems are visible on screen, honoring
|
|
84
|
+
// the current expanded set. This drives roving tabindex + keyboard nav.
|
|
85
|
+
const visible = React.useMemo(() => {
|
|
86
|
+
const out = [];
|
|
87
|
+
const walk = (list, level, parentId) => {
|
|
88
|
+
for (const node of list) {
|
|
89
|
+
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
|
|
90
|
+
const isExpanded = hasChildren && expandedSet.has(node.id);
|
|
91
|
+
out.push({ node, level, parentId, hasChildren, expanded: isExpanded });
|
|
92
|
+
if (isExpanded && node.children) {
|
|
93
|
+
walk(node.children, level + 1, node.id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(nodes, 1, null);
|
|
98
|
+
return out;
|
|
99
|
+
}, [nodes, expandedSet]);
|
|
100
|
+
// The treeitem that owns tabIndex 0 (roving). Falls back to the first
|
|
101
|
+
// enabled visible item if the active one disappears (e.g. parent collapsed).
|
|
102
|
+
const [activeId, setActiveId] = React.useState(null);
|
|
103
|
+
const firstEnabledId = React.useMemo(() => {
|
|
104
|
+
const found = visible.find((v) => !v.node.disabled);
|
|
105
|
+
return found ? found.node.id : null;
|
|
106
|
+
}, [visible]);
|
|
107
|
+
const effectiveActiveId = activeId !== null && visible.some((v) => v.node.id === activeId) ? activeId : firstEnabledId;
|
|
108
|
+
const itemRefs = React.useRef(new Map());
|
|
109
|
+
const focusItem = React.useCallback((nodeId) => {
|
|
110
|
+
const el = itemRefs.current.get(nodeId);
|
|
111
|
+
if (el)
|
|
112
|
+
el.focus();
|
|
113
|
+
}, []);
|
|
114
|
+
const setExpanded = React.useCallback((next) => {
|
|
115
|
+
if (!expandedControlled)
|
|
116
|
+
setExpandedUncontrolled(next);
|
|
117
|
+
onExpandedChange?.(next);
|
|
118
|
+
}, [expandedControlled, onExpandedChange]);
|
|
119
|
+
const toggleExpanded = React.useCallback((nodeId) => {
|
|
120
|
+
const next = expandedSet.has(nodeId)
|
|
121
|
+
? expanded.filter((x) => x !== nodeId)
|
|
122
|
+
: [...expanded, nodeId];
|
|
123
|
+
setExpanded(next);
|
|
124
|
+
}, [expanded, expandedSet, setExpanded]);
|
|
125
|
+
const expand = React.useCallback((nodeId) => {
|
|
126
|
+
if (expandedSet.has(nodeId))
|
|
127
|
+
return;
|
|
128
|
+
setExpanded([...expanded, nodeId]);
|
|
129
|
+
}, [expanded, expandedSet, setExpanded]);
|
|
130
|
+
const collapse = React.useCallback((nodeId) => {
|
|
131
|
+
if (!expandedSet.has(nodeId))
|
|
132
|
+
return;
|
|
133
|
+
setExpanded(expanded.filter((x) => x !== nodeId));
|
|
134
|
+
}, [expanded, expandedSet, setExpanded]);
|
|
135
|
+
const select = React.useCallback((node) => {
|
|
136
|
+
if (node.disabled)
|
|
137
|
+
return;
|
|
138
|
+
if (!selectedControlled)
|
|
139
|
+
setSelectedUncontrolled(node.id);
|
|
140
|
+
onSelect?.(node.id);
|
|
141
|
+
}, [selectedControlled, onSelect]);
|
|
142
|
+
const moveTo = React.useCallback((nodeId) => {
|
|
143
|
+
setActiveId(nodeId);
|
|
144
|
+
focusItem(nodeId);
|
|
145
|
+
}, [focusItem]);
|
|
146
|
+
// Tracks whether focus currently lives inside this tree, so we only re-target
|
|
147
|
+
// focus after a collapse if the user was actually navigating the tree.
|
|
148
|
+
const treeHadFocus = React.useRef(false);
|
|
149
|
+
// When the focused treeitem leaves the visible set (e.g. an ancestor is
|
|
150
|
+
// collapsed and the focused child unmounts), DOM focus would otherwise drop to
|
|
151
|
+
// <body>. If the tree held focus, re-target it to the fallback
|
|
152
|
+
// (effectiveActiveId) so keyboard navigation keeps working.
|
|
153
|
+
React.useEffect(() => {
|
|
154
|
+
if (effectiveActiveId === null || !treeHadFocus.current)
|
|
155
|
+
return;
|
|
156
|
+
const active = document.activeElement;
|
|
157
|
+
const focusInsideTree = active instanceof HTMLElement &&
|
|
158
|
+
Array.from(itemRefs.current.values()).includes(active);
|
|
159
|
+
// Focus is still on a live treeitem in this tree — nothing to fix.
|
|
160
|
+
if (focusInsideTree)
|
|
161
|
+
return;
|
|
162
|
+
// Focus was lost (dropped to <body> or null) because the focused item
|
|
163
|
+
// unmounted. Pull it back to the fallback item.
|
|
164
|
+
if (active === null || active === document.body) {
|
|
165
|
+
focusItem(effectiveActiveId);
|
|
166
|
+
}
|
|
167
|
+
}, [effectiveActiveId, visible, focusItem]);
|
|
168
|
+
const handleKeyDown = (e, item) => {
|
|
169
|
+
// Treeitems are nested in the DOM (a parent treeitem OWNS its child
|
|
170
|
+
// role="group"), so a keydown on a child bubbles up to every ancestor
|
|
171
|
+
// treeitem's handler. Only let the treeitem the event actually originated on
|
|
172
|
+
// act, otherwise an ancestor would override the navigation/selection.
|
|
173
|
+
if (e.target !== e.currentTarget)
|
|
174
|
+
return;
|
|
175
|
+
const index = visible.findIndex((v) => v.node.id === item.node.id);
|
|
176
|
+
if (index === -1)
|
|
177
|
+
return;
|
|
178
|
+
switch (e.key) {
|
|
179
|
+
case 'ArrowDown': {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
if (index < visible.length - 1)
|
|
182
|
+
moveTo(visible[index + 1].node.id);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case 'ArrowUp': {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
if (index > 0)
|
|
188
|
+
moveTo(visible[index - 1].node.id);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'ArrowRight': {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
if (item.hasChildren) {
|
|
194
|
+
if (!item.expanded) {
|
|
195
|
+
expand(item.node.id);
|
|
196
|
+
}
|
|
197
|
+
else if (index < visible.length - 1 && visible[index + 1].parentId === item.node.id) {
|
|
198
|
+
moveTo(visible[index + 1].node.id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case 'ArrowLeft': {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
if (item.hasChildren && item.expanded) {
|
|
206
|
+
collapse(item.node.id);
|
|
207
|
+
}
|
|
208
|
+
else if (item.parentId !== null) {
|
|
209
|
+
moveTo(item.parentId);
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'Enter':
|
|
214
|
+
case ' ': {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
select(item.node);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'Home': {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
if (visible.length > 0)
|
|
222
|
+
moveTo(visible[0].node.id);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case 'End': {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
if (visible.length > 0)
|
|
228
|
+
moveTo(visible[visible.length - 1].node.id);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
default:
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
// Recursive renderer. The treeitem element CONTAINS both its presentational
|
|
236
|
+
// row and (when expanded) the nested role="group" of its children, so the
|
|
237
|
+
// ARIA ownership mirrors the tree hierarchy: tree -> treeitem -> group ->
|
|
238
|
+
// treeitem. `parentId` is threaded through so ArrowLeft can move to the parent
|
|
239
|
+
// treeitem.
|
|
240
|
+
const renderNodes = (list, level, parentId) => list.map((node) => {
|
|
241
|
+
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
|
|
242
|
+
const isExpanded = hasChildren && expandedSet.has(node.id);
|
|
243
|
+
const isSelected = selected === node.id;
|
|
244
|
+
const isActive = effectiveActiveId === node.id;
|
|
245
|
+
const item = { node, level, parentId, hasChildren, expanded: isExpanded };
|
|
246
|
+
return (_jsxs(Item, { ref: (el) => {
|
|
247
|
+
if (el)
|
|
248
|
+
itemRefs.current.set(node.id, el);
|
|
249
|
+
else
|
|
250
|
+
itemRefs.current.delete(node.id);
|
|
251
|
+
}, id: `${baseId}-item-${node.id}`, role: "treeitem", "aria-level": level, "aria-selected": isSelected, "aria-expanded": hasChildren ? isExpanded : undefined, "aria-disabled": node.disabled || undefined, "aria-labelledby": `${baseId}-label-${node.id}`, tabIndex: isActive ? 0 : -1, onKeyDown: (e) => handleKeyDown(e, item),
|
|
252
|
+
// Selection lives on the treeitem element itself (the focusable,
|
|
253
|
+
// interactive node), so clicking anywhere on the row — or the treeitem
|
|
254
|
+
// padding — selects it, matching Enter/Space. stopPropagation keeps a
|
|
255
|
+
// click from bubbling to an ANCESTOR treeitem (which now DOM-contains
|
|
256
|
+
// this one) and selecting that instead.
|
|
257
|
+
onClick: (e) => {
|
|
258
|
+
e.stopPropagation();
|
|
259
|
+
if (node.disabled)
|
|
260
|
+
return;
|
|
261
|
+
setActiveId(node.id);
|
|
262
|
+
select(node);
|
|
263
|
+
}, children: [_jsxs(Row, { "$selected": isSelected, "$disabled": node.disabled === true, "$level": level, children: [hasChildren ? (_jsx(Toggle, { "$expanded": isExpanded, onClick: (e) => {
|
|
264
|
+
e.stopPropagation();
|
|
265
|
+
if (node.disabled)
|
|
266
|
+
return;
|
|
267
|
+
setActiveId(node.id);
|
|
268
|
+
toggleExpanded(node.id);
|
|
269
|
+
}, children: _jsx(ChevronRight, { size: 16, "aria-hidden": "true" }) })) : (_jsx(TogglePlaceholder, { "aria-hidden": "true" })), node.icon != null && _jsx(IconSlot, { "aria-hidden": "true", children: node.icon }), _jsx(Label, { id: `${baseId}-label-${node.id}`, children: node.label })] }), hasChildren && isExpanded && (_jsx(Group, { role: "group", children: renderNodes(node.children, level + 1, node.id) }))] }, node.id));
|
|
270
|
+
});
|
|
271
|
+
return (_jsx(Root, { role: "tree", id: baseId, onFocus: () => {
|
|
272
|
+
treeHadFocus.current = true;
|
|
273
|
+
}, onBlur: (e) => {
|
|
274
|
+
// Only clear when focus genuinely leaves the tree for another element.
|
|
275
|
+
// A null relatedTarget usually means the focused item unmounted (e.g. a
|
|
276
|
+
// collapse), in which case we keep the flag so focus can be re-targeted.
|
|
277
|
+
const next = e.relatedTarget;
|
|
278
|
+
if (next && !e.currentTarget.contains(next)) {
|
|
279
|
+
treeHadFocus.current = false;
|
|
280
|
+
}
|
|
281
|
+
}, children: renderNodes(nodes, 1, null) }));
|
|
282
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared internal calendar module — month math + grid rendering used by
|
|
3
|
+
* DatePicker and DateRangePicker. No date library; all helpers operate at DAY
|
|
4
|
+
* granularity (time zeroed) and build dates with `new Date(year, month, day)`
|
|
5
|
+
* in local time. Not exported from `index.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
/** First day of `d`'s month at local midnight. */
|
|
9
|
+
export declare function startOfMonth(d: Date): Date;
|
|
10
|
+
/**
|
|
11
|
+
* `d` shifted by `n` whole months. The day-of-month is clamped to the target
|
|
12
|
+
* month's length so month-end days don't overflow into the following month
|
|
13
|
+
* (e.g. Jan 31 + 1 month -> Feb 28/29, not Mar 3).
|
|
14
|
+
*/
|
|
15
|
+
export declare function addMonths(d: Date, n: number): Date;
|
|
16
|
+
/** True when `a` and `b` fall on the same calendar day. */
|
|
17
|
+
export declare function isSameDay(a: Date, b: Date): boolean;
|
|
18
|
+
/** True when `a`'s day is strictly before `b`'s day (time ignored). */
|
|
19
|
+
export declare function isBefore(a: Date, b: Date): boolean;
|
|
20
|
+
/** True when `a`'s day is strictly after `b`'s day (time ignored). */
|
|
21
|
+
export declare function isAfter(a: Date, b: Date): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Inclusive day-granularity bounds check. A `null`/`undefined` bound is open
|
|
24
|
+
* (no constraint on that side).
|
|
25
|
+
*/
|
|
26
|
+
export declare function isWithin(d: Date, min?: Date | null, max?: Date | null): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Build a 6-row x 7-column matrix of Date objects for `month`, including
|
|
29
|
+
* leading days from the previous month and trailing days from the next month
|
|
30
|
+
* so the grid is always full and aligned to `weekStartsOn` (0=Sunday,
|
|
31
|
+
* 1=Monday). All cells are local-midnight Dates.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildMonthMatrix(month: Date, weekStartsOn: 0 | 1): Date[][];
|
|
34
|
+
/** 2-char weekday labels rotated so index 0 is `weekStartsOn`. */
|
|
35
|
+
export declare function weekdayLabels(weekStartsOn: 0 | 1): string[];
|
|
36
|
+
/** Long month name + space + 4-digit year, e.g. "May 2026". */
|
|
37
|
+
export declare function monthLabel(month: Date): string;
|
|
38
|
+
export interface MonthGridProps {
|
|
39
|
+
month: Date;
|
|
40
|
+
weekStartsOn?: 0 | 1;
|
|
41
|
+
isSelected?: (day: Date) => boolean;
|
|
42
|
+
isInRange?: (day: Date) => boolean;
|
|
43
|
+
isRangeStart?: (day: Date) => boolean;
|
|
44
|
+
isRangeEnd?: (day: Date) => boolean;
|
|
45
|
+
isDisabled?: (day: Date) => boolean;
|
|
46
|
+
isToday?: (day: Date) => boolean;
|
|
47
|
+
onSelect: (day: Date) => void;
|
|
48
|
+
focusedDay?: Date | null;
|
|
49
|
+
onFocusDay?: (day: Date) => void;
|
|
50
|
+
labelId?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare function MonthGrid(props: MonthGridProps): React.ReactElement;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Shared internal calendar module — month math + grid rendering used by
|
|
4
|
+
* DatePicker and DateRangePicker. No date library; all helpers operate at DAY
|
|
5
|
+
* granularity (time zeroed) and build dates with `new Date(year, month, day)`
|
|
6
|
+
* in local time. Not exported from `index.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
9
|
+
import styled from 'styled-components';
|
|
10
|
+
// --- Pure helpers ----------------------------------------------------------
|
|
11
|
+
/** Local-midnight Date for the same calendar day (time zeroed). */
|
|
12
|
+
function startOfDay(d) {
|
|
13
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
14
|
+
}
|
|
15
|
+
/** First day of `d`'s month at local midnight. */
|
|
16
|
+
export function startOfMonth(d) {
|
|
17
|
+
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `d` shifted by `n` whole months. The day-of-month is clamped to the target
|
|
21
|
+
* month's length so month-end days don't overflow into the following month
|
|
22
|
+
* (e.g. Jan 31 + 1 month -> Feb 28/29, not Mar 3).
|
|
23
|
+
*/
|
|
24
|
+
export function addMonths(d, n) {
|
|
25
|
+
const t = new Date(d.getFullYear(), d.getMonth() + n, 1);
|
|
26
|
+
const daysInMonth = new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate();
|
|
27
|
+
return new Date(t.getFullYear(), t.getMonth(), Math.min(d.getDate(), daysInMonth));
|
|
28
|
+
}
|
|
29
|
+
/** True when `a` and `b` fall on the same calendar day. */
|
|
30
|
+
export function isSameDay(a, b) {
|
|
31
|
+
return (a.getFullYear() === b.getFullYear() &&
|
|
32
|
+
a.getMonth() === b.getMonth() &&
|
|
33
|
+
a.getDate() === b.getDate());
|
|
34
|
+
}
|
|
35
|
+
/** True when `a`'s day is strictly before `b`'s day (time ignored). */
|
|
36
|
+
export function isBefore(a, b) {
|
|
37
|
+
return startOfDay(a).getTime() < startOfDay(b).getTime();
|
|
38
|
+
}
|
|
39
|
+
/** True when `a`'s day is strictly after `b`'s day (time ignored). */
|
|
40
|
+
export function isAfter(a, b) {
|
|
41
|
+
return startOfDay(a).getTime() > startOfDay(b).getTime();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Inclusive day-granularity bounds check. A `null`/`undefined` bound is open
|
|
45
|
+
* (no constraint on that side).
|
|
46
|
+
*/
|
|
47
|
+
export function isWithin(d, min, max) {
|
|
48
|
+
if (min != null && isBefore(d, min))
|
|
49
|
+
return false;
|
|
50
|
+
if (max != null && isAfter(d, max))
|
|
51
|
+
return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build a 6-row x 7-column matrix of Date objects for `month`, including
|
|
56
|
+
* leading days from the previous month and trailing days from the next month
|
|
57
|
+
* so the grid is always full and aligned to `weekStartsOn` (0=Sunday,
|
|
58
|
+
* 1=Monday). All cells are local-midnight Dates.
|
|
59
|
+
*/
|
|
60
|
+
export function buildMonthMatrix(month, weekStartsOn) {
|
|
61
|
+
const first = startOfMonth(month);
|
|
62
|
+
// How many leading days from the previous month precede the 1st.
|
|
63
|
+
const lead = (first.getDay() - weekStartsOn + 7) % 7;
|
|
64
|
+
const gridStart = new Date(first.getFullYear(), first.getMonth(), 1 - lead);
|
|
65
|
+
const matrix = [];
|
|
66
|
+
for (let row = 0; row < 6; row++) {
|
|
67
|
+
const week = [];
|
|
68
|
+
for (let col = 0; col < 7; col++) {
|
|
69
|
+
const offset = row * 7 + col;
|
|
70
|
+
week.push(new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + offset));
|
|
71
|
+
}
|
|
72
|
+
matrix.push(week);
|
|
73
|
+
}
|
|
74
|
+
return matrix;
|
|
75
|
+
}
|
|
76
|
+
const FULL_WEEKDAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
77
|
+
/** 2-char weekday labels rotated so index 0 is `weekStartsOn`. */
|
|
78
|
+
export function weekdayLabels(weekStartsOn) {
|
|
79
|
+
return FULL_WEEKDAY_LABELS.map((_, i) => FULL_WEEKDAY_LABELS[(i + weekStartsOn) % 7]);
|
|
80
|
+
}
|
|
81
|
+
/** Long month name + space + 4-digit year, e.g. "May 2026". */
|
|
82
|
+
export function monthLabel(month) {
|
|
83
|
+
return `${month.toLocaleString(undefined, { month: 'long' })} ${month.getFullYear()}`;
|
|
84
|
+
}
|
|
85
|
+
/** Stable ISO-ish key (YYYY-MM-DD) for a local calendar day. */
|
|
86
|
+
function dayKey(d) {
|
|
87
|
+
const y = d.getFullYear();
|
|
88
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
89
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
90
|
+
return `${y}-${m}-${day}`;
|
|
91
|
+
}
|
|
92
|
+
// --- MonthGrid component ----------------------------------------------------
|
|
93
|
+
const Grid = styled.div `
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-direction: column;
|
|
96
|
+
gap: ${(p) => p.theme.space[1]};
|
|
97
|
+
font-family: ${(p) => p.theme.font.body};
|
|
98
|
+
`;
|
|
99
|
+
const Row = styled.div `
|
|
100
|
+
display: grid;
|
|
101
|
+
grid-template-columns: repeat(7, 1fr);
|
|
102
|
+
gap: ${(p) => p.theme.space[1]};
|
|
103
|
+
`;
|
|
104
|
+
const WeekdayCell = styled.div `
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
height: 32px;
|
|
109
|
+
font-size: 12px;
|
|
110
|
+
font-weight: 800;
|
|
111
|
+
color: ${(p) => p.theme.colors.textSubtle};
|
|
112
|
+
text-transform: uppercase;
|
|
113
|
+
`;
|
|
114
|
+
const DayButton = styled.button `
|
|
115
|
+
appearance: none;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
width: 100%;
|
|
120
|
+
height: 36px;
|
|
121
|
+
font-family: inherit;
|
|
122
|
+
font-size: 14px;
|
|
123
|
+
font-weight: ${(p) => (p.$today ? 800 : 700)};
|
|
124
|
+
border: 2px solid transparent;
|
|
125
|
+
border-radius: ${(p) => p.theme.radii.sm};
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
transition: background 80ms ${(p) => p.theme.easing.out};
|
|
128
|
+
|
|
129
|
+
color: ${(p) => p.$selected
|
|
130
|
+
? p.theme.colors.primaryInk
|
|
131
|
+
: p.$outside
|
|
132
|
+
? p.theme.colors.textSubtle
|
|
133
|
+
: p.theme.colors.text};
|
|
134
|
+
|
|
135
|
+
background: ${(p) => p.$selected ? p.theme.colors.primary : p.$inRange ? p.theme.colors.primarySoft : 'transparent'};
|
|
136
|
+
|
|
137
|
+
outline: ${(p) => (p.$today ? `2px solid ${p.theme.colors.accent}` : 'none')};
|
|
138
|
+
outline-offset: ${(p) => (p.$today ? '-2px' : '0')};
|
|
139
|
+
|
|
140
|
+
&:hover:not(:disabled) {
|
|
141
|
+
background: ${(p) => (p.$selected ? p.theme.colors.primary : p.theme.colors.surfaceSunken)};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
&:focus-visible {
|
|
145
|
+
outline: 2px solid ${(p) => p.theme.colors.accent};
|
|
146
|
+
outline-offset: 2px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
&:disabled {
|
|
150
|
+
opacity: 0.4;
|
|
151
|
+
cursor: not-allowed;
|
|
152
|
+
}
|
|
153
|
+
`;
|
|
154
|
+
const NAV_KEYS = new Set([
|
|
155
|
+
'ArrowLeft',
|
|
156
|
+
'ArrowRight',
|
|
157
|
+
'ArrowUp',
|
|
158
|
+
'ArrowDown',
|
|
159
|
+
'PageUp',
|
|
160
|
+
'PageDown',
|
|
161
|
+
'Home',
|
|
162
|
+
'End',
|
|
163
|
+
'Enter',
|
|
164
|
+
' ',
|
|
165
|
+
]);
|
|
166
|
+
export function MonthGrid(props) {
|
|
167
|
+
const { month, weekStartsOn = 0, isSelected, isInRange, isRangeStart, isRangeEnd, isDisabled, isToday, onSelect, focusedDay, onFocusDay, labelId, } = props;
|
|
168
|
+
const matrix = useMemo(() => buildMonthMatrix(month, weekStartsOn), [month, weekStartsOn]);
|
|
169
|
+
const labels = useMemo(() => weekdayLabels(weekStartsOn), [weekStartsOn]);
|
|
170
|
+
const dayIsSelected = useCallback((day) => Boolean(isSelected?.(day)) || Boolean(isRangeStart?.(day)) || Boolean(isRangeEnd?.(day)), [isSelected, isRangeStart, isRangeEnd]);
|
|
171
|
+
const dayIsToday = useCallback((day) => (isToday ? isToday(day) : isSameDay(day, new Date())), [isToday]);
|
|
172
|
+
const dayIsDisabled = useCallback((day) => Boolean(isDisabled?.(day)), [isDisabled]);
|
|
173
|
+
// Determine which cell owns tabIndex 0 (roving tabindex): focusedDay, else
|
|
174
|
+
// the first selected day, else the first enabled day of the displayed month.
|
|
175
|
+
const rovingDay = useMemo(() => {
|
|
176
|
+
if (focusedDay)
|
|
177
|
+
return focusedDay;
|
|
178
|
+
const flat = matrix.flat();
|
|
179
|
+
const selected = flat.find((d) => d.getMonth() === month.getMonth() && dayIsSelected(d) && !dayIsDisabled(d));
|
|
180
|
+
if (selected)
|
|
181
|
+
return selected;
|
|
182
|
+
const firstEnabled = flat.find((d) => d.getMonth() === month.getMonth() && !dayIsDisabled(d));
|
|
183
|
+
if (firstEnabled)
|
|
184
|
+
return firstEnabled;
|
|
185
|
+
// No enabled day in the displayed month: fall back to the first enabled day
|
|
186
|
+
// anywhere in the matrix so the grid still has a focusable tab stop. Only if
|
|
187
|
+
// every cell is disabled do we fall back to flat[0].
|
|
188
|
+
const anyEnabled = flat.find((d) => !dayIsDisabled(d));
|
|
189
|
+
return anyEnabled ?? flat[0];
|
|
190
|
+
}, [focusedDay, matrix, month, dayIsSelected, dayIsDisabled]);
|
|
191
|
+
// Ref map: ISO day-string -> button element, used to move DOM focus.
|
|
192
|
+
const cellRefs = useRef(new Map());
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!focusedDay)
|
|
195
|
+
return;
|
|
196
|
+
const el = cellRefs.current.get(dayKey(focusedDay));
|
|
197
|
+
if (el)
|
|
198
|
+
el.focus();
|
|
199
|
+
}, [focusedDay]);
|
|
200
|
+
const handleKeyDown = (e) => {
|
|
201
|
+
if (!NAV_KEYS.has(e.key))
|
|
202
|
+
return;
|
|
203
|
+
const current = rovingDay;
|
|
204
|
+
if (!current)
|
|
205
|
+
return;
|
|
206
|
+
let next = null;
|
|
207
|
+
// Per-day step direction used to search outward for the nearest enabled
|
|
208
|
+
// day when the computed target lands on a disabled (out-of-range) day.
|
|
209
|
+
let step = 0;
|
|
210
|
+
switch (e.key) {
|
|
211
|
+
case 'ArrowLeft':
|
|
212
|
+
next = addDays(current, -1);
|
|
213
|
+
step = -1;
|
|
214
|
+
break;
|
|
215
|
+
case 'ArrowRight':
|
|
216
|
+
next = addDays(current, 1);
|
|
217
|
+
step = 1;
|
|
218
|
+
break;
|
|
219
|
+
case 'ArrowUp':
|
|
220
|
+
next = addDays(current, -7);
|
|
221
|
+
step = -1;
|
|
222
|
+
break;
|
|
223
|
+
case 'ArrowDown':
|
|
224
|
+
next = addDays(current, 7);
|
|
225
|
+
step = 1;
|
|
226
|
+
break;
|
|
227
|
+
case 'PageUp':
|
|
228
|
+
next = addMonths(current, -1);
|
|
229
|
+
step = -1;
|
|
230
|
+
break;
|
|
231
|
+
case 'PageDown':
|
|
232
|
+
next = addMonths(current, 1);
|
|
233
|
+
step = 1;
|
|
234
|
+
break;
|
|
235
|
+
case 'Home': {
|
|
236
|
+
const offset = (current.getDay() - weekStartsOn + 7) % 7;
|
|
237
|
+
next = addDays(current, -offset);
|
|
238
|
+
step = 1; // search forward from week start toward an enabled day
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case 'End': {
|
|
242
|
+
const offset = (current.getDay() - weekStartsOn + 7) % 7;
|
|
243
|
+
next = addDays(current, 6 - offset);
|
|
244
|
+
step = -1; // search backward from week end toward an enabled day
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case 'Enter':
|
|
248
|
+
case ' ':
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
if (!dayIsDisabled(current))
|
|
251
|
+
onSelect(current);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!next)
|
|
255
|
+
return;
|
|
256
|
+
// If the target is disabled, search outward in the travel direction for the
|
|
257
|
+
// nearest enabled day, bounded by the grid extent. Never focus a disabled
|
|
258
|
+
// (non-focusable) day, which would drop DOM focus to <body>.
|
|
259
|
+
if (dayIsDisabled(next) && step !== 0) {
|
|
260
|
+
const gridStart = matrix[0][0];
|
|
261
|
+
const gridEnd = matrix[matrix.length - 1][6];
|
|
262
|
+
let candidate = addDays(next, step);
|
|
263
|
+
next = null;
|
|
264
|
+
while (candidate && !isBefore(candidate, gridStart) && !isAfter(candidate, gridEnd)) {
|
|
265
|
+
if (!dayIsDisabled(candidate)) {
|
|
266
|
+
next = candidate;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
candidate = addDays(candidate, step);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (next && !dayIsDisabled(next)) {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
onFocusDay?.(next);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
return (_jsxs(Grid, { role: "grid", "aria-labelledby": labelId, onKeyDown: handleKeyDown, children: [_jsx(Row, { role: "row", children: labels.map((label, i) => (_jsx(WeekdayCell, { role: "columnheader", "aria-label": label, children: label }, i))) }), matrix.map((week, rowIndex) => (_jsx(Row, { role: "row", children: week.map((day) => {
|
|
278
|
+
const outside = day.getMonth() !== month.getMonth();
|
|
279
|
+
const selected = dayIsSelected(day);
|
|
280
|
+
const inRange = Boolean(isInRange?.(day)) && !selected;
|
|
281
|
+
const today = dayIsToday(day);
|
|
282
|
+
const disabled = dayIsDisabled(day);
|
|
283
|
+
const isRoving = Boolean(rovingDay && isSameDay(day, rovingDay));
|
|
284
|
+
const key = dayKey(day);
|
|
285
|
+
return (_jsx("div", { role: "gridcell", "aria-selected": selected, tabIndex: -1, children: _jsx(DayButton, { type: "button", ref: (el) => {
|
|
286
|
+
if (el)
|
|
287
|
+
cellRefs.current.set(key, el);
|
|
288
|
+
else
|
|
289
|
+
cellRefs.current.delete(key);
|
|
290
|
+
}, "$outside": outside, "$selected": selected, "$inRange": inRange, "$today": today, tabIndex: isRoving ? 0 : -1, disabled: disabled, "aria-label": day.toLocaleDateString(), onClick: () => {
|
|
291
|
+
if (disabled)
|
|
292
|
+
return;
|
|
293
|
+
onFocusDay?.(day);
|
|
294
|
+
onSelect(day);
|
|
295
|
+
}, children: day.getDate() }) }, key));
|
|
296
|
+
}) }, rowIndex)))] }));
|
|
297
|
+
}
|
|
298
|
+
/** `d` shifted by `n` whole days at local midnight. */
|
|
299
|
+
function addDays(d, n) {
|
|
300
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
301
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -38,3 +38,11 @@ export { Breadcrumb, BreadcrumbItem, type BreadcrumbProps, type BreadcrumbItemPr
|
|
|
38
38
|
export { Pagination, type PaginationProps } from './components/Pagination';
|
|
39
39
|
export { Stepper, type StepDef, type StepperProps, type StepperOrientation, } from './components/Stepper';
|
|
40
40
|
export { Table, THead, TBody, Tr, Th, Td } from './components/Table';
|
|
41
|
+
export { Combobox, type ComboboxProps, type ComboboxOption, } from './components/Combobox';
|
|
42
|
+
export { DataTable, type DataTableProps, type DataTableColumn, type DataTableSort, } from './components/DataTable';
|
|
43
|
+
export { DatePicker, type DatePickerProps } from './components/DatePicker';
|
|
44
|
+
export { Command, type CommandProps, type CommandItem, } from './components/Command';
|
|
45
|
+
export { FileUpload, formatFileSize, type FileUploadProps, type FileUploadRejection, type FileUploadRejectReason, } from './components/FileUpload';
|
|
46
|
+
export { TreeView, type TreeViewProps, type TreeNode, } from './components/TreeView';
|
|
47
|
+
export { TagInput, type TagInputProps } from './components/TagInput';
|
|
48
|
+
export { DateRangePicker, type DateRangePickerProps, type DateRange, } from './components/DateRangePicker';
|
package/dist/index.js
CHANGED
|
@@ -41,3 +41,11 @@ export { Breadcrumb, BreadcrumbItem, } from './components/Breadcrumb';
|
|
|
41
41
|
export { Pagination } from './components/Pagination';
|
|
42
42
|
export { Stepper, } from './components/Stepper';
|
|
43
43
|
export { Table, THead, TBody, Tr, Th, Td } from './components/Table';
|
|
44
|
+
export { Combobox, } from './components/Combobox';
|
|
45
|
+
export { DataTable, } from './components/DataTable';
|
|
46
|
+
export { DatePicker } from './components/DatePicker';
|
|
47
|
+
export { Command, } from './components/Command';
|
|
48
|
+
export { FileUpload, formatFileSize, } from './components/FileUpload';
|
|
49
|
+
export { TreeView, } from './components/TreeView';
|
|
50
|
+
export { TagInput } from './components/TagInput';
|
|
51
|
+
export { DateRangePicker, } from './components/DateRangePicker';
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harismawan/stamp-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Stamp-aesthetic React component library — chunky borders, hard offset shadows, no gradients.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Harismawan <mail@harismawan.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/harismawan/stamp-ui.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/harismawan/stamp-ui#readme",
|
|
12
|
+
"bugs": "https://github.com/harismawan/stamp-ui/issues",
|
|
7
13
|
"type": "module",
|
|
8
14
|
"sideEffects": false,
|
|
9
15
|
"files": ["dist"],
|