ajo-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/LICENSE +15 -0
- package/README.md +126 -0
- package/dist/accordion.js +130 -0
- package/dist/avatar.js +54 -0
- package/dist/calendar.js +2 -0
- package/dist/carousel.js +239 -0
- package/dist/chart.js +790 -0
- package/dist/checkbox-group.js +70 -0
- package/dist/checkbox.js +77 -0
- package/dist/chunks/bar-CVafh6C1.js +99 -0
- package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
- package/dist/chunks/collection-DtRB63U4.js +111 -0
- package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
- package/dist/chunks/menu-B45IyHHC.js +704 -0
- package/dist/chunks/native-BJdhd9XJ.js +20 -0
- package/dist/chunks/popup-C8Bb3l_g.js +459 -0
- package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
- package/dist/chunks/position-D6_i_SRn.js +434 -0
- package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
- package/dist/collapsible.js +106 -0
- package/dist/command.js +263 -0
- package/dist/context-menu.js +112 -0
- package/dist/data-table.js +5 -0
- package/dist/dialog.js +207 -0
- package/dist/direction.js +22 -0
- package/dist/drawer.js +139 -0
- package/dist/field.js +26 -0
- package/dist/index.js +38 -0
- package/dist/input-date.js +994 -0
- package/dist/input-group.js +52 -0
- package/dist/input-otp.js +179 -0
- package/dist/menu.js +2 -0
- package/dist/menubar.js +236 -0
- package/dist/message-scroller.js +446 -0
- package/dist/navigation-menu.js +330 -0
- package/dist/popover.js +307 -0
- package/dist/progress.js +39 -0
- package/dist/radio-group.js +107 -0
- package/dist/resizable.js +172 -0
- package/dist/select.js +961 -0
- package/dist/sidebar.js +343 -0
- package/dist/slider.js +259 -0
- package/dist/switch.js +53 -0
- package/dist/tabs.js +182 -0
- package/dist/toast.js +492 -0
- package/dist/toggle-group.js +111 -0
- package/dist/toggle.js +52 -0
- package/dist/toolbar.js +127 -0
- package/dist/tooltip.js +196 -0
- package/dist/utils.js +104 -0
- package/dist/virtual-list.js +2 -0
- package/package.json +250 -0
- package/src/accordion.tsx +261 -0
- package/src/availability.ts +261 -0
- package/src/avatar.tsx +99 -0
- package/src/bar.ts +156 -0
- package/src/calendar.tsx +1441 -0
- package/src/carousel.tsx +424 -0
- package/src/chart.tsx +1194 -0
- package/src/checkbox-group.tsx +132 -0
- package/src/checkbox.tsx +130 -0
- package/src/collapsible.tsx +188 -0
- package/src/collection.ts +154 -0
- package/src/command.tsx +511 -0
- package/src/context-menu.tsx +233 -0
- package/src/data-table-contract.ts +143 -0
- package/src/data-table-model.ts +760 -0
- package/src/data-table.tsx +475 -0
- package/src/dialog.tsx +393 -0
- package/src/direction.tsx +45 -0
- package/src/drawer.tsx +251 -0
- package/src/field.tsx +61 -0
- package/src/index.ts +37 -0
- package/src/input-date.tsx +1539 -0
- package/src/input-group.tsx +142 -0
- package/src/input-otp.tsx +324 -0
- package/src/menu-cluster.ts +124 -0
- package/src/menu.tsx +1095 -0
- package/src/menubar.tsx +459 -0
- package/src/message-scroller.tsx +732 -0
- package/src/native.ts +26 -0
- package/src/navigation-menu.tsx +578 -0
- package/src/popover.tsx +519 -0
- package/src/popup-surface.tsx +31 -0
- package/src/popup.ts +569 -0
- package/src/position.ts +523 -0
- package/src/progress.tsx +70 -0
- package/src/radio-group.tsx +186 -0
- package/src/resizable.tsx +310 -0
- package/src/segments.ts +922 -0
- package/src/select.tsx +1501 -0
- package/src/sidebar.tsx +683 -0
- package/src/slider.tsx +424 -0
- package/src/switch.tsx +104 -0
- package/src/tabs.tsx +314 -0
- package/src/toast.tsx +923 -0
- package/src/toggle-group.tsx +249 -0
- package/src/toggle.tsx +91 -0
- package/src/toolbar.tsx +212 -0
- package/src/tooltip.tsx +359 -0
- package/src/utils.ts +204 -0
- package/src/virtual-list.tsx +205 -0
- package/src/virtual.ts +385 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { remember } from 'ajo-cloves'
|
|
2
|
+
import { fromISO, type SegmentsKind } from './segments'
|
|
3
|
+
|
|
4
|
+
/** Half-open wall-time interval used by availability expressions. */
|
|
5
|
+
export type TimeWindow = {
|
|
6
|
+
/** Inclusive wall-time start, `HH:MM[:SS]`. */
|
|
7
|
+
from?: string
|
|
8
|
+
/** Exclusive wall-time end, `HH:MM[:SS]`. */
|
|
9
|
+
to?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type DateMatcher = {
|
|
13
|
+
after?: Date
|
|
14
|
+
before?: Date
|
|
15
|
+
dayOfWeek?: number[]
|
|
16
|
+
from?: Date
|
|
17
|
+
to?: Date
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Day-granular matcher grammar shared by Calendar and date fields. */
|
|
21
|
+
export type CalendarMatcher =
|
|
22
|
+
| ((date: Date) => boolean)
|
|
23
|
+
| DateMatcher
|
|
24
|
+
| Date
|
|
25
|
+
| Date[]
|
|
26
|
+
|
|
27
|
+
/** Calendar matcher with an optional half-open wall-time dimension. */
|
|
28
|
+
export type AvailabilityMatcher =
|
|
29
|
+
| CalendarMatcher
|
|
30
|
+
| (DateMatcher & { time: TimeWindow })
|
|
31
|
+
|
|
32
|
+
type DateParts = {
|
|
33
|
+
day: number
|
|
34
|
+
hour: number
|
|
35
|
+
minute: number
|
|
36
|
+
month: number
|
|
37
|
+
second: number
|
|
38
|
+
year: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Stable compiled availability view. */
|
|
42
|
+
export type Availability = {
|
|
43
|
+
day(date: Date): boolean
|
|
44
|
+
at(date: Date): boolean
|
|
45
|
+
value(kind: SegmentsKind, value: string): boolean
|
|
46
|
+
crosses(from: Date, to: Date): boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type AvailabilityCompileOptions = {
|
|
50
|
+
/** Calendar time zone used for matcher and candidate date parts. */
|
|
51
|
+
timeZone?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const clock = (value: string | undefined, fallback: number) => {
|
|
55
|
+
if (value == null) return fallback
|
|
56
|
+
const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value)
|
|
57
|
+
if (!match) return Number.NaN
|
|
58
|
+
const hour = Number(match[1])
|
|
59
|
+
const minute = Number(match[2])
|
|
60
|
+
const second = Number(match[3] ?? 0)
|
|
61
|
+
if (hour > 23 || minute > 59 || second > 59) return Number.NaN
|
|
62
|
+
return hour * 3600 + minute * 60 + second
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const timePredicate = (window: TimeWindow) => {
|
|
66
|
+
const from = clock(window.from, 0)
|
|
67
|
+
const to = clock(window.to, 24 * 3600)
|
|
68
|
+
const valid = Number.isFinite(from) && Number.isFinite(to) && from < to
|
|
69
|
+
return (parts: DateParts) => {
|
|
70
|
+
const value = parts.hour * 3600 + parts.minute * 60 + parts.second
|
|
71
|
+
return valid && from <= value && value < to
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const partFormatters = new Map<string, Intl.DateTimeFormat>()
|
|
76
|
+
|
|
77
|
+
const partFormatter = (timeZone: string) => {
|
|
78
|
+
let formatter = partFormatters.get(timeZone)
|
|
79
|
+
if (!formatter) {
|
|
80
|
+
formatter = new Intl.DateTimeFormat('en-CA', {
|
|
81
|
+
day: '2-digit',
|
|
82
|
+
hour: '2-digit',
|
|
83
|
+
hourCycle: 'h23',
|
|
84
|
+
minute: '2-digit',
|
|
85
|
+
month: '2-digit',
|
|
86
|
+
second: '2-digit',
|
|
87
|
+
timeZone,
|
|
88
|
+
year: 'numeric',
|
|
89
|
+
})
|
|
90
|
+
remember(partFormatters, timeZone, formatter)
|
|
91
|
+
}
|
|
92
|
+
return formatter
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const partsOf = (date: Date, timeZone?: string): DateParts => {
|
|
96
|
+
if (!Number.isFinite(date.getTime())) return {
|
|
97
|
+
day: Number.NaN,
|
|
98
|
+
hour: Number.NaN,
|
|
99
|
+
minute: Number.NaN,
|
|
100
|
+
month: Number.NaN,
|
|
101
|
+
second: Number.NaN,
|
|
102
|
+
year: Number.NaN,
|
|
103
|
+
}
|
|
104
|
+
if (!timeZone) return {
|
|
105
|
+
day: date.getDate(),
|
|
106
|
+
hour: date.getHours(),
|
|
107
|
+
minute: date.getMinutes(),
|
|
108
|
+
month: date.getMonth() + 1,
|
|
109
|
+
second: date.getSeconds(),
|
|
110
|
+
year: date.getFullYear(),
|
|
111
|
+
}
|
|
112
|
+
const parts = partFormatter(timeZone).formatToParts(date)
|
|
113
|
+
const value = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find(part => part.type === type)?.value)
|
|
114
|
+
return {
|
|
115
|
+
day: value('day'),
|
|
116
|
+
hour: value('hour'),
|
|
117
|
+
minute: value('minute'),
|
|
118
|
+
month: value('month'),
|
|
119
|
+
second: value('second'),
|
|
120
|
+
year: value('year'),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const dayValue = (parts: Pick<DateParts, 'day' | 'month' | 'year'>) =>
|
|
125
|
+
parts.year * 10000 + parts.month * 100 + parts.day
|
|
126
|
+
|
|
127
|
+
const exactUtcDate = (parts: Pick<DateParts, 'day' | 'month' | 'year'> & Partial<Pick<DateParts, 'hour' | 'minute' | 'second'>>) => {
|
|
128
|
+
const date = new Date(0)
|
|
129
|
+
date.setUTCFullYear(parts.year, parts.month - 1, parts.day)
|
|
130
|
+
date.setUTCHours(parts.hour ?? 0, parts.minute ?? 0, parts.second ?? 0, 0)
|
|
131
|
+
return date
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const exactLocalDate = (parts: DateParts) => {
|
|
135
|
+
const date = new Date(0)
|
|
136
|
+
date.setFullYear(parts.year, parts.month - 1, parts.day)
|
|
137
|
+
date.setHours(parts.hour, parts.minute, parts.second, 0)
|
|
138
|
+
return date
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const weekday = (parts: Pick<DateParts, 'day' | 'month' | 'year'>) =>
|
|
142
|
+
exactUtcDate(parts).getUTCDay()
|
|
143
|
+
|
|
144
|
+
/** Constructs a stable instant for one wall-clock value in a calendar time zone. */
|
|
145
|
+
export const calendarDate = (parts: DateParts, timeZone?: string) => {
|
|
146
|
+
if (!timeZone) return exactLocalDate(parts)
|
|
147
|
+
const wanted = exactUtcDate(parts).getTime()
|
|
148
|
+
let instant = wanted
|
|
149
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
150
|
+
const observed = partsOf(new Date(instant), timeZone)
|
|
151
|
+
const actual = exactUtcDate(observed).getTime()
|
|
152
|
+
instant += wanted - actual
|
|
153
|
+
}
|
|
154
|
+
return new Date(instant)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const addDay = (parts: DateParts): DateParts => {
|
|
158
|
+
const next = exactUtcDate({ ...parts, day: parts.day + 1, hour: 12 })
|
|
159
|
+
return {
|
|
160
|
+
day: next.getUTCDate(),
|
|
161
|
+
hour: 12,
|
|
162
|
+
minute: 0,
|
|
163
|
+
month: next.getUTCMonth() + 1,
|
|
164
|
+
second: 0,
|
|
165
|
+
year: next.getUTCFullYear(),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Compiles date/time expressions into stable day- and instant-granular predicates. */
|
|
170
|
+
export const compile = (
|
|
171
|
+
matcher: AvailabilityMatcher | AvailabilityMatcher[] | undefined,
|
|
172
|
+
options: AvailabilityCompileOptions = {},
|
|
173
|
+
): Availability | undefined => {
|
|
174
|
+
if (matcher == null) return undefined
|
|
175
|
+
const matchers = (Array.isArray(matcher) ? matcher : [matcher]) as AvailabilityMatcher[]
|
|
176
|
+
if (!matchers.length) return undefined
|
|
177
|
+
const timeZone = options.timeZone
|
|
178
|
+
const parts = (date: Date) => partsOf(date, timeZone)
|
|
179
|
+
type Predicate = {
|
|
180
|
+
date: (value: Date, current: () => DateParts) => boolean
|
|
181
|
+
time?: (current: DateParts) => boolean
|
|
182
|
+
}
|
|
183
|
+
const compileMatcher = (item: AvailabilityMatcher): Predicate => {
|
|
184
|
+
if (item instanceof Date) {
|
|
185
|
+
const expected = dayValue(parts(item))
|
|
186
|
+
return { date: (_value, current) => dayValue(current()) === expected }
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(item)) {
|
|
189
|
+
const expected = new Set(item
|
|
190
|
+
.map(entry => dayValue(parts(entry)))
|
|
191
|
+
.filter(Number.isFinite))
|
|
192
|
+
return { date: (_value, current) => expected.has(dayValue(current())) }
|
|
193
|
+
}
|
|
194
|
+
if (typeof item === 'function') return { date: item }
|
|
195
|
+
|
|
196
|
+
const {
|
|
197
|
+
after,
|
|
198
|
+
before,
|
|
199
|
+
dayOfWeek,
|
|
200
|
+
from,
|
|
201
|
+
to,
|
|
202
|
+
} = item
|
|
203
|
+
const afterDay = after == null ? undefined : dayValue(parts(after))
|
|
204
|
+
const beforeDay = before == null ? undefined : dayValue(parts(before))
|
|
205
|
+
const fromDay = from == null ? undefined : dayValue(parts(from))
|
|
206
|
+
const toDay = to == null ? undefined : dayValue(parts(to))
|
|
207
|
+
const weekdays = dayOfWeek == null ? undefined : new Set(dayOfWeek)
|
|
208
|
+
const hasDate = after != null || before != null || dayOfWeek != null || from != null || to != null
|
|
209
|
+
const time = 'time' in item ? timePredicate(item.time) : undefined
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
date: (_value, read) => {
|
|
213
|
+
if (!hasDate) return time != null
|
|
214
|
+
const current = read()
|
|
215
|
+
const currentDay = dayValue(current)
|
|
216
|
+
if (weekdays != null && !weekdays.has(weekday(current))) return false
|
|
217
|
+
if (beforeDay != null && !(currentDay < beforeDay)) return false
|
|
218
|
+
if (afterDay != null && !(currentDay > afterDay)) return false
|
|
219
|
+
if (fromDay != null && toDay != null) return fromDay <= currentDay && currentDay <= toDay
|
|
220
|
+
if (fromDay != null) return currentDay === fromDay
|
|
221
|
+
if (toDay != null) return currentDay === toDay
|
|
222
|
+
return true
|
|
223
|
+
},
|
|
224
|
+
time,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const predicates = matchers.map(compileMatcher)
|
|
228
|
+
const day = (date: Date) => {
|
|
229
|
+
let current: DateParts | undefined
|
|
230
|
+
const read = () => current ??= parts(date)
|
|
231
|
+
return predicates.some(predicate => predicate.time == null && predicate.date(date, read))
|
|
232
|
+
}
|
|
233
|
+
const at = (date: Date) => {
|
|
234
|
+
let current: DateParts | undefined
|
|
235
|
+
const read = () => current ??= parts(date)
|
|
236
|
+
return predicates.some(predicate => predicate.date(date, read) && (predicate.time?.(read()) ?? true))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
day,
|
|
241
|
+
at,
|
|
242
|
+
value(kind: SegmentsKind, value: string) {
|
|
243
|
+
const units = fromISO(kind, value)
|
|
244
|
+
if (!units) return false
|
|
245
|
+
const now = parts(new Date())
|
|
246
|
+
const date = kind === 'time'
|
|
247
|
+
? calendarDate({ ...now, hour: units.hour ?? 0, minute: units.minute ?? 0, second: units.second ?? 0 }, timeZone)
|
|
248
|
+
: calendarDate({ day: units.day!, hour: units.hour ?? 12, minute: units.minute ?? 0, month: units.month!, second: units.second ?? 0, year: units.year! }, timeZone)
|
|
249
|
+
return kind === 'date' ? day(date) : at(date)
|
|
250
|
+
},
|
|
251
|
+
crosses(from: Date, to: Date) {
|
|
252
|
+
const end = dayValue(parts(to))
|
|
253
|
+
let current = parts(from)
|
|
254
|
+
if (dayValue(current) >= end) return false
|
|
255
|
+
for (current = addDay(current); dayValue(current) < end; current = addDay(current)) {
|
|
256
|
+
if (day(calendarDate(current, timeZone))) return true
|
|
257
|
+
}
|
|
258
|
+
return false
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
}
|
package/src/avatar.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { callHandler } from 'ajo-cloves'
|
|
3
|
+
|
|
4
|
+
/** Arguments for the Avatar root. */
|
|
5
|
+
export type AvatarArgs = WithChildren<IntrinsicElements['div'] & {
|
|
6
|
+
/** Size marker mirrored as `data-size`. */
|
|
7
|
+
size?: string
|
|
8
|
+
}>
|
|
9
|
+
|
|
10
|
+
/** Arguments for the native image inside an Avatar. */
|
|
11
|
+
export type AvatarImageArgs = IntrinsicElements['img']
|
|
12
|
+
/** Arguments for fallback content shown without a usable image. */
|
|
13
|
+
export type AvatarFallbackArgs = WithChildren<IntrinsicElements['span']>
|
|
14
|
+
/** Arguments for the status badge attached to an Avatar. */
|
|
15
|
+
export type AvatarBadgeArgs = WithChildren<IntrinsicElements['span']>
|
|
16
|
+
/** Arguments for a visual group of overlapping avatars. */
|
|
17
|
+
export type AvatarGroupArgs = WithChildren<IntrinsicElements['div']>
|
|
18
|
+
/** Arguments for the overflow count in an Avatar group. */
|
|
19
|
+
export type AvatarGroupCountArgs = WithChildren<IntrinsicElements['div']>
|
|
20
|
+
|
|
21
|
+
/** Unstyled avatar root that exposes image, fallback, and badge slots. */
|
|
22
|
+
const Avatar: Stateless<AvatarArgs> = ({
|
|
23
|
+
children,
|
|
24
|
+
size = 'default',
|
|
25
|
+
...attrs
|
|
26
|
+
}) => (
|
|
27
|
+
<div
|
|
28
|
+
{...attrs}
|
|
29
|
+
data-size={size}
|
|
30
|
+
data-slot="avatar"
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
/** Unstyled avatar image that hides itself after load errors. */
|
|
37
|
+
const AvatarImage: Stateless<AvatarImageArgs> = ({
|
|
38
|
+
alt = '',
|
|
39
|
+
decoding = 'async',
|
|
40
|
+
loading = 'lazy',
|
|
41
|
+
'set:onerror': onerror,
|
|
42
|
+
'set:onload': onload,
|
|
43
|
+
...attrs
|
|
44
|
+
}) => (
|
|
45
|
+
<img
|
|
46
|
+
{...attrs}
|
|
47
|
+
alt={alt}
|
|
48
|
+
data-slot="avatar-image"
|
|
49
|
+
decoding={decoding}
|
|
50
|
+
loading={loading}
|
|
51
|
+
set:onerror={(event: Event | string) => {
|
|
52
|
+
if (event instanceof Event) {
|
|
53
|
+
;(event.currentTarget as HTMLImageElement).hidden = true
|
|
54
|
+
callHandler(onerror, event)
|
|
55
|
+
}
|
|
56
|
+
}}
|
|
57
|
+
set:onload={(event: Event) => {
|
|
58
|
+
;(event.currentTarget as HTMLImageElement).hidden = false
|
|
59
|
+
callHandler(onload, event)
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
/** Unstyled fallback content for an Avatar. */
|
|
65
|
+
const AvatarFallback: Stateless<AvatarFallbackArgs> = ({ children, ...attrs }) => (
|
|
66
|
+
<span {...attrs} data-slot="avatar-fallback">
|
|
67
|
+
{children}
|
|
68
|
+
</span>
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
/** Unstyled badge anchored to an Avatar. */
|
|
72
|
+
const AvatarBadge: Stateless<AvatarBadgeArgs> = ({ children, ...attrs }) => (
|
|
73
|
+
<span {...attrs} data-slot="avatar-badge">
|
|
74
|
+
{children}
|
|
75
|
+
</span>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
/** Unstyled container for a group of avatars. */
|
|
79
|
+
const AvatarGroup: Stateless<AvatarGroupArgs> = ({ children, ...attrs }) => (
|
|
80
|
+
<div {...attrs} data-slot="avatar-group">
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/** Unstyled count for avatars omitted from a visible group. */
|
|
86
|
+
const AvatarGroupCount: Stateless<AvatarGroupCountArgs> = ({ children, ...attrs }) => (
|
|
87
|
+
<div {...attrs} data-slot="avatar-group-count">
|
|
88
|
+
{children}
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
export {
|
|
93
|
+
Avatar,
|
|
94
|
+
AvatarBadge,
|
|
95
|
+
AvatarFallback,
|
|
96
|
+
AvatarGroup,
|
|
97
|
+
AvatarGroupCount,
|
|
98
|
+
AvatarImage,
|
|
99
|
+
}
|
package/src/bar.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Host } from 'ajo-cloves'
|
|
2
|
+
import { controlled, dom, roving, typeahead } from 'ajo-cloves'
|
|
3
|
+
|
|
4
|
+
export type BarView = {
|
|
5
|
+
/** Open trigger value; '' = every surface closed. */
|
|
6
|
+
readonly value: string
|
|
7
|
+
/** Stored roving tab-stop value; '' = defer to DOM order. */
|
|
8
|
+
readonly focused: string
|
|
9
|
+
/** Reads controlled/uncontrolled value for this render; call once per render pass. */
|
|
10
|
+
sync(value: string | undefined): string
|
|
11
|
+
/** Opens a value, or closes everything with ''. */
|
|
12
|
+
setValue(next: string, event?: Event): void
|
|
13
|
+
close(event?: Event): void
|
|
14
|
+
/** Follow policy: while the bar is open, moves the open value to the entered trigger; inert while closed. */
|
|
15
|
+
follow(next: string, event?: Event): void
|
|
16
|
+
/** Focus policy: adopts the roving tab stop, and follows while the bar is open. */
|
|
17
|
+
focus(next: string, event?: Event): void
|
|
18
|
+
/** Seeds the tab stop without invalidating (registration repair). */
|
|
19
|
+
adopt(next: string): void
|
|
20
|
+
/** Single tab stop: true when the value holds it, resolved against the live row. */
|
|
21
|
+
isTabbable(value: string): boolean
|
|
22
|
+
/** Trigger-row keydown skeleton: roving arrows, typeahead, and Escape gated on open. */
|
|
23
|
+
handle(event: KeyboardEvent): boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type BarOptions = {
|
|
27
|
+
/** Live enabled trigger row, in DOM order. */
|
|
28
|
+
triggers: () => HTMLElement[]
|
|
29
|
+
/** Bar value of one trigger. Default: dataset.value. */
|
|
30
|
+
value?: (trigger: HTMLElement) => string
|
|
31
|
+
/** Value before the first sync; '' = closed. */
|
|
32
|
+
initialValue?: string
|
|
33
|
+
/** Blocks opening while true. */
|
|
34
|
+
disabled?: () => boolean
|
|
35
|
+
/** Wrap arrow navigation at the ends. Default: true. */
|
|
36
|
+
loop?: () => boolean
|
|
37
|
+
/** Called whenever the open value changes. */
|
|
38
|
+
onValueChange?: (value: string, event?: Event) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The shared root machine for horizontal trigger bars (menubar,
|
|
43
|
+
* navigation-menu): one controlled open value ('' = closed), roving over a
|
|
44
|
+
* live trigger row with typeahead, and the open-follows-focus/hover policy.
|
|
45
|
+
*
|
|
46
|
+
* Movement (arrows, typeahead) only focuses the target trigger; the follow
|
|
47
|
+
* policy runs exactly once through the trigger's own focus handler, so
|
|
48
|
+
* controlled consumers see a single onValueChange per move.
|
|
49
|
+
*/
|
|
50
|
+
export const bar = (host: Host, opts: BarOptions): BarView => {
|
|
51
|
+
const read = opts.value ?? ((trigger: HTMLElement) => trigger.dataset.value ?? '')
|
|
52
|
+
const state = controlled<string>(host, {
|
|
53
|
+
fallback: opts.initialValue ?? '',
|
|
54
|
+
onChange: opts.onValueChange,
|
|
55
|
+
})
|
|
56
|
+
let focused = ''
|
|
57
|
+
|
|
58
|
+
// The tab stop resolves against the live row: a stale, removed, or
|
|
59
|
+
// disabled entry falls back to the first trigger in DOM order.
|
|
60
|
+
const stop = () => {
|
|
61
|
+
if (!dom(host)) return ''
|
|
62
|
+
const row = opts.triggers()
|
|
63
|
+
if (!row.length) return ''
|
|
64
|
+
return focused && row.some(trigger => read(trigger) === focused) ? focused : read(row[0])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const setValue = (next: string, event?: Event) => {
|
|
68
|
+
if (next && opts.disabled?.()) return
|
|
69
|
+
if (next === state.value) return
|
|
70
|
+
if (next) focused = next
|
|
71
|
+
state.set(next, event)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let following = false
|
|
75
|
+
|
|
76
|
+
const follow = (next: string, event?: Event) => {
|
|
77
|
+
if (following) return
|
|
78
|
+
if (!state.value || !next || next === state.value) return
|
|
79
|
+
|
|
80
|
+
following = true
|
|
81
|
+
try {
|
|
82
|
+
// When focus sits inside the surface about to close (a DOM
|
|
83
|
+
// descendant of the bar that is not a row trigger), move it onto
|
|
84
|
+
// the followed trigger first: hiding a native popover restores
|
|
85
|
+
// focus to its invoker, which would re-run the follow policy and
|
|
86
|
+
// bounce the open value straight back.
|
|
87
|
+
if (dom(host)) {
|
|
88
|
+
const row = opts.triggers()
|
|
89
|
+
const target = row.find(trigger => read(trigger) === next)
|
|
90
|
+
const active = document.activeElement as HTMLElement | null
|
|
91
|
+
if (target && active && active !== target && (host as unknown as HTMLElement).contains(active) && !row.includes(active)) {
|
|
92
|
+
target.focus()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
setValue(next, event)
|
|
96
|
+
} finally {
|
|
97
|
+
following = false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const nav = roving(host, {
|
|
102
|
+
items: opts.triggers,
|
|
103
|
+
orientation: () => 'horizontal',
|
|
104
|
+
loop: () => opts.loop?.() ?? true,
|
|
105
|
+
// Focus only: the focus event runs the follow policy once.
|
|
106
|
+
onMove: target => target.focus(),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const ta = typeahead(host, {
|
|
110
|
+
items: opts.triggers,
|
|
111
|
+
onMatch: (target, event) => {
|
|
112
|
+
event.preventDefault()
|
|
113
|
+
target.focus()
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
get value() {
|
|
119
|
+
return state.value
|
|
120
|
+
},
|
|
121
|
+
get focused() {
|
|
122
|
+
return focused
|
|
123
|
+
},
|
|
124
|
+
sync: value => state.sync(value),
|
|
125
|
+
setValue,
|
|
126
|
+
close: event => setValue('', event),
|
|
127
|
+
follow,
|
|
128
|
+
focus(next, event) {
|
|
129
|
+
if (state.value && next && next !== state.value) {
|
|
130
|
+
follow(next, event)
|
|
131
|
+
} else if (focused !== next) {
|
|
132
|
+
focused = next
|
|
133
|
+
host.next()
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
adopt(next) {
|
|
137
|
+
focused = next
|
|
138
|
+
},
|
|
139
|
+
isTabbable(value) {
|
|
140
|
+
const current = stop()
|
|
141
|
+
return !current || current === value
|
|
142
|
+
},
|
|
143
|
+
handle(event) {
|
|
144
|
+
if (nav.handle(event)) return true
|
|
145
|
+
|
|
146
|
+
if (event.key === 'Escape') {
|
|
147
|
+
if (!state.value) return false
|
|
148
|
+
event.preventDefault()
|
|
149
|
+
setValue('', event)
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return ta.handle(event)
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|