@monkey-mini-app/ui 0.1.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/components.json +23 -0
- package/dist/globals.css +2 -0
- package/dist/index.js +138 -0
- package/dist/manifest.json +11 -0
- package/dist/src/blocks/activity-feed.tsx +9 -0
- package/dist/src/blocks/app-shell.tsx +37 -0
- package/dist/src/blocks/attachment-gallery.tsx +30 -0
- package/dist/src/blocks/bar-chart.tsx +33 -0
- package/dist/src/blocks/comment-thread.tsx +30 -0
- package/dist/src/blocks/description-list.tsx +16 -0
- package/dist/src/blocks/detail-panel.tsx +43 -0
- package/dist/src/blocks/donut-chart.tsx +38 -0
- package/dist/src/blocks/env-badge.tsx +23 -0
- package/dist/src/blocks/file-tree.tsx +5 -0
- package/dist/src/blocks/filter-bar.tsx +21 -0
- package/dist/src/blocks/gauge.tsx +13 -0
- package/dist/src/blocks/notification-center.tsx +22 -0
- package/dist/src/blocks/page-header.tsx +30 -0
- package/dist/src/blocks/progress-ring.tsx +25 -0
- package/dist/src/blocks/radar-chart.tsx +31 -0
- package/dist/src/blocks/request-inspector.tsx +34 -0
- package/dist/src/blocks/run-timeline.tsx +5 -0
- package/dist/src/blocks/scrollspy.tsx +42 -0
- package/dist/src/blocks/severity-chip.tsx +27 -0
- package/dist/src/blocks/sparkline.tsx +19 -0
- package/dist/src/blocks/stat-card.tsx +49 -0
- package/dist/src/blocks/status-badge.tsx +39 -0
- package/dist/src/blocks/terminal.tsx +10 -0
- package/dist/src/blocks/test-step-list.tsx +19 -0
- package/dist/src/blocks/trend-card.tsx +51 -0
- package/dist/src/components/accordion.tsx +72 -0
- package/dist/src/components/alert-dialog.tsx +185 -0
- package/dist/src/components/alert.tsx +76 -0
- package/dist/src/components/aspect-ratio.tsx +22 -0
- package/dist/src/components/attachment.tsx +207 -0
- package/dist/src/components/avatar.tsx +107 -0
- package/dist/src/components/badge.tsx +52 -0
- package/dist/src/components/breadcrumb.tsx +125 -0
- package/dist/src/components/bubble.tsx +128 -0
- package/dist/src/components/button-group.tsx +87 -0
- package/dist/src/components/button.tsx +58 -0
- package/dist/src/components/calendar.tsx +221 -0
- package/dist/src/components/card.tsx +103 -0
- package/dist/src/components/chart.tsx +371 -0
- package/dist/src/components/checkbox.tsx +29 -0
- package/dist/src/components/collapsible.tsx +19 -0
- package/dist/src/components/combobox.tsx +295 -0
- package/dist/src/components/command.tsx +196 -0
- package/dist/src/components/context-menu.tsx +271 -0
- package/dist/src/components/dialog.tsx +158 -0
- package/dist/src/components/drawer.tsx +226 -0
- package/dist/src/components/dropdown-menu.tsx +268 -0
- package/dist/src/components/empty.tsx +123 -0
- package/dist/src/components/field.tsx +236 -0
- package/dist/src/components/hover-card.tsx +49 -0
- package/dist/src/components/input-group.tsx +158 -0
- package/dist/src/components/input.tsx +20 -0
- package/dist/src/components/item.tsx +201 -0
- package/dist/src/components/kbd.tsx +26 -0
- package/dist/src/components/label.tsx +20 -0
- package/dist/src/components/marker.tsx +71 -0
- package/dist/src/components/menubar.tsx +280 -0
- package/dist/src/components/message-scroller.tsx +129 -0
- package/dist/src/components/message.tsx +92 -0
- package/dist/src/components/native-select.tsx +61 -0
- package/dist/src/components/navigation-menu.tsx +168 -0
- package/dist/src/components/pagination.tsx +135 -0
- package/dist/src/components/popover.tsx +88 -0
- package/dist/src/components/progress.tsx +83 -0
- package/dist/src/components/questionnaire.tsx +329 -0
- package/dist/src/components/radio-group.tsx +36 -0
- package/dist/src/components/resizable.tsx +50 -0
- package/dist/src/components/scroll-area.tsx +52 -0
- package/dist/src/components/select.tsx +201 -0
- package/dist/src/components/separator.tsx +23 -0
- package/dist/src/components/sheet.tsx +136 -0
- package/dist/src/components/sidebar.tsx +721 -0
- package/dist/src/components/skeleton.tsx +13 -0
- package/dist/src/components/slider.tsx +52 -0
- package/dist/src/components/spinner.tsx +10 -0
- package/dist/src/components/switch.tsx +32 -0
- package/dist/src/components/table.tsx +114 -0
- package/dist/src/components/tabs.tsx +82 -0
- package/dist/src/components/textarea.tsx +18 -0
- package/dist/src/components/toast.tsx +234 -0
- package/dist/src/components/toggle-group.tsx +89 -0
- package/dist/src/components/toggle.tsx +43 -0
- package/dist/src/components/tooltip.tsx +66 -0
- package/dist/src/composites/autocomplete.tsx +55 -0
- package/dist/src/composites/cascader.tsx +46 -0
- package/dist/src/composites/color-picker.tsx +19 -0
- package/dist/src/composites/confirm-dialog.tsx +56 -0
- package/dist/src/composites/copyable.tsx +37 -0
- package/dist/src/composites/currency-input.tsx +31 -0
- package/dist/src/composites/date-chrome.tsx +157 -0
- package/dist/src/composites/date-picker.tsx +91 -0
- package/dist/src/composites/date-range-picker.tsx +96 -0
- package/dist/src/composites/date-time-picker.tsx +115 -0
- package/dist/src/composites/date-time-range-picker.tsx +51 -0
- package/dist/src/composites/duration-input.tsx +122 -0
- package/dist/src/composites/mini-calendar.tsx +24 -0
- package/dist/src/composites/number-field.tsx +53 -0
- package/dist/src/composites/password-field.tsx +44 -0
- package/dist/src/composites/phone-input.tsx +22 -0
- package/dist/src/composites/rating.tsx +39 -0
- package/dist/src/composites/relative-date-picker.tsx +62 -0
- package/dist/src/composites/search-input.tsx +49 -0
- package/dist/src/composites/slider-range.tsx +30 -0
- package/dist/src/composites/tag-input.tsx +56 -0
- package/dist/src/composites/time-picker.tsx +199 -0
- package/dist/src/composites/time-range-picker.tsx +25 -0
- package/dist/src/composites/timezone-select.tsx +251 -0
- package/dist/src/composites/transfer.tsx +52 -0
- package/dist/src/composites/user-picker.tsx +40 -0
- package/dist/src/hooks/use-mobile.ts +19 -0
- package/dist/src/i18n/context.tsx +72 -0
- package/dist/src/i18n/en.ts +168 -0
- package/dist/src/i18n/zh.ts +160 -0
- package/dist/src/index.ts +134 -0
- package/dist/src/lib/icons.tsx +16 -0
- package/dist/src/lib/illustrations.tsx +573 -0
- package/dist/src/lib/utils.ts +6 -0
- package/dist/src/products/cells.tsx +18 -0
- package/dist/src/products/code-block.tsx +75 -0
- package/dist/src/products/code-editor.tsx +49 -0
- package/dist/src/products/column-meta.d.ts +9 -0
- package/dist/src/products/data-grid.tsx +689 -0
- package/dist/src/products/diff-viewer.tsx +129 -0
- package/dist/src/products/event-calendar.tsx +845 -0
- package/dist/src/products/export-csv.ts +25 -0
- package/dist/src/products/file-dropzone.tsx +41 -0
- package/dist/src/products/gantt.tsx +41 -0
- package/dist/src/products/jira-wiki-parse.ts +197 -0
- package/dist/src/products/jira-wiki.tsx +285 -0
- package/dist/src/products/jql-input.tsx +62 -0
- package/dist/src/products/jql-language.ts +70 -0
- package/dist/src/products/json-viewer.tsx +37 -0
- package/dist/src/products/kanban-issue-panel.tsx +98 -0
- package/dist/src/products/kanban.tsx +245 -0
- package/dist/src/products/log-viewer.tsx +33 -0
- package/dist/src/products/markdown-editor.tsx +63 -0
- package/dist/src/products/markdown.tsx +26 -0
- package/dist/src/products/rich-text-editor.tsx +84 -0
- package/dist/src/products/sortable-list.tsx +61 -0
- package/dist/src/products/stepper.tsx +89 -0
- package/dist/src/products/timeline.tsx +32 -0
- package/dist/src/products/tree-view.tsx +56 -0
- package/dist/src/styles/globals.css +352 -0
- package/package.json +100 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Slider } from "@monkey-mini-app/ui/components/slider"
|
|
4
|
+
|
|
5
|
+
export function SliderRange({
|
|
6
|
+
value = [20, 80],
|
|
7
|
+
onChange,
|
|
8
|
+
min = 0,
|
|
9
|
+
max = 100,
|
|
10
|
+
}: {
|
|
11
|
+
value?: number[]
|
|
12
|
+
onChange?: (value: number[]) => void
|
|
13
|
+
min?: number
|
|
14
|
+
max?: number
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="w-56" data-testid="slider-range">
|
|
18
|
+
<Slider
|
|
19
|
+
value={value}
|
|
20
|
+
min={min}
|
|
21
|
+
max={max}
|
|
22
|
+
onValueChange={(next) => onChange?.(Array.from(next as number[]))}
|
|
23
|
+
/>
|
|
24
|
+
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
|
25
|
+
<span>{value[0]}</span>
|
|
26
|
+
<span>{value[1]}</span>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { X } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { Badge } from "@monkey-mini-app/ui/components/badge"
|
|
7
|
+
import { Input } from "@monkey-mini-app/ui/components/input"
|
|
8
|
+
import { useLabels } from "@monkey-mini-app/ui/i18n/context"
|
|
9
|
+
|
|
10
|
+
export function TagInput({
|
|
11
|
+
value = [],
|
|
12
|
+
onChange,
|
|
13
|
+
placeholder,
|
|
14
|
+
}: {
|
|
15
|
+
value?: string[]
|
|
16
|
+
onChange?: (value: string[]) => void
|
|
17
|
+
placeholder?: string
|
|
18
|
+
}) {
|
|
19
|
+
const t = useLabels("tagInput")
|
|
20
|
+
const [draft, setDraft] = React.useState("")
|
|
21
|
+
const add = () => {
|
|
22
|
+
const next = draft.trim()
|
|
23
|
+
if (!next || value.includes(next)) return
|
|
24
|
+
onChange?.([...value, next])
|
|
25
|
+
setDraft("")
|
|
26
|
+
}
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex flex-col gap-2" data-testid="tag-input">
|
|
29
|
+
<div className="flex flex-wrap gap-1">
|
|
30
|
+
{value.map((tag) => (
|
|
31
|
+
<Badge key={tag} variant="secondary" className="gap-1">
|
|
32
|
+
{tag}
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
aria-label={`Remove ${tag}`}
|
|
36
|
+
onClick={() => onChange?.(value.filter((item) => item !== tag))}
|
|
37
|
+
>
|
|
38
|
+
<X className="size-3" />
|
|
39
|
+
</button>
|
|
40
|
+
</Badge>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
<Input
|
|
44
|
+
value={draft}
|
|
45
|
+
placeholder={placeholder ?? t.placeholder}
|
|
46
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
47
|
+
onKeyDown={(event) => {
|
|
48
|
+
if (event.key === "Enter") {
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
add()
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ChevronDown } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { Button } from "@monkey-mini-app/ui/components/button"
|
|
7
|
+
import {
|
|
8
|
+
Popover,
|
|
9
|
+
PopoverContent,
|
|
10
|
+
PopoverTrigger,
|
|
11
|
+
} from "@monkey-mini-app/ui/components/popover"
|
|
12
|
+
import { cn } from "@monkey-mini-app/ui/lib/utils"
|
|
13
|
+
|
|
14
|
+
const ITEM_H = 36
|
|
15
|
+
|
|
16
|
+
function pad(n: number) {
|
|
17
|
+
return String(n).padStart(2, "0")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseTime(value: string) {
|
|
21
|
+
const match = /^(\d{1,2}):(\d{1,2})$/.exec(value)
|
|
22
|
+
if (!match) return { hours: 0, minutes: 0 }
|
|
23
|
+
return {
|
|
24
|
+
hours: Math.min(23, Number(match[1])),
|
|
25
|
+
minutes: Math.min(59, Number(match[2])),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function TimeWheel({
|
|
30
|
+
values,
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
testId,
|
|
34
|
+
visible = 5,
|
|
35
|
+
className,
|
|
36
|
+
}: {
|
|
37
|
+
values: number[]
|
|
38
|
+
value: number
|
|
39
|
+
onChange: (value: number) => void
|
|
40
|
+
testId: string
|
|
41
|
+
visible?: number
|
|
42
|
+
className?: string
|
|
43
|
+
}) {
|
|
44
|
+
const listRef = React.useRef<HTMLDivElement>(null)
|
|
45
|
+
const padPx = ITEM_H * Math.floor((visible - 1) / 2)
|
|
46
|
+
const index = Math.max(0, values.indexOf(value))
|
|
47
|
+
const armed = React.useRef(false)
|
|
48
|
+
const timer = React.useRef<number>(0)
|
|
49
|
+
|
|
50
|
+
const jump = React.useCallback((i: number) => {
|
|
51
|
+
const list = listRef.current
|
|
52
|
+
if (!list || list.clientHeight === 0) return
|
|
53
|
+
list.scrollTop = i * ITEM_H
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
React.useLayoutEffect(() => {
|
|
57
|
+
armed.current = false
|
|
58
|
+
const list = listRef.current
|
|
59
|
+
if (!list) return
|
|
60
|
+
jump(index)
|
|
61
|
+
const ro = new ResizeObserver(() => jump(index))
|
|
62
|
+
ro.observe(list)
|
|
63
|
+
const t1 = window.setTimeout(() => jump(index), 0)
|
|
64
|
+
const t2 = window.setTimeout(() => jump(index), 32)
|
|
65
|
+
return () => {
|
|
66
|
+
ro.disconnect()
|
|
67
|
+
window.clearTimeout(t1)
|
|
68
|
+
window.clearTimeout(t2)
|
|
69
|
+
}
|
|
70
|
+
}, [index, jump])
|
|
71
|
+
|
|
72
|
+
const commit = () => {
|
|
73
|
+
const list = listRef.current
|
|
74
|
+
if (!list || !armed.current) return
|
|
75
|
+
const next = Math.min(
|
|
76
|
+
values.length - 1,
|
|
77
|
+
Math.max(0, Math.round(list.scrollTop / ITEM_H))
|
|
78
|
+
)
|
|
79
|
+
jump(next)
|
|
80
|
+
const picked = values[next]
|
|
81
|
+
if (picked != null && picked !== value) onChange(picked)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className={cn("relative w-14", className)}
|
|
87
|
+
style={{ height: ITEM_H * visible }}
|
|
88
|
+
>
|
|
89
|
+
<div className="bg-muted pointer-events-none absolute inset-x-0 top-1/2 z-0 h-9 -translate-y-1/2 rounded-md" />
|
|
90
|
+
<div
|
|
91
|
+
ref={listRef}
|
|
92
|
+
data-testid={testId}
|
|
93
|
+
className="relative z-10 h-full overflow-y-auto [overflow-anchor:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
94
|
+
style={{
|
|
95
|
+
WebkitMaskImage:
|
|
96
|
+
"linear-gradient(to bottom, transparent, black 18%, black 82%, transparent)",
|
|
97
|
+
maskImage:
|
|
98
|
+
"linear-gradient(to bottom, transparent, black 18%, black 82%, transparent)",
|
|
99
|
+
}}
|
|
100
|
+
onPointerDown={() => {
|
|
101
|
+
armed.current = true
|
|
102
|
+
}}
|
|
103
|
+
onScroll={() => {
|
|
104
|
+
if (!armed.current) return
|
|
105
|
+
window.clearTimeout(timer.current)
|
|
106
|
+
timer.current = window.setTimeout(commit, 80)
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<div style={{ height: padPx }} />
|
|
110
|
+
{values.map((item, i) => (
|
|
111
|
+
<button
|
|
112
|
+
key={item}
|
|
113
|
+
type="button"
|
|
114
|
+
data-selected={item === value || undefined}
|
|
115
|
+
className={cn(
|
|
116
|
+
"flex w-full items-center justify-center text-sm tabular-nums",
|
|
117
|
+
item === value
|
|
118
|
+
? "font-medium text-foreground"
|
|
119
|
+
: "text-muted-foreground"
|
|
120
|
+
)}
|
|
121
|
+
style={{ height: ITEM_H }}
|
|
122
|
+
onClick={() => {
|
|
123
|
+
jump(i)
|
|
124
|
+
onChange(item)
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{pad(item)}
|
|
128
|
+
</button>
|
|
129
|
+
))}
|
|
130
|
+
<div style={{ height: padPx }} />
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type TimePickerProps = {
|
|
137
|
+
value?: string
|
|
138
|
+
onChange?: (value: string) => void
|
|
139
|
+
disabled?: boolean
|
|
140
|
+
className?: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `value` is `HH:mm` (24h). */
|
|
144
|
+
export function TimePicker({
|
|
145
|
+
value = "",
|
|
146
|
+
onChange,
|
|
147
|
+
disabled,
|
|
148
|
+
className,
|
|
149
|
+
}: TimePickerProps) {
|
|
150
|
+
const [open, setOpen] = React.useState(false)
|
|
151
|
+
const parsed = parseTime(value)
|
|
152
|
+
const [hours, setHours] = React.useState(parsed.hours)
|
|
153
|
+
const [minutes, setMinutes] = React.useState(parsed.minutes)
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
const next = parseTime(value)
|
|
156
|
+
setHours(next.hours)
|
|
157
|
+
setMinutes(next.minutes)
|
|
158
|
+
}, [value])
|
|
159
|
+
const emit = (nextHours: number, nextMinutes: number) => {
|
|
160
|
+
setHours(nextHours)
|
|
161
|
+
setMinutes(nextMinutes)
|
|
162
|
+
onChange?.(`${pad(nextHours)}:${pad(nextMinutes)}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
167
|
+
<PopoverTrigger
|
|
168
|
+
disabled={disabled}
|
|
169
|
+
render={
|
|
170
|
+
<Button
|
|
171
|
+
variant="outline"
|
|
172
|
+
data-testid="time-picker"
|
|
173
|
+
className={cn("w-[120px] justify-between font-normal tabular-nums", className)}
|
|
174
|
+
/>
|
|
175
|
+
}
|
|
176
|
+
>
|
|
177
|
+
<span>{value || "--:--"}</span>
|
|
178
|
+
<ChevronDown className="size-3.5 opacity-50" />
|
|
179
|
+
</PopoverTrigger>
|
|
180
|
+
<PopoverContent align="start" className="w-auto p-2">
|
|
181
|
+
<div className="flex items-center gap-1">
|
|
182
|
+
<TimeWheel
|
|
183
|
+
testId="time-picker-hour"
|
|
184
|
+
values={Array.from({ length: 24 }, (_, i) => i)}
|
|
185
|
+
value={hours}
|
|
186
|
+
onChange={(h) => emit(h, minutes)}
|
|
187
|
+
/>
|
|
188
|
+
<span className="text-muted-foreground pb-0.5 text-sm font-medium">:</span>
|
|
189
|
+
<TimeWheel
|
|
190
|
+
testId="time-picker-minute"
|
|
191
|
+
values={Array.from({ length: 60 }, (_, i) => i)}
|
|
192
|
+
value={minutes}
|
|
193
|
+
onChange={(m) => emit(hours, m)}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
</PopoverContent>
|
|
197
|
+
</Popover>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { TimePicker } from "@monkey-mini-app/ui/composites/time-picker"
|
|
4
|
+
import { useLabels } from "@monkey-mini-app/ui/i18n/context"
|
|
5
|
+
|
|
6
|
+
export type TimeRange = { start: string; end: string }
|
|
7
|
+
|
|
8
|
+
export function TimeRangePicker({
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
}: {
|
|
12
|
+
value?: TimeRange
|
|
13
|
+
onChange?: (value: TimeRange) => void
|
|
14
|
+
}) {
|
|
15
|
+
const t = useLabels("timeRange")
|
|
16
|
+
const start = value?.start ?? ""
|
|
17
|
+
const end = value?.end ?? ""
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex items-center gap-2" data-testid="time-range-picker">
|
|
20
|
+
<TimePicker value={start} onChange={(next) => onChange?.({ start: next, end })} />
|
|
21
|
+
<span className="text-muted-foreground text-xs">{t.to}</span>
|
|
22
|
+
<TimePicker value={end} onChange={(next) => onChange?.({ start, end: next })} />
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ChevronsUpDown } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { Button } from "@monkey-mini-app/ui/components/button"
|
|
7
|
+
import {
|
|
8
|
+
Command,
|
|
9
|
+
CommandEmpty,
|
|
10
|
+
CommandGroup,
|
|
11
|
+
CommandInput,
|
|
12
|
+
CommandItem,
|
|
13
|
+
CommandList,
|
|
14
|
+
} from "@monkey-mini-app/ui/components/command"
|
|
15
|
+
import {
|
|
16
|
+
Popover,
|
|
17
|
+
PopoverContent,
|
|
18
|
+
PopoverTrigger,
|
|
19
|
+
} from "@monkey-mini-app/ui/components/popover"
|
|
20
|
+
import { useLabels } from "@monkey-mini-app/ui/i18n/context"
|
|
21
|
+
import { cn } from "@monkey-mini-app/ui/lib/utils"
|
|
22
|
+
|
|
23
|
+
const COMMON_IDS = [
|
|
24
|
+
"UTC",
|
|
25
|
+
"Asia/Shanghai",
|
|
26
|
+
"Asia/Singapore",
|
|
27
|
+
"Asia/Tokyo",
|
|
28
|
+
"Asia/Hong_Kong",
|
|
29
|
+
"Europe/London",
|
|
30
|
+
"Europe/Paris",
|
|
31
|
+
"America/New_York",
|
|
32
|
+
"America/Los_Angeles",
|
|
33
|
+
"Australia/Sydney",
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
const ALIASES: Record<string, string> = {
|
|
37
|
+
UTC: "gmt zulu",
|
|
38
|
+
"Asia/Shanghai": "china beijing cst prc",
|
|
39
|
+
"Asia/Singapore": "sg",
|
|
40
|
+
"Asia/Tokyo": "japan jst",
|
|
41
|
+
"Asia/Hong_Kong": "hk hongkong",
|
|
42
|
+
"Europe/London": "uk gmt bst",
|
|
43
|
+
"Europe/Paris": "france cet cest",
|
|
44
|
+
"America/New_York": "nyc eastern est edt usa",
|
|
45
|
+
"America/Los_Angeles": "pacific pst pdt la california usa",
|
|
46
|
+
"Australia/Sydney": "aest australia",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ZoneInfo = {
|
|
50
|
+
id: string
|
|
51
|
+
city: string
|
|
52
|
+
region: string
|
|
53
|
+
offset: string
|
|
54
|
+
search: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ianaZones =
|
|
58
|
+
typeof Intl !== "undefined" && "supportedValuesOf" in Intl
|
|
59
|
+
? Intl.supportedValuesOf("timeZone")
|
|
60
|
+
: ["UTC"]
|
|
61
|
+
|
|
62
|
+
function cityName(zone: string) {
|
|
63
|
+
if (zone === "UTC" || zone === "Etc/UTC") return "UTC"
|
|
64
|
+
return zone.split("/").pop()?.replaceAll("_", " ") ?? zone
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function offsetLabel(zone: string) {
|
|
68
|
+
try {
|
|
69
|
+
return (
|
|
70
|
+
new Intl.DateTimeFormat("en-US", {
|
|
71
|
+
timeZone: zone,
|
|
72
|
+
timeZoneName: "shortOffset",
|
|
73
|
+
})
|
|
74
|
+
.formatToParts(new Date())
|
|
75
|
+
.find((part) => part.type === "timeZoneName")?.value ?? ""
|
|
76
|
+
)
|
|
77
|
+
} catch {
|
|
78
|
+
return ""
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let zoneCache: ZoneInfo[] | null = null
|
|
83
|
+
|
|
84
|
+
export function listTimezones(): ZoneInfo[] {
|
|
85
|
+
if (zoneCache) return zoneCache
|
|
86
|
+
zoneCache = ianaZones.map((id) => {
|
|
87
|
+
const city = cityName(id)
|
|
88
|
+
const region = id.includes("/") ? id.slice(0, id.indexOf("/")) : id
|
|
89
|
+
const offset = offsetLabel(id)
|
|
90
|
+
const alias = ALIASES[id] ?? ""
|
|
91
|
+
return {
|
|
92
|
+
id,
|
|
93
|
+
city,
|
|
94
|
+
region,
|
|
95
|
+
offset,
|
|
96
|
+
search: `${id} ${city} ${offset} ${offset.replace("GMT", "UTC")} ${alias}`.toLowerCase(),
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
return zoneCache
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function localZoneId() {
|
|
103
|
+
try {
|
|
104
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
105
|
+
} catch {
|
|
106
|
+
return "UTC"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function groupByRegion(zones: ZoneInfo[]) {
|
|
111
|
+
const map = new Map<string, ZoneInfo[]>()
|
|
112
|
+
for (const zone of zones) {
|
|
113
|
+
const list = map.get(zone.region)
|
|
114
|
+
if (list) list.push(zone)
|
|
115
|
+
else map.set(zone.region, [zone])
|
|
116
|
+
}
|
|
117
|
+
return [...map.entries()]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ZoneItem({
|
|
121
|
+
zone,
|
|
122
|
+
selected,
|
|
123
|
+
onSelect,
|
|
124
|
+
}: {
|
|
125
|
+
zone: ZoneInfo
|
|
126
|
+
selected: boolean
|
|
127
|
+
onSelect: (id: string) => void
|
|
128
|
+
}) {
|
|
129
|
+
return (
|
|
130
|
+
<CommandItem
|
|
131
|
+
value={zone.id}
|
|
132
|
+
data-checked={selected || undefined}
|
|
133
|
+
onSelect={() => onSelect(zone.id)}
|
|
134
|
+
>
|
|
135
|
+
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
|
136
|
+
<span className="truncate">{zone.city}</span>
|
|
137
|
+
<span className="text-muted-foreground truncate text-[11px]">{zone.id}</span>
|
|
138
|
+
</span>
|
|
139
|
+
<span className="text-muted-foreground text-xs tabular-nums">{zone.offset}</span>
|
|
140
|
+
</CommandItem>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function TimezoneSelect({
|
|
145
|
+
value,
|
|
146
|
+
onChange,
|
|
147
|
+
className,
|
|
148
|
+
}: {
|
|
149
|
+
value?: string
|
|
150
|
+
onChange?: (zone: string) => void
|
|
151
|
+
className?: string
|
|
152
|
+
}) {
|
|
153
|
+
const [open, setOpen] = React.useState(false)
|
|
154
|
+
const [query, setQuery] = React.useState("")
|
|
155
|
+
const t = useLabels("timezoneSelect")
|
|
156
|
+
const zones = React.useMemo(() => listTimezones(), [])
|
|
157
|
+
const selectedId = value ?? localZoneId()
|
|
158
|
+
const selected = zones.find((zone) => zone.id === selectedId)
|
|
159
|
+
|
|
160
|
+
const common = React.useMemo(() => {
|
|
161
|
+
const local = localZoneId()
|
|
162
|
+
const ids = [local, ...COMMON_IDS.filter((id) => id !== local)]
|
|
163
|
+
return ids
|
|
164
|
+
.map((id) => zones.find((zone) => zone.id === id))
|
|
165
|
+
.filter((zone): zone is ZoneInfo => Boolean(zone))
|
|
166
|
+
}, [zones])
|
|
167
|
+
|
|
168
|
+
const matches = React.useMemo(() => {
|
|
169
|
+
const needle = query.trim().toLowerCase()
|
|
170
|
+
if (!needle) return null
|
|
171
|
+
return zones.filter((zone) => zone.search.includes(needle)).slice(0, 80)
|
|
172
|
+
}, [query, zones])
|
|
173
|
+
|
|
174
|
+
const pick = (id: string) => {
|
|
175
|
+
onChange?.(id)
|
|
176
|
+
setOpen(false)
|
|
177
|
+
setQuery("")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<Popover
|
|
182
|
+
open={open}
|
|
183
|
+
onOpenChange={(next) => {
|
|
184
|
+
setOpen(next)
|
|
185
|
+
if (!next) setQuery("")
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<PopoverTrigger
|
|
189
|
+
render={
|
|
190
|
+
<Button
|
|
191
|
+
variant="outline"
|
|
192
|
+
data-testid="timezone-select"
|
|
193
|
+
aria-label={t.aria(selected?.city ?? selectedId)}
|
|
194
|
+
className={cn("w-[240px] justify-between font-normal", className)}
|
|
195
|
+
/>
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
<span className="truncate">
|
|
199
|
+
{selected?.city ?? cityName(selectedId)}
|
|
200
|
+
<span className="text-muted-foreground ml-1 text-xs tabular-nums">
|
|
201
|
+
{selected?.offset ?? offsetLabel(selectedId)}
|
|
202
|
+
</span>
|
|
203
|
+
</span>
|
|
204
|
+
<ChevronsUpDown className="size-3.5 opacity-50" />
|
|
205
|
+
</PopoverTrigger>
|
|
206
|
+
<PopoverContent align="start" className="w-[320px] overflow-hidden p-0">
|
|
207
|
+
<Command shouldFilter={false} className="h-auto overflow-hidden">
|
|
208
|
+
<CommandInput
|
|
209
|
+
placeholder={t.search}
|
|
210
|
+
value={query}
|
|
211
|
+
onValueChange={setQuery}
|
|
212
|
+
data-testid="timezone-search"
|
|
213
|
+
/>
|
|
214
|
+
<CommandList className="max-h-64">
|
|
215
|
+
<CommandEmpty>{t.empty}</CommandEmpty>
|
|
216
|
+
{matches ? (
|
|
217
|
+
groupByRegion(matches).map(([region, items]) => (
|
|
218
|
+
<CommandGroup key={region} heading={region}>
|
|
219
|
+
{items.map((zone) => (
|
|
220
|
+
<ZoneItem
|
|
221
|
+
key={zone.id}
|
|
222
|
+
zone={zone}
|
|
223
|
+
selected={zone.id === selectedId}
|
|
224
|
+
onSelect={pick}
|
|
225
|
+
/>
|
|
226
|
+
))}
|
|
227
|
+
</CommandGroup>
|
|
228
|
+
))
|
|
229
|
+
) : (
|
|
230
|
+
<CommandGroup heading={t.common}>
|
|
231
|
+
{common.map((zone) => (
|
|
232
|
+
<ZoneItem
|
|
233
|
+
key={zone.id}
|
|
234
|
+
zone={zone}
|
|
235
|
+
selected={zone.id === selectedId}
|
|
236
|
+
onSelect={pick}
|
|
237
|
+
/>
|
|
238
|
+
))}
|
|
239
|
+
</CommandGroup>
|
|
240
|
+
)}
|
|
241
|
+
</CommandList>
|
|
242
|
+
{!matches ? (
|
|
243
|
+
<p className="text-muted-foreground border-t px-2.5 py-1.5 text-[11px]">
|
|
244
|
+
{t.typeToSearch(zones.length)}
|
|
245
|
+
</p>
|
|
246
|
+
) : null}
|
|
247
|
+
</Command>
|
|
248
|
+
</PopoverContent>
|
|
249
|
+
</Popover>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Button } from "@monkey-mini-app/ui/components/button"
|
|
4
|
+
|
|
5
|
+
export type TransferItem = { id: string; label: string }
|
|
6
|
+
|
|
7
|
+
export function Transfer({
|
|
8
|
+
items,
|
|
9
|
+
value = [],
|
|
10
|
+
onChange,
|
|
11
|
+
}: {
|
|
12
|
+
items: TransferItem[]
|
|
13
|
+
value?: string[]
|
|
14
|
+
onChange?: (value: string[]) => void
|
|
15
|
+
}) {
|
|
16
|
+
const selected = new Set(value)
|
|
17
|
+
const left = items.filter((item) => !selected.has(item.id))
|
|
18
|
+
const right = items.filter((item) => selected.has(item.id))
|
|
19
|
+
return (
|
|
20
|
+
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2" data-testid="transfer">
|
|
21
|
+
<ul className="h-40 overflow-auto rounded-lg border p-1 text-sm">
|
|
22
|
+
{left.map((item) => (
|
|
23
|
+
<li key={item.id}>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
className="w-full rounded-md px-2 py-1 text-left hover:bg-muted"
|
|
27
|
+
onClick={() => onChange?.([...value, item.id])}
|
|
28
|
+
>
|
|
29
|
+
{item.label}
|
|
30
|
+
</button>
|
|
31
|
+
</li>
|
|
32
|
+
))}
|
|
33
|
+
</ul>
|
|
34
|
+
<Button variant="outline" size="icon-xs" disabled>
|
|
35
|
+
→
|
|
36
|
+
</Button>
|
|
37
|
+
<ul className="h-40 overflow-auto rounded-lg border p-1 text-sm">
|
|
38
|
+
{right.map((item) => (
|
|
39
|
+
<li key={item.id}>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className="w-full rounded-md px-2 py-1 text-left hover:bg-muted"
|
|
43
|
+
onClick={() => onChange?.(value.filter((id) => id !== item.id))}
|
|
44
|
+
>
|
|
45
|
+
{item.label}
|
|
46
|
+
</button>
|
|
47
|
+
</li>
|
|
48
|
+
))}
|
|
49
|
+
</ul>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Avatar, AvatarFallback } from "@monkey-mini-app/ui/components/avatar"
|
|
4
|
+
import { NativeSelect, NativeSelectOption } from "@monkey-mini-app/ui/components/native-select"
|
|
5
|
+
import { useLabels } from "@monkey-mini-app/ui/i18n/context"
|
|
6
|
+
|
|
7
|
+
export type UserOption = { id: string; name: string }
|
|
8
|
+
|
|
9
|
+
export function UserPicker({
|
|
10
|
+
users,
|
|
11
|
+
value,
|
|
12
|
+
onChange,
|
|
13
|
+
}: {
|
|
14
|
+
users: UserOption[]
|
|
15
|
+
value?: string
|
|
16
|
+
onChange?: (id: string) => void
|
|
17
|
+
}) {
|
|
18
|
+
const t = useLabels("userPicker")
|
|
19
|
+
const selected = users.find((user) => user.id === value)
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex items-center gap-2" data-testid="user-picker">
|
|
22
|
+
<Avatar className="size-6">
|
|
23
|
+
<AvatarFallback className="text-[10px]">
|
|
24
|
+
{(selected?.name ?? "?").slice(0, 2).toUpperCase()}
|
|
25
|
+
</AvatarFallback>
|
|
26
|
+
</Avatar>
|
|
27
|
+
<NativeSelect
|
|
28
|
+
value={value ?? ""}
|
|
29
|
+
onChange={(event) => onChange?.(event.target.value)}
|
|
30
|
+
>
|
|
31
|
+
<NativeSelectOption value="">{t.placeholder}</NativeSelectOption>
|
|
32
|
+
{users.map((user) => (
|
|
33
|
+
<NativeSelectOption key={user.id} value={user.id}>
|
|
34
|
+
{user.name}
|
|
35
|
+
</NativeSelectOption>
|
|
36
|
+
))}
|
|
37
|
+
</NativeSelect>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
10
|
+
const onChange = () => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
12
|
+
}
|
|
13
|
+
mql.addEventListener("change", onChange)
|
|
14
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
15
|
+
return () => mql.removeEventListener("change", onChange)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return !!isMobile
|
|
19
|
+
}
|