@blurrow/ui 0.1.0 → 0.2.1
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 +60 -20
- package/dist/Accordion.d.ts +17 -0
- package/dist/Accordion.js +138 -0
- package/dist/Accordion.js.map +1 -0
- package/dist/Alert.d.ts +13 -0
- package/dist/Alert.js +69 -0
- package/dist/Alert.js.map +1 -0
- package/dist/AuthorCard.d.ts +12 -0
- package/dist/AuthorCard.js +57 -0
- package/dist/AuthorCard.js.map +1 -0
- package/dist/Avatar.d.ts +21 -0
- package/dist/Avatar.js +96 -0
- package/dist/Avatar.js.map +1 -0
- package/dist/Badge.d.ts +12 -0
- package/dist/Badge.js +46 -0
- package/dist/Badge.js.map +1 -0
- package/dist/Banner.d.ts +15 -0
- package/dist/Banner.js +68 -0
- package/dist/Banner.js.map +1 -0
- package/dist/Breadcrumb.d.ts +13 -0
- package/dist/Breadcrumb.js +45 -0
- package/dist/Breadcrumb.js.map +1 -0
- package/dist/Button.d.ts +19 -0
- package/dist/Button.js +282 -0
- package/dist/Button.js.map +1 -0
- package/dist/ButtonGroup.d.ts +19 -0
- package/dist/ButtonGroup.js +62 -0
- package/dist/ButtonGroup.js.map +1 -0
- package/dist/Card.d.ts +40 -0
- package/dist/Card.js +175 -0
- package/dist/Card.js.map +1 -0
- package/dist/CardShell.d.ts +18 -0
- package/dist/CardShell.js +58 -0
- package/dist/CardShell.js.map +1 -0
- package/dist/Carousel.d.ts +22 -0
- package/dist/Carousel.js +247 -0
- package/dist/Carousel.js.map +1 -0
- package/dist/Checkbox.d.ts +15 -0
- package/dist/Checkbox.js +84 -0
- package/dist/Checkbox.js.map +1 -0
- package/dist/CloseButton.d.ts +12 -0
- package/dist/CloseButton.js +63 -0
- package/dist/CloseButton.js.map +1 -0
- package/dist/CodeBlock.d.ts +12 -0
- package/dist/CodeBlock.js +221 -0
- package/dist/CodeBlock.js.map +1 -0
- package/dist/ColorPicker.d.ts +24 -0
- package/dist/ColorPicker.js +366 -0
- package/dist/ColorPicker.js.map +1 -0
- package/dist/Combobox.d.ts +34 -0
- package/dist/Combobox.js +255 -0
- package/dist/Combobox.js.map +1 -0
- package/dist/CommandPalette.d.ts +25 -0
- package/dist/CommandPalette.js +304 -0
- package/dist/CommandPalette.js.map +1 -0
- package/dist/ContextMenu.d.ts +16 -0
- package/dist/ContextMenu.js +209 -0
- package/dist/ContextMenu.js.map +1 -0
- package/dist/DataList.d.ts +15 -0
- package/dist/DataList.js +42 -0
- package/dist/DataList.js.map +1 -0
- package/dist/DatePicker.d.ts +65 -0
- package/dist/DatePicker.js +391 -0
- package/dist/DatePicker.js.map +1 -0
- package/dist/DateRangePicker.d.ts +34 -0
- package/dist/DateRangePicker.js +180 -0
- package/dist/DateRangePicker.js.map +1 -0
- package/dist/Drawer.d.ts +17 -0
- package/dist/Drawer.js +132 -0
- package/dist/Drawer.js.map +1 -0
- package/dist/DropdownMenu.d.ts +23 -0
- package/dist/DropdownMenu.js +185 -0
- package/dist/DropdownMenu.js.map +1 -0
- package/dist/EmptyState.d.ts +13 -0
- package/dist/EmptyState.js +55 -0
- package/dist/EmptyState.js.map +1 -0
- package/dist/FieldMessage.d.ts +26 -0
- package/dist/FieldMessage.js +95 -0
- package/dist/FieldMessage.js.map +1 -0
- package/dist/FileUpload.d.ts +32 -0
- package/dist/FileUpload.js +253 -0
- package/dist/FileUpload.js.map +1 -0
- package/dist/Floating.d.ts +32 -0
- package/dist/Floating.js +95 -0
- package/dist/Floating.js.map +1 -0
- package/dist/HoverCard.d.ts +18 -0
- package/dist/HoverCard.js +90 -0
- package/dist/HoverCard.js.map +1 -0
- package/dist/Input.d.ts +17 -0
- package/dist/Input.js +96 -0
- package/dist/Input.js.map +1 -0
- package/dist/Kbd.d.ts +9 -0
- package/dist/Kbd.js +24 -0
- package/dist/Kbd.js.map +1 -0
- package/dist/Modal.d.ts +15 -0
- package/dist/Modal.js +109 -0
- package/dist/Modal.js.map +1 -0
- package/dist/NumberInput.d.ts +23 -0
- package/dist/NumberInput.js +162 -0
- package/dist/NumberInput.js.map +1 -0
- package/dist/OtpInput.d.ts +26 -0
- package/dist/OtpInput.js +173 -0
- package/dist/OtpInput.js.map +1 -0
- package/dist/Pagination.d.ts +11 -0
- package/dist/Pagination.js +148 -0
- package/dist/Pagination.js.map +1 -0
- package/dist/Popover.d.ts +13 -0
- package/dist/Popover.js +98 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Progress.d.ts +12 -0
- package/dist/Progress.js +61 -0
- package/dist/Progress.js.map +1 -0
- package/dist/QuoteCard.d.ts +13 -0
- package/dist/QuoteCard.js +92 -0
- package/dist/QuoteCard.js.map +1 -0
- package/dist/Radio.d.ts +19 -0
- package/dist/Radio.js +161 -0
- package/dist/Radio.js.map +1 -0
- package/dist/RadioCards.d.ts +27 -0
- package/dist/RadioCards.js +205 -0
- package/dist/RadioCards.js.map +1 -0
- package/dist/ReactionBar.d.ts +15 -0
- package/dist/ReactionBar.js +92 -0
- package/dist/ReactionBar.js.map +1 -0
- package/dist/ReadingProgress.d.ts +8 -0
- package/dist/ReadingProgress.js +51 -0
- package/dist/ReadingProgress.js.map +1 -0
- package/dist/ReadingTime.d.ts +9 -0
- package/dist/ReadingTime.js +39 -0
- package/dist/ReadingTime.js.map +1 -0
- package/dist/Select.d.ts +20 -0
- package/dist/Select.js +244 -0
- package/dist/Select.js.map +1 -0
- package/dist/Separator.d.ts +14 -0
- package/dist/Separator.js +33 -0
- package/dist/Separator.js.map +1 -0
- package/dist/ShareButtons.d.ts +10 -0
- package/dist/ShareButtons.js +107 -0
- package/dist/ShareButtons.js.map +1 -0
- package/dist/Sidebar.d.ts +30 -0
- package/dist/Sidebar.js +140 -0
- package/dist/Sidebar.js.map +1 -0
- package/dist/Skeleton.d.ts +15 -0
- package/dist/Skeleton.js +67 -0
- package/dist/Skeleton.js.map +1 -0
- package/dist/Slider.d.ts +27 -0
- package/dist/Slider.js +182 -0
- package/dist/Slider.js.map +1 -0
- package/dist/Spinner.d.ts +12 -0
- package/dist/Spinner.js +24 -0
- package/dist/Spinner.js.map +1 -0
- package/dist/SpotlightCard.d.ts +17 -0
- package/dist/SpotlightCard.js +87 -0
- package/dist/SpotlightCard.js.map +1 -0
- package/dist/Stat.d.ts +19 -0
- package/dist/Stat.js +71 -0
- package/dist/Stat.js.map +1 -0
- package/dist/StatCard.d.ts +24 -0
- package/dist/StatCard.js +212 -0
- package/dist/StatCard.js.map +1 -0
- package/dist/Steps.d.ts +14 -0
- package/dist/Steps.js +94 -0
- package/dist/Steps.js.map +1 -0
- package/dist/Table.d.ts +21 -0
- package/dist/Table.js +93 -0
- package/dist/Table.js.map +1 -0
- package/dist/TableOfContents.d.ts +17 -0
- package/dist/TableOfContents.js +92 -0
- package/dist/TableOfContents.js.map +1 -0
- package/dist/Tabs.d.ts +17 -0
- package/dist/Tabs.js +123 -0
- package/dist/Tabs.js.map +1 -0
- package/dist/TagInput.d.ts +16 -0
- package/dist/TagInput.js +169 -0
- package/dist/TagInput.js.map +1 -0
- package/dist/Textarea.d.ts +13 -0
- package/dist/Textarea.js +74 -0
- package/dist/Textarea.js.map +1 -0
- package/dist/TimePicker.d.ts +28 -0
- package/dist/TimePicker.js +264 -0
- package/dist/TimePicker.js.map +1 -0
- package/dist/Timeline.d.ts +19 -0
- package/dist/Timeline.js +76 -0
- package/dist/Timeline.js.map +1 -0
- package/dist/Toast.d.ts +18 -0
- package/dist/Toast.js +318 -0
- package/dist/Toast.js.map +1 -0
- package/dist/Toggle.d.ts +13 -0
- package/dist/Toggle.js +84 -0
- package/dist/Toggle.js.map +1 -0
- package/dist/Tooltip.d.ts +13 -0
- package/dist/Tooltip.js +138 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/cli.js +1644 -0
- package/dist/index.d.ts +68 -631
- package/dist/index.js +67 -5558
- package/dist/index.js.map +1 -1
- package/dist/registry/Accordion.tsx +140 -0
- package/dist/registry/Alert.tsx +95 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +55 -0
- package/dist/registry/Banner.tsx +86 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +361 -0
- package/dist/registry/ButtonGroup.tsx +82 -0
- package/dist/registry/Card.tsx +241 -0
- package/dist/registry/CardShell.tsx +78 -0
- package/dist/registry/Carousel.tsx +296 -0
- package/dist/registry/Checkbox.tsx +104 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/ColorPicker.tsx +404 -0
- package/dist/registry/Combobox.tsx +318 -0
- package/dist/registry/CommandPalette.tsx +356 -0
- package/dist/registry/ContextMenu.tsx +241 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/DatePicker.tsx +494 -0
- package/dist/registry/DateRangePicker.tsx +232 -0
- package/dist/registry/Drawer.tsx +166 -0
- package/dist/registry/DropdownMenu.tsx +219 -0
- package/dist/registry/EmptyState.tsx +55 -0
- package/dist/registry/FieldMessage.tsx +123 -0
- package/dist/registry/FileUpload.tsx +318 -0
- package/dist/registry/Floating.tsx +134 -0
- package/dist/registry/HoverCard.tsx +113 -0
- package/dist/registry/Input.tsx +108 -0
- package/dist/registry/Kbd.tsx +23 -0
- package/dist/registry/Modal.tsx +130 -0
- package/dist/registry/NumberInput.tsx +183 -0
- package/dist/registry/OtpInput.tsx +219 -0
- package/dist/registry/Pagination.tsx +160 -0
- package/dist/registry/Popover.tsx +116 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +205 -0
- package/dist/registry/RadioCards.tsx +247 -0
- package/dist/registry/ReactionBar.tsx +101 -0
- package/dist/registry/ReadingProgress.tsx +51 -0
- package/dist/registry/ReadingTime.tsx +35 -0
- package/dist/registry/Select.tsx +265 -0
- package/dist/registry/Separator.tsx +52 -0
- package/dist/registry/ShareButtons.tsx +134 -0
- package/dist/registry/Sidebar.tsx +182 -0
- package/dist/registry/Skeleton.tsx +83 -0
- package/dist/registry/Slider.tsx +229 -0
- package/dist/registry/Spinner.tsx +32 -0
- package/dist/registry/SpotlightCard.tsx +106 -0
- package/dist/registry/Stat.tsx +82 -0
- package/dist/registry/StatCard.tsx +256 -0
- package/dist/registry/Steps.tsx +109 -0
- package/dist/registry/Table.tsx +123 -0
- package/dist/registry/TableOfContents.tsx +116 -0
- package/dist/registry/Tabs.tsx +136 -0
- package/dist/registry/TagInput.tsx +191 -0
- package/dist/registry/Textarea.tsx +84 -0
- package/dist/registry/TimePicker.tsx +315 -0
- package/dist/registry/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +388 -0
- package/dist/registry/Toggle.tsx +96 -0
- package/dist/registry/Tooltip.tsx +160 -0
- package/dist/registry/styles.css +1207 -0
- package/dist/registry/useFloatingPanel.ts +112 -0
- package/dist/registry/useModalBehavior.ts +77 -0
- package/dist/styles.css +868 -62
- package/dist/useFloatingPanel.d.ts +24 -0
- package/dist/useFloatingPanel.js +89 -0
- package/dist/useFloatingPanel.js.map +1 -0
- package/dist/useModalBehavior.d.ts +15 -0
- package/dist/useModalBehavior.js +57 -0
- package/dist/useModalBehavior.js.map +1 -0
- package/package.json +23 -6
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useId, useRef, useState } from "react";
|
|
4
|
+
import { Calendar, CalendarIcon, ClearKey, DateRange, sameDay, startOfDay, toISODate } from "./DatePicker";
|
|
5
|
+
import { Portal } from "./Floating";
|
|
6
|
+
import { FieldLabel, FieldMessage } from "./FieldMessage";
|
|
7
|
+
import { useFloatingPanel } from "./useFloatingPanel";
|
|
8
|
+
|
|
9
|
+
export type { DateRange };
|
|
10
|
+
|
|
11
|
+
export interface DateRangePreset {
|
|
12
|
+
label: string;
|
|
13
|
+
range: () => DateRange;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DateRangePickerProps {
|
|
17
|
+
value?: DateRange;
|
|
18
|
+
defaultValue?: DateRange;
|
|
19
|
+
onChange?: (range: DateRange) => void;
|
|
20
|
+
label?: string;
|
|
21
|
+
hint?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
min?: Date;
|
|
25
|
+
max?: Date;
|
|
26
|
+
isDateDisabled?: (date: Date) => boolean;
|
|
27
|
+
weekStartsOn?: 0 | 1;
|
|
28
|
+
locale?: string;
|
|
29
|
+
/** Shortcuts down the side, like "last 7 days" */
|
|
30
|
+
presets?: DateRangePreset[];
|
|
31
|
+
/** One month instead of two side by side */
|
|
32
|
+
singleMonth?: boolean;
|
|
33
|
+
clearable?: boolean;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
fullWidth?: boolean;
|
|
36
|
+
/** Submits name[from] and name[to] as yyyy-mm-dd */
|
|
37
|
+
name?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const EMPTY: DateRange = { from: null, to: null };
|
|
42
|
+
|
|
43
|
+
/* The first click sets where the range starts, the second where it ends
|
|
44
|
+
(either way round), and the days between sink into a well as you hover.
|
|
45
|
+
Two months sit side by side, wrapping to one above the other on a
|
|
46
|
+
narrow screen. */
|
|
47
|
+
export function DateRangePicker({
|
|
48
|
+
value,
|
|
49
|
+
defaultValue = EMPTY,
|
|
50
|
+
onChange,
|
|
51
|
+
label,
|
|
52
|
+
hint,
|
|
53
|
+
error,
|
|
54
|
+
placeholder = "pick dates",
|
|
55
|
+
min,
|
|
56
|
+
max,
|
|
57
|
+
isDateDisabled,
|
|
58
|
+
weekStartsOn,
|
|
59
|
+
locale = "en-US",
|
|
60
|
+
presets,
|
|
61
|
+
singleMonth = false,
|
|
62
|
+
clearable = false,
|
|
63
|
+
disabled = false,
|
|
64
|
+
fullWidth = false,
|
|
65
|
+
name,
|
|
66
|
+
className = "",
|
|
67
|
+
}: DateRangePickerProps) {
|
|
68
|
+
const uid = useId();
|
|
69
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
70
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
71
|
+
const [internal, setInternal] = useState<DateRange>(defaultValue);
|
|
72
|
+
const range = value ?? internal;
|
|
73
|
+
// Mid-selection: the start is down, the end follows the pointer
|
|
74
|
+
const [draft, setDraft] = useState<Date | null>(null);
|
|
75
|
+
const [hover, setHover] = useState<Date | null>(null);
|
|
76
|
+
const { open, mounted, toggle, close, setFloating, panelStyle } = useFloatingPanel(ref, {
|
|
77
|
+
onEscape: () => buttonRef.current?.focus(),
|
|
78
|
+
});
|
|
79
|
+
const messageId = error || hint ? `${uid}-message` : undefined;
|
|
80
|
+
|
|
81
|
+
// Reset a half-made selection whenever the panel closes
|
|
82
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
83
|
+
if (open !== wasOpen) {
|
|
84
|
+
setWasOpen(open);
|
|
85
|
+
if (!open) {
|
|
86
|
+
setDraft(null);
|
|
87
|
+
setHover(null);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const set = (r: DateRange) => {
|
|
92
|
+
setInternal(r);
|
|
93
|
+
onChange?.(r);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const pickDay = (d: Date) => {
|
|
97
|
+
if (!draft) {
|
|
98
|
+
setDraft(d);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const [from, to] = d < draft ? [d, draft] : [draft, d];
|
|
102
|
+
set({ from, to });
|
|
103
|
+
setDraft(null);
|
|
104
|
+
close();
|
|
105
|
+
buttonRef.current?.focus();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// What the calendar shades: the finished range, or the one being drawn
|
|
109
|
+
const shown: DateRange = draft
|
|
110
|
+
? hover && !sameDay(hover, draft)
|
|
111
|
+
? hover < draft
|
|
112
|
+
? { from: hover, to: draft }
|
|
113
|
+
: { from: draft, to: hover }
|
|
114
|
+
: { from: draft, to: draft }
|
|
115
|
+
: range;
|
|
116
|
+
|
|
117
|
+
const fmt = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" });
|
|
118
|
+
const text =
|
|
119
|
+
range.from && range.to
|
|
120
|
+
? // formatRange drops the repeated month and year: "Aug 3 – 12, 2028"
|
|
121
|
+
fmt.formatRange(range.from, range.to)
|
|
122
|
+
: range.from
|
|
123
|
+
? `${fmt.format(range.from)} –`
|
|
124
|
+
: null;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
128
|
+
{label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
|
|
129
|
+
<div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-72", className].join(" ")}>
|
|
130
|
+
{/* The field's error styling keys off aria-invalid, as Input's does */}
|
|
131
|
+
{/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}
|
|
132
|
+
<button
|
|
133
|
+
ref={buttonRef}
|
|
134
|
+
type="button"
|
|
135
|
+
disabled={disabled}
|
|
136
|
+
onClick={toggle}
|
|
137
|
+
aria-haspopup="dialog"
|
|
138
|
+
aria-expanded={open}
|
|
139
|
+
aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
|
|
140
|
+
aria-invalid={error ? true : undefined}
|
|
141
|
+
aria-describedby={messageId}
|
|
142
|
+
data-open={open || undefined}
|
|
143
|
+
className={[
|
|
144
|
+
"field group/date relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-3 pl-3.5 text-left text-[13.5px]",
|
|
145
|
+
clearable && range.from ? "pr-10" : "pr-4",
|
|
146
|
+
"focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
|
|
147
|
+
].join(" ")}
|
|
148
|
+
>
|
|
149
|
+
<CalendarIcon
|
|
150
|
+
className={[
|
|
151
|
+
"shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",
|
|
152
|
+
open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/date:text-ink-body",
|
|
153
|
+
].join(" ")}
|
|
154
|
+
/>
|
|
155
|
+
<span id={`${uid}-value`} className={["truncate tabular-nums", text ? "font-medium text-ink" : "text-ink-faint"].join(" ")}>
|
|
156
|
+
{text ?? placeholder}
|
|
157
|
+
</span>
|
|
158
|
+
</button>
|
|
159
|
+
{clearable && range.from && !disabled && (
|
|
160
|
+
<ClearKey
|
|
161
|
+
label="clear dates"
|
|
162
|
+
onClear={() => {
|
|
163
|
+
set(EMPTY);
|
|
164
|
+
buttonRef.current?.focus();
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
)}
|
|
168
|
+
{name && (
|
|
169
|
+
<>
|
|
170
|
+
<input type="hidden" name={`${name}[from]`} value={range.from ? toISODate(range.from) : ""} />
|
|
171
|
+
<input type="hidden" name={`${name}[to]`} value={range.to ? toISODate(range.to) : ""} />
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{mounted && (
|
|
177
|
+
<Portal>
|
|
178
|
+
<div
|
|
179
|
+
ref={setFloating}
|
|
180
|
+
role="dialog"
|
|
181
|
+
aria-label={label ? `choose ${label}` : "choose dates"}
|
|
182
|
+
className="max-w-[calc(100vw-16px)] rounded-[16px] p-1.5"
|
|
183
|
+
style={panelStyle}
|
|
184
|
+
>
|
|
185
|
+
<div
|
|
186
|
+
className="flex flex-col gap-3 rounded-[11px] bg-panel-face p-2.5 sm:flex-row"
|
|
187
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
188
|
+
>
|
|
189
|
+
{presets && presets.length > 0 && (
|
|
190
|
+
<div className="flex shrink-0 flex-row flex-wrap gap-1 border-line-strong pb-2 sm:w-32 sm:flex-col sm:border-r sm:pb-0 sm:pr-2">
|
|
191
|
+
{presets.map((p) => (
|
|
192
|
+
<button
|
|
193
|
+
key={p.label}
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={() => {
|
|
196
|
+
const r = p.range();
|
|
197
|
+
set({ from: r.from && startOfDay(r.from), to: r.to && startOfDay(r.to) });
|
|
198
|
+
close();
|
|
199
|
+
buttonRef.current?.focus();
|
|
200
|
+
}}
|
|
201
|
+
className="cursor-pointer rounded-[8px] px-2.5 py-1.5 text-left text-[12.5px] text-ink-body transition-colors hover:bg-surface-active hover:text-ink focus-visible:outline-2 focus-visible:outline-ink"
|
|
202
|
+
>
|
|
203
|
+
{p.label}
|
|
204
|
+
</button>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
<div className="flex flex-col gap-2">
|
|
209
|
+
<Calendar
|
|
210
|
+
range={shown}
|
|
211
|
+
onChange={pickDay}
|
|
212
|
+
onDayHover={setHover}
|
|
213
|
+
months={singleMonth ? 1 : 2}
|
|
214
|
+
min={min}
|
|
215
|
+
max={max}
|
|
216
|
+
isDateDisabled={isDateDisabled}
|
|
217
|
+
weekStartsOn={weekStartsOn}
|
|
218
|
+
locale={locale}
|
|
219
|
+
autoFocus
|
|
220
|
+
/>
|
|
221
|
+
<p aria-live="polite" className="px-1 text-[11.5px] text-ink-faint">
|
|
222
|
+
{draft ? "now pick the last day" : "pick the first day"}
|
|
223
|
+
</p>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</Portal>
|
|
228
|
+
)}
|
|
229
|
+
<FieldMessage id={messageId} error={error} hint={hint} />
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { Portal } from "./Floating";
|
|
5
|
+
import { CloseButton } from "./CloseButton";
|
|
6
|
+
import { useModalBehavior } from "./useModalBehavior";
|
|
7
|
+
|
|
8
|
+
interface DrawerProps {
|
|
9
|
+
open: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
title?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
actions?: React.ReactNode;
|
|
15
|
+
side?: "left" | "right";
|
|
16
|
+
size?: "sm" | "md" | "lg";
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ENTER_MS = 380;
|
|
21
|
+
const EXIT_MS = 240;
|
|
22
|
+
|
|
23
|
+
const sizeMap = {
|
|
24
|
+
sm: "max-w-xs",
|
|
25
|
+
md: "max-w-md",
|
|
26
|
+
lg: "max-w-lg",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export function Drawer({
|
|
30
|
+
open,
|
|
31
|
+
onClose,
|
|
32
|
+
title,
|
|
33
|
+
description,
|
|
34
|
+
children,
|
|
35
|
+
actions,
|
|
36
|
+
side = "right",
|
|
37
|
+
size = "md",
|
|
38
|
+
className = "",
|
|
39
|
+
}: DrawerProps) {
|
|
40
|
+
const [mounted, setMounted] = useState(false);
|
|
41
|
+
const [visible, setVisible] = useState(false);
|
|
42
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
const uid = useId();
|
|
44
|
+
|
|
45
|
+
/* ── mount / unmount ────────────────────────────────── */
|
|
46
|
+
/* Mounting on open and sliding out on close happen during render, so
|
|
47
|
+
neither waits for an extra commit; only the unmount is delayed */
|
|
48
|
+
if (open && !mounted) setMounted(true);
|
|
49
|
+
if (!open && visible) setVisible(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (open || !mounted) return;
|
|
53
|
+
const t = setTimeout(() => setMounted(false), EXIT_MS);
|
|
54
|
+
return () => clearTimeout(t);
|
|
55
|
+
}, [open, mounted]);
|
|
56
|
+
|
|
57
|
+
/* ── trigger enter animation after mount ────────────── */
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (!mounted || !open || visible) return;
|
|
60
|
+
void panelRef.current?.getBoundingClientRect();
|
|
61
|
+
const id = requestAnimationFrame(() => setVisible(true));
|
|
62
|
+
return () => cancelAnimationFrame(id);
|
|
63
|
+
}, [mounted, open, visible]);
|
|
64
|
+
|
|
65
|
+
/* ── Escape, focus trap + restore, scroll lock ─────── */
|
|
66
|
+
useModalBehavior({ open, ready: visible, ref: panelRef, onClose });
|
|
67
|
+
|
|
68
|
+
if (!mounted) return null;
|
|
69
|
+
|
|
70
|
+
const isRight = side === "right";
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Portal>
|
|
74
|
+
<div className="fixed inset-0 z-90">
|
|
75
|
+
<div
|
|
76
|
+
aria-hidden
|
|
77
|
+
className="absolute inset-0 backdrop-blur-[6px] transition-opacity ease-out"
|
|
78
|
+
style={{
|
|
79
|
+
background: "var(--scrim)",
|
|
80
|
+
opacity: visible ? 1 : 0,
|
|
81
|
+
transitionDuration: `${visible ? ENTER_MS : EXIT_MS}ms`,
|
|
82
|
+
}}
|
|
83
|
+
onClick={onClose}
|
|
84
|
+
/>
|
|
85
|
+
|
|
86
|
+
{/* Floats a few px off the viewport edge so it reads as a panel, not a wall */}
|
|
87
|
+
<div
|
|
88
|
+
className={[
|
|
89
|
+
"absolute inset-y-0 w-full p-2 flex",
|
|
90
|
+
isRight ? "right-0 justify-end" : "left-0 justify-start",
|
|
91
|
+
].join(" ")}
|
|
92
|
+
style={{ pointerEvents: "none" }}
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
ref={panelRef}
|
|
96
|
+
role="dialog"
|
|
97
|
+
aria-modal="true"
|
|
98
|
+
aria-labelledby={title ? `${uid}-title` : undefined}
|
|
99
|
+
aria-describedby={description ? `${uid}-desc` : undefined}
|
|
100
|
+
className={[
|
|
101
|
+
"relative h-full w-full rounded-[22px] p-1.5 flex flex-col pointer-events-auto",
|
|
102
|
+
sizeMap[size],
|
|
103
|
+
className,
|
|
104
|
+
]
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join(" ")}
|
|
107
|
+
style={{
|
|
108
|
+
background: "var(--panel-bezel)",
|
|
109
|
+
boxShadow: "var(--modal-shadow)",
|
|
110
|
+
transform: visible
|
|
111
|
+
? "translateX(0)"
|
|
112
|
+
: isRight
|
|
113
|
+
? "translateX(calc(100% + 16px))"
|
|
114
|
+
: "translateX(calc(-100% - 16px))",
|
|
115
|
+
transition: `transform ${visible ? ENTER_MS : EXIT_MS}ms ${
|
|
116
|
+
visible ? "var(--ease-out-soft)" : "cubic-bezier(0.4,0,1,1)"
|
|
117
|
+
}`,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<div
|
|
121
|
+
className="flex flex-col h-full bg-panel-face rounded-[16px] overflow-hidden"
|
|
122
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
123
|
+
>
|
|
124
|
+
<div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4 shrink-0">
|
|
125
|
+
<div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
|
|
126
|
+
{title && (
|
|
127
|
+
<h2
|
|
128
|
+
id={`${uid}-title`}
|
|
129
|
+
className="text-[16px] font-semibold text-ink tracking-[-0.015em] leading-snug"
|
|
130
|
+
>
|
|
131
|
+
{title}
|
|
132
|
+
</h2>
|
|
133
|
+
)}
|
|
134
|
+
{description && (
|
|
135
|
+
<p id={`${uid}-desc`} className="text-[13px] text-ink-muted leading-relaxed">
|
|
136
|
+
{description}
|
|
137
|
+
</p>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
<CloseButton label="Close drawer" onClick={onClose} />
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{children && (
|
|
144
|
+
<div className="flex-1 overflow-y-auto scroll-thin px-6 pb-5 text-[13.5px] text-ink-body leading-relaxed">
|
|
145
|
+
{children}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{actions && (
|
|
150
|
+
<div
|
|
151
|
+
className="mt-auto px-5 py-3.5 flex items-center justify-end gap-2 shrink-0"
|
|
152
|
+
style={{
|
|
153
|
+
background: "var(--panel-tray)",
|
|
154
|
+
boxShadow: "inset 0 1px 0 var(--hairline)",
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{actions}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</Portal>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useId, useCallback, ReactNode } from "react";
|
|
4
|
+
import { Kbd } from "./Kbd";
|
|
5
|
+
import { ENTER_FROM, LAYER, Portal, useAnchoredPosition } from "./Floating";
|
|
6
|
+
|
|
7
|
+
type DropdownMenuPosition =
|
|
8
|
+
| "bottom-start"
|
|
9
|
+
| "bottom-end"
|
|
10
|
+
| "top-start"
|
|
11
|
+
| "top-end";
|
|
12
|
+
|
|
13
|
+
export interface DropdownMenuItem {
|
|
14
|
+
key: string;
|
|
15
|
+
label: string;
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
shortcut?: string;
|
|
18
|
+
danger?: boolean;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
divider?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface DropdownMenuProps {
|
|
24
|
+
trigger: ReactNode;
|
|
25
|
+
items: DropdownMenuItem[];
|
|
26
|
+
position?: DropdownMenuPosition;
|
|
27
|
+
onSelect?: (key: string) => void;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function DropdownMenu({
|
|
32
|
+
trigger,
|
|
33
|
+
items,
|
|
34
|
+
position = "bottom-end",
|
|
35
|
+
onSelect,
|
|
36
|
+
className = "",
|
|
37
|
+
}: DropdownMenuProps) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const [visible, setVisible] = useState(false);
|
|
40
|
+
const [mounted, setMounted] = useState(false);
|
|
41
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
42
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
43
|
+
const uid = useId();
|
|
44
|
+
const [v, h] = position.split("-") as ["top" | "bottom", "start" | "end"];
|
|
45
|
+
// Portalled and pinned to the trigger; flips up or over when it runs out of room
|
|
46
|
+
const { setFloating, side } = useAnchoredPosition(ref, { open: mounted, side: v, align: h });
|
|
47
|
+
const attachMenu = useCallback(
|
|
48
|
+
(el: HTMLDivElement | null) => {
|
|
49
|
+
menuRef.current = el;
|
|
50
|
+
setFloating(el);
|
|
51
|
+
},
|
|
52
|
+
[setFloating],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const enabledItems = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);
|
|
56
|
+
|
|
57
|
+
const focusItem = (index: number) => {
|
|
58
|
+
const list = enabledItems();
|
|
59
|
+
if (list.length === 0) return;
|
|
60
|
+
list[(index + list.length) % list.length].focus();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/* Mounted and faded out during render; the effect only times the rest */
|
|
64
|
+
if (open && !mounted) setMounted(true);
|
|
65
|
+
if (!open && visible) setVisible(false);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (open) {
|
|
69
|
+
requestAnimationFrame(() =>
|
|
70
|
+
requestAnimationFrame(() => {
|
|
71
|
+
setVisible(true);
|
|
72
|
+
// Land on the first item so arrow keys work straight away
|
|
73
|
+
enabledItems()[0]?.focus();
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
const t = setTimeout(() => setMounted(false), 180);
|
|
78
|
+
return () => clearTimeout(t);
|
|
79
|
+
}
|
|
80
|
+
}, [open]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
function onOutside(e: MouseEvent) {
|
|
84
|
+
const t = e.target as Node;
|
|
85
|
+
if (ref.current?.contains(t) || menuRef.current?.contains(t)) return;
|
|
86
|
+
setOpen(false);
|
|
87
|
+
}
|
|
88
|
+
document.addEventListener("mousedown", onOutside);
|
|
89
|
+
return () => document.removeEventListener("mousedown", onOutside);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const returnFocus = () => {
|
|
93
|
+
ref.current?.querySelector<HTMLElement>("[data-menu-trigger] button, [data-menu-trigger] a")?.focus();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
97
|
+
const list = enabledItems();
|
|
98
|
+
const at = list.indexOf(document.activeElement as HTMLElement);
|
|
99
|
+
switch (e.key) {
|
|
100
|
+
case "ArrowDown":
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
focusItem(at + 1);
|
|
103
|
+
break;
|
|
104
|
+
case "ArrowUp":
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
focusItem(at - 1);
|
|
107
|
+
break;
|
|
108
|
+
case "Home":
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
focusItem(0);
|
|
111
|
+
break;
|
|
112
|
+
case "End":
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
focusItem(list.length - 1);
|
|
115
|
+
break;
|
|
116
|
+
case "Escape":
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
setOpen(false);
|
|
119
|
+
returnFocus();
|
|
120
|
+
break;
|
|
121
|
+
case "Tab":
|
|
122
|
+
setOpen(false);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function handleSelect(item: DropdownMenuItem) {
|
|
128
|
+
if (item.disabled) return;
|
|
129
|
+
onSelect?.(item.key);
|
|
130
|
+
setOpen(false);
|
|
131
|
+
returnFocus();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div ref={ref} className={["relative inline-flex", className].join(" ")}>
|
|
136
|
+
<div
|
|
137
|
+
data-menu-trigger
|
|
138
|
+
onClick={() => {
|
|
139
|
+
setOpen(!open);
|
|
140
|
+
}}
|
|
141
|
+
className="inline-flex cursor-pointer"
|
|
142
|
+
>
|
|
143
|
+
{React.isValidElement(trigger)
|
|
144
|
+
? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
|
|
145
|
+
"aria-haspopup": "menu",
|
|
146
|
+
"aria-expanded": open,
|
|
147
|
+
"aria-controls": open ? `${uid}-menu` : undefined,
|
|
148
|
+
})
|
|
149
|
+
: trigger}
|
|
150
|
+
</div>
|
|
151
|
+
{mounted && (
|
|
152
|
+
<Portal>
|
|
153
|
+
<div
|
|
154
|
+
ref={attachMenu}
|
|
155
|
+
id={`${uid}-menu`}
|
|
156
|
+
role="menu"
|
|
157
|
+
onKeyDown={onMenuKeyDown}
|
|
158
|
+
className="min-w-[200px] rounded-[16px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"
|
|
159
|
+
style={{
|
|
160
|
+
position: "fixed",
|
|
161
|
+
top: 0,
|
|
162
|
+
left: 0,
|
|
163
|
+
zIndex: LAYER.floating,
|
|
164
|
+
transformOrigin: `${side === "top" ? "bottom" : "top"} ${h === "start" ? "left" : "right"}`,
|
|
165
|
+
opacity: visible ? 1 : 0,
|
|
166
|
+
transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.96)`,
|
|
167
|
+
background: "var(--panel-bezel)",
|
|
168
|
+
boxShadow: "var(--overlay-shadow)",
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<div
|
|
172
|
+
className="bg-panel-face rounded-[11px] p-1 flex flex-col"
|
|
173
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
174
|
+
>
|
|
175
|
+
{items.map((item) => {
|
|
176
|
+
if (item.divider) {
|
|
177
|
+
return <div key={item.key} role="separator" className="h-px bg-line my-1 mx-2" />;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<button
|
|
182
|
+
key={item.key}
|
|
183
|
+
type="button"
|
|
184
|
+
role="menuitem"
|
|
185
|
+
tabIndex={-1}
|
|
186
|
+
onClick={() => handleSelect(item)}
|
|
187
|
+
onMouseEnter={(e) => e.currentTarget.focus()}
|
|
188
|
+
disabled={item.disabled}
|
|
189
|
+
className={[
|
|
190
|
+
"group/item w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[8px] text-[13px] font-medium cursor-pointer outline-none",
|
|
191
|
+
"transition-[background,color] duration-100",
|
|
192
|
+
"disabled:opacity-35 disabled:pointer-events-none",
|
|
193
|
+
item.danger
|
|
194
|
+
? "text-danger focus:bg-danger-wash"
|
|
195
|
+
: "text-ink-label focus:text-ink focus:bg-surface-active/80",
|
|
196
|
+
].join(" ")}
|
|
197
|
+
>
|
|
198
|
+
{item.icon && (
|
|
199
|
+
<span
|
|
200
|
+
className={[
|
|
201
|
+
"shrink-0 w-4 h-4 flex items-center justify-center transition-colors duration-100",
|
|
202
|
+
item.danger ? "text-danger-soft" : "text-ink-faint group-focus/item:text-ink-label",
|
|
203
|
+
].join(" ")}
|
|
204
|
+
>
|
|
205
|
+
{item.icon}
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
208
|
+
<span className="flex-1 text-left">{item.label}</span>
|
|
209
|
+
{item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}
|
|
210
|
+
</button>
|
|
211
|
+
);
|
|
212
|
+
})}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</Portal>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface EmptyStateProps {
|
|
4
|
+
icon?: React.ReactNode;
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
action?: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function EmptyState({
|
|
12
|
+
icon,
|
|
13
|
+
title,
|
|
14
|
+
description,
|
|
15
|
+
action,
|
|
16
|
+
className = "",
|
|
17
|
+
}: EmptyStateProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={["rounded-[20px] p-1.5", className].filter(Boolean).join(" ")}
|
|
21
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
22
|
+
>
|
|
23
|
+
<div
|
|
24
|
+
className="relative bg-panel-face rounded-[15px] overflow-hidden flex flex-col items-center justify-center text-center py-14 px-6"
|
|
25
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
26
|
+
>
|
|
27
|
+
{/* A faint dotted floor: the space is empty, not broken */}
|
|
28
|
+
<div
|
|
29
|
+
aria-hidden
|
|
30
|
+
className="pointer-events-none absolute inset-0"
|
|
31
|
+
style={{
|
|
32
|
+
backgroundImage: "radial-gradient(var(--dots) 1px, transparent 1px)",
|
|
33
|
+
backgroundSize: "14px 14px",
|
|
34
|
+
maskImage: "radial-gradient(60% 70% at 50% 40%, black 0%, transparent 100%)",
|
|
35
|
+
WebkitMaskImage: "radial-gradient(60% 70% at 50% 40%, black 0%, transparent 100%)",
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
{icon && (
|
|
39
|
+
<div
|
|
40
|
+
aria-hidden
|
|
41
|
+
className="relative w-12 h-12 rounded-[14px] flex items-center justify-center text-ink-muted mb-5"
|
|
42
|
+
style={{ background: "var(--face)", boxShadow: "var(--face-shadow), var(--face-drop)" }}
|
|
43
|
+
>
|
|
44
|
+
{icon}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
<p className="relative text-[15px] font-semibold text-ink tracking-[-0.015em]">{title}</p>
|
|
48
|
+
{description && (
|
|
49
|
+
<p className="relative text-[13px] text-ink-muted mt-1.5 max-w-xs leading-relaxed">{description}</p>
|
|
50
|
+
)}
|
|
51
|
+
{action && <div className="relative mt-6">{action}</div>}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|