@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.
Files changed (149) hide show
  1. package/components.json +23 -0
  2. package/dist/globals.css +2 -0
  3. package/dist/index.js +138 -0
  4. package/dist/manifest.json +11 -0
  5. package/dist/src/blocks/activity-feed.tsx +9 -0
  6. package/dist/src/blocks/app-shell.tsx +37 -0
  7. package/dist/src/blocks/attachment-gallery.tsx +30 -0
  8. package/dist/src/blocks/bar-chart.tsx +33 -0
  9. package/dist/src/blocks/comment-thread.tsx +30 -0
  10. package/dist/src/blocks/description-list.tsx +16 -0
  11. package/dist/src/blocks/detail-panel.tsx +43 -0
  12. package/dist/src/blocks/donut-chart.tsx +38 -0
  13. package/dist/src/blocks/env-badge.tsx +23 -0
  14. package/dist/src/blocks/file-tree.tsx +5 -0
  15. package/dist/src/blocks/filter-bar.tsx +21 -0
  16. package/dist/src/blocks/gauge.tsx +13 -0
  17. package/dist/src/blocks/notification-center.tsx +22 -0
  18. package/dist/src/blocks/page-header.tsx +30 -0
  19. package/dist/src/blocks/progress-ring.tsx +25 -0
  20. package/dist/src/blocks/radar-chart.tsx +31 -0
  21. package/dist/src/blocks/request-inspector.tsx +34 -0
  22. package/dist/src/blocks/run-timeline.tsx +5 -0
  23. package/dist/src/blocks/scrollspy.tsx +42 -0
  24. package/dist/src/blocks/severity-chip.tsx +27 -0
  25. package/dist/src/blocks/sparkline.tsx +19 -0
  26. package/dist/src/blocks/stat-card.tsx +49 -0
  27. package/dist/src/blocks/status-badge.tsx +39 -0
  28. package/dist/src/blocks/terminal.tsx +10 -0
  29. package/dist/src/blocks/test-step-list.tsx +19 -0
  30. package/dist/src/blocks/trend-card.tsx +51 -0
  31. package/dist/src/components/accordion.tsx +72 -0
  32. package/dist/src/components/alert-dialog.tsx +185 -0
  33. package/dist/src/components/alert.tsx +76 -0
  34. package/dist/src/components/aspect-ratio.tsx +22 -0
  35. package/dist/src/components/attachment.tsx +207 -0
  36. package/dist/src/components/avatar.tsx +107 -0
  37. package/dist/src/components/badge.tsx +52 -0
  38. package/dist/src/components/breadcrumb.tsx +125 -0
  39. package/dist/src/components/bubble.tsx +128 -0
  40. package/dist/src/components/button-group.tsx +87 -0
  41. package/dist/src/components/button.tsx +58 -0
  42. package/dist/src/components/calendar.tsx +221 -0
  43. package/dist/src/components/card.tsx +103 -0
  44. package/dist/src/components/chart.tsx +371 -0
  45. package/dist/src/components/checkbox.tsx +29 -0
  46. package/dist/src/components/collapsible.tsx +19 -0
  47. package/dist/src/components/combobox.tsx +295 -0
  48. package/dist/src/components/command.tsx +196 -0
  49. package/dist/src/components/context-menu.tsx +271 -0
  50. package/dist/src/components/dialog.tsx +158 -0
  51. package/dist/src/components/drawer.tsx +226 -0
  52. package/dist/src/components/dropdown-menu.tsx +268 -0
  53. package/dist/src/components/empty.tsx +123 -0
  54. package/dist/src/components/field.tsx +236 -0
  55. package/dist/src/components/hover-card.tsx +49 -0
  56. package/dist/src/components/input-group.tsx +158 -0
  57. package/dist/src/components/input.tsx +20 -0
  58. package/dist/src/components/item.tsx +201 -0
  59. package/dist/src/components/kbd.tsx +26 -0
  60. package/dist/src/components/label.tsx +20 -0
  61. package/dist/src/components/marker.tsx +71 -0
  62. package/dist/src/components/menubar.tsx +280 -0
  63. package/dist/src/components/message-scroller.tsx +129 -0
  64. package/dist/src/components/message.tsx +92 -0
  65. package/dist/src/components/native-select.tsx +61 -0
  66. package/dist/src/components/navigation-menu.tsx +168 -0
  67. package/dist/src/components/pagination.tsx +135 -0
  68. package/dist/src/components/popover.tsx +88 -0
  69. package/dist/src/components/progress.tsx +83 -0
  70. package/dist/src/components/questionnaire.tsx +329 -0
  71. package/dist/src/components/radio-group.tsx +36 -0
  72. package/dist/src/components/resizable.tsx +50 -0
  73. package/dist/src/components/scroll-area.tsx +52 -0
  74. package/dist/src/components/select.tsx +201 -0
  75. package/dist/src/components/separator.tsx +23 -0
  76. package/dist/src/components/sheet.tsx +136 -0
  77. package/dist/src/components/sidebar.tsx +721 -0
  78. package/dist/src/components/skeleton.tsx +13 -0
  79. package/dist/src/components/slider.tsx +52 -0
  80. package/dist/src/components/spinner.tsx +10 -0
  81. package/dist/src/components/switch.tsx +32 -0
  82. package/dist/src/components/table.tsx +114 -0
  83. package/dist/src/components/tabs.tsx +82 -0
  84. package/dist/src/components/textarea.tsx +18 -0
  85. package/dist/src/components/toast.tsx +234 -0
  86. package/dist/src/components/toggle-group.tsx +89 -0
  87. package/dist/src/components/toggle.tsx +43 -0
  88. package/dist/src/components/tooltip.tsx +66 -0
  89. package/dist/src/composites/autocomplete.tsx +55 -0
  90. package/dist/src/composites/cascader.tsx +46 -0
  91. package/dist/src/composites/color-picker.tsx +19 -0
  92. package/dist/src/composites/confirm-dialog.tsx +56 -0
  93. package/dist/src/composites/copyable.tsx +37 -0
  94. package/dist/src/composites/currency-input.tsx +31 -0
  95. package/dist/src/composites/date-chrome.tsx +157 -0
  96. package/dist/src/composites/date-picker.tsx +91 -0
  97. package/dist/src/composites/date-range-picker.tsx +96 -0
  98. package/dist/src/composites/date-time-picker.tsx +115 -0
  99. package/dist/src/composites/date-time-range-picker.tsx +51 -0
  100. package/dist/src/composites/duration-input.tsx +122 -0
  101. package/dist/src/composites/mini-calendar.tsx +24 -0
  102. package/dist/src/composites/number-field.tsx +53 -0
  103. package/dist/src/composites/password-field.tsx +44 -0
  104. package/dist/src/composites/phone-input.tsx +22 -0
  105. package/dist/src/composites/rating.tsx +39 -0
  106. package/dist/src/composites/relative-date-picker.tsx +62 -0
  107. package/dist/src/composites/search-input.tsx +49 -0
  108. package/dist/src/composites/slider-range.tsx +30 -0
  109. package/dist/src/composites/tag-input.tsx +56 -0
  110. package/dist/src/composites/time-picker.tsx +199 -0
  111. package/dist/src/composites/time-range-picker.tsx +25 -0
  112. package/dist/src/composites/timezone-select.tsx +251 -0
  113. package/dist/src/composites/transfer.tsx +52 -0
  114. package/dist/src/composites/user-picker.tsx +40 -0
  115. package/dist/src/hooks/use-mobile.ts +19 -0
  116. package/dist/src/i18n/context.tsx +72 -0
  117. package/dist/src/i18n/en.ts +168 -0
  118. package/dist/src/i18n/zh.ts +160 -0
  119. package/dist/src/index.ts +134 -0
  120. package/dist/src/lib/icons.tsx +16 -0
  121. package/dist/src/lib/illustrations.tsx +573 -0
  122. package/dist/src/lib/utils.ts +6 -0
  123. package/dist/src/products/cells.tsx +18 -0
  124. package/dist/src/products/code-block.tsx +75 -0
  125. package/dist/src/products/code-editor.tsx +49 -0
  126. package/dist/src/products/column-meta.d.ts +9 -0
  127. package/dist/src/products/data-grid.tsx +689 -0
  128. package/dist/src/products/diff-viewer.tsx +129 -0
  129. package/dist/src/products/event-calendar.tsx +845 -0
  130. package/dist/src/products/export-csv.ts +25 -0
  131. package/dist/src/products/file-dropzone.tsx +41 -0
  132. package/dist/src/products/gantt.tsx +41 -0
  133. package/dist/src/products/jira-wiki-parse.ts +197 -0
  134. package/dist/src/products/jira-wiki.tsx +285 -0
  135. package/dist/src/products/jql-input.tsx +62 -0
  136. package/dist/src/products/jql-language.ts +70 -0
  137. package/dist/src/products/json-viewer.tsx +37 -0
  138. package/dist/src/products/kanban-issue-panel.tsx +98 -0
  139. package/dist/src/products/kanban.tsx +245 -0
  140. package/dist/src/products/log-viewer.tsx +33 -0
  141. package/dist/src/products/markdown-editor.tsx +63 -0
  142. package/dist/src/products/markdown.tsx +26 -0
  143. package/dist/src/products/rich-text-editor.tsx +84 -0
  144. package/dist/src/products/sortable-list.tsx +61 -0
  145. package/dist/src/products/stepper.tsx +89 -0
  146. package/dist/src/products/timeline.tsx +32 -0
  147. package/dist/src/products/tree-view.tsx +56 -0
  148. package/dist/src/styles/globals.css +352 -0
  149. 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
+ }