@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.
@@ -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.1.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"],