@botiverse/raft-ui 0.10.4 → 0.10.6
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/CHANGELOG.md +17 -0
- package/dist/components/combobox-select/combobox-select.css +115 -0
- package/dist/{dialog-CDPAT3DH.mjs → drawer-yZD16Ybl.mjs} +442 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +3 -447
- package/dist/style.css +130 -0
- package/dist/styles.css +20 -0
- package/dist/wip.d.mts +192 -3
- package/dist/wip.mjs +904 -8
- package/package.json +1 -1
- package/skills/raft-ui-critique/SKILL.md +3 -3
package/dist/wip.mjs
CHANGED
|
@@ -1,8 +1,57 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Check, ChevronDown, LoaderCircle, Plus, X } from "lucide-react";
|
|
3
|
-
import { useCallback, useId, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { D as DialogTitle, I as useThemeScopeProps, L as useTheme, M as defaultNativeButton, O as DialogTrigger, a as DrawerContent, c as DrawerHeader, d as DrawerTitle, f as DrawerTrigger, g as Dialog, i as DrawerClose, k as Button$1, n as DrawerActions, r as DrawerBody, s as DrawerFooter, t as Drawer, v as DialogClose, y as DialogContent, z as tv } from "./drawer-yZD16Ybl.mjs";
|
|
2
|
+
import { Check, ChevronDown, LoaderCircle, Plus, Search, X } from "lucide-react";
|
|
3
|
+
import { createContext, use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { Button } from "@base-ui/react/button";
|
|
6
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
7
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
5
8
|
import { Combobox } from "@base-ui/react/combobox";
|
|
9
|
+
import { Select } from "@base-ui/react/select";
|
|
10
|
+
//#region src/lib/phone-media.ts
|
|
11
|
+
/**
|
|
12
|
+
* A phone: a touch screen narrower than the desktop layout. The complement, on
|
|
13
|
+
* both axes, of the desktop query `(width >= 48rem) and (pointer: fine)`
|
|
14
|
+
* (design/foundations/spacing.md): a narrow window with a mouse keeps the
|
|
15
|
+
* desktop behaviour, and so does a tablet used by touch, which is wide.
|
|
16
|
+
*/
|
|
17
|
+
const PHONE_MEDIA_QUERY = "(width < 48rem) and (pointer: coarse)";
|
|
18
|
+
function subscribe(onStoreChange) {
|
|
19
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => void 0;
|
|
20
|
+
const media = window.matchMedia(PHONE_MEDIA_QUERY);
|
|
21
|
+
media.addEventListener("change", onStoreChange);
|
|
22
|
+
return () => media.removeEventListener("change", onStoreChange);
|
|
23
|
+
}
|
|
24
|
+
function getSnapshot() {
|
|
25
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(width < 48rem) and (pointer: coarse)").matches;
|
|
26
|
+
}
|
|
27
|
+
/** Whether `PHONE_MEDIA_QUERY` matches. `false` on the server and before hydration. */
|
|
28
|
+
function usePhoneMedia() {
|
|
29
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
|
30
|
+
}
|
|
31
|
+
function resolveComboboxSelectMode({ phone, optionCount, sheetThreshold = 10, create = false, remote = false }) {
|
|
32
|
+
if (!phone) return "field";
|
|
33
|
+
if (create || remote) return "sheet";
|
|
34
|
+
return optionCount > sheetThreshold ? "sheet" : "list";
|
|
35
|
+
}
|
|
36
|
+
/** Options people can pick, flat or grouped: disabled options don't count. */
|
|
37
|
+
function countSelectableOptions(groups) {
|
|
38
|
+
let count = 0;
|
|
39
|
+
for (const group of groups) for (const item of group.items) if (!item.disabled) count += 1;
|
|
40
|
+
return count;
|
|
41
|
+
}
|
|
42
|
+
/** The tallest the anchored list gets (`.r-combobox-select__list`, 16rem). */
|
|
43
|
+
const LIST_MAX_HEIGHT = 256;
|
|
44
|
+
/**
|
|
45
|
+
* The side the anchored list opens on, chosen once when it opens so it never
|
|
46
|
+
* flips while it is open (a phone's visual viewport keeps changing under it).
|
|
47
|
+
* Below, unless the room below is short of the list and there is more above.
|
|
48
|
+
*/
|
|
49
|
+
function preferredListSide(anchor, viewport, listHeight) {
|
|
50
|
+
const below = viewport.top + viewport.height - anchor.bottom;
|
|
51
|
+
const above = anchor.top - viewport.top;
|
|
52
|
+
return below >= Math.min(listHeight, LIST_MAX_HEIGHT) || below >= above ? "bottom" : "top";
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
6
55
|
//#region src/components/combobox-select/combobox-select.options.ts
|
|
7
56
|
function isOptionGroup(option) {
|
|
8
57
|
return typeof option === "object" && option !== null && "type" in option && option.type === "group" && "children" in option && Array.isArray(option.children);
|
|
@@ -30,7 +79,10 @@ const defaultMessages = {
|
|
|
30
79
|
selected: "Selected options",
|
|
31
80
|
chipDescription: "Press Backspace or Delete to remove",
|
|
32
81
|
inputDescription: "From the start of the input, press Left Arrow to focus selected options",
|
|
33
|
-
remove: (label) => `Remove ${label}
|
|
82
|
+
remove: (label) => `Remove ${label}`,
|
|
83
|
+
search: "Search",
|
|
84
|
+
done: "Done",
|
|
85
|
+
close: "Close"
|
|
34
86
|
};
|
|
35
87
|
const defaultRenderCreateLabel = (query) => `Create “${query}”`;
|
|
36
88
|
function recordLabel(record, renderLabel) {
|
|
@@ -69,6 +121,74 @@ function renderSelectedChip({ record, index, tagLimit, strings, renderLabel, ren
|
|
|
69
121
|
})]
|
|
70
122
|
}, optionKey(record.value));
|
|
71
123
|
}
|
|
124
|
+
/** An option of the phone's anchored list: the same content as the field's list. */
|
|
125
|
+
function renderSelectOption({ record, selected, loading, renderLabel, renderOption }) {
|
|
126
|
+
const content = record.option === void 0 ? record.label : renderOption?.(record.option, { selected }) ?? recordLabel(record, renderLabel);
|
|
127
|
+
return /* @__PURE__ */ jsxs(Select.Item, {
|
|
128
|
+
value: record,
|
|
129
|
+
label: record.label,
|
|
130
|
+
disabled: record.disabled || loading,
|
|
131
|
+
className: "r-combobox-select__option",
|
|
132
|
+
children: [/* @__PURE__ */ jsx(Select.ItemText, {
|
|
133
|
+
className: "r-combobox-select__text",
|
|
134
|
+
children: content
|
|
135
|
+
}), /* @__PURE__ */ jsx(Select.ItemIndicator, {
|
|
136
|
+
className: "r-combobox-select__indicator",
|
|
137
|
+
children: /* @__PURE__ */ jsx(Check, { "aria-hidden": true })
|
|
138
|
+
})]
|
|
139
|
+
}, optionKey(record.value));
|
|
140
|
+
}
|
|
141
|
+
/** Change details for the phone's own changes: the clear button outside any Base UI root, the sheet's search emptied as it closes. */
|
|
142
|
+
function syntheticDetails(reason, event) {
|
|
143
|
+
let canceled = false;
|
|
144
|
+
return {
|
|
145
|
+
reason,
|
|
146
|
+
event,
|
|
147
|
+
trigger: void 0,
|
|
148
|
+
cancel() {
|
|
149
|
+
canceled = true;
|
|
150
|
+
},
|
|
151
|
+
allowPropagation() {},
|
|
152
|
+
get isCanceled() {
|
|
153
|
+
return canceled;
|
|
154
|
+
},
|
|
155
|
+
get isPropagationAllowed() {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function assignRef(ref, value) {
|
|
161
|
+
if (typeof ref === "function") ref(value);
|
|
162
|
+
else if (ref) ref.current = value;
|
|
163
|
+
}
|
|
164
|
+
/** The text that names a field: its `aria-labelledby` elements, else its `<label>`. */
|
|
165
|
+
function readFieldLabel(element, labelledBy) {
|
|
166
|
+
if (!element) return "";
|
|
167
|
+
const doc = element.ownerDocument;
|
|
168
|
+
const fromIds = (labelledBy ?? "").split(/\s+/).filter(Boolean).map((labelId) => doc.getElementById(labelId)?.textContent?.trim() ?? "").filter(Boolean).join(" ");
|
|
169
|
+
if (fromIds) return fromIds;
|
|
170
|
+
return [...element.labels ?? []].map((label) => label.textContent?.trim() ?? "").filter(Boolean).join(" ");
|
|
171
|
+
}
|
|
172
|
+
/** The phone's anchored list keeps the side it opened on: it never flips while open. */
|
|
173
|
+
const PHONE_LIST_COLLISION = {
|
|
174
|
+
side: "none",
|
|
175
|
+
align: "shift",
|
|
176
|
+
fallbackAxisSide: "none"
|
|
177
|
+
};
|
|
178
|
+
/** The side the phone's anchored list opens on, from the room around the field. */
|
|
179
|
+
function measureListSide(anchor, groups) {
|
|
180
|
+
const view = anchor?.ownerDocument.defaultView;
|
|
181
|
+
if (!anchor || !view) return "bottom";
|
|
182
|
+
const visual = view.visualViewport;
|
|
183
|
+
const rows = groups.reduce((count, group) => count + group.items.length + (group.label == null ? 0 : 1), 0);
|
|
184
|
+
return preferredListSide(anchor.getBoundingClientRect(), visual ? {
|
|
185
|
+
top: visual.offsetTop,
|
|
186
|
+
height: visual.height
|
|
187
|
+
} : {
|
|
188
|
+
top: 0,
|
|
189
|
+
height: view.innerHeight
|
|
190
|
+
}, rows * 36 + 12);
|
|
191
|
+
}
|
|
72
192
|
/** Options-driven composition. The stable compound Combobox API stays unchanged. */
|
|
73
193
|
function isItemEqualToValue(a, b) {
|
|
74
194
|
return a.createQuery === b.createQuery && a.value === b.value;
|
|
@@ -79,7 +199,7 @@ function itemToStringLabel(record) {
|
|
|
79
199
|
function itemToStringValue(record) {
|
|
80
200
|
return String(record.value);
|
|
81
201
|
}
|
|
82
|
-
function ComboboxSelect({ options, multiple, value, defaultValue, onValueChange, labelField = "label", valueField = "value", disabledField = "disabled", fallbackOption, filterable = true, filter, remote = false, onSearch, loading = false, create = false, creatable, onCreate, clearable = false, showArrow = true, clearFilterAfterSelect = true, onClear, maxTagCount, size = "md", status, placeholder = "Select an option", renderLabel, renderOption, renderTag, renderCreateLabel = defaultRenderCreateLabel, emptyContent = "No options found", loadingContent = "Loading…", messages, inputProps, portalProps, positionerProps, popupProps, className, style, id: idProp, ref, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, inputValue, defaultInputValue, onInputValueChange, disabled, readOnly, autoHighlight = true, ...rootProps }) {
|
|
202
|
+
function ComboboxSelect({ options, multiple, value, defaultValue, onValueChange, labelField = "label", valueField = "value", disabledField = "disabled", fallbackOption, filterable = true, filter, remote = false, onSearch, loading = false, create = false, creatable, onCreate, clearable = false, showArrow = true, clearFilterAfterSelect = true, onClear, maxTagCount, size = "md", status, placeholder = "Select an option", renderLabel, renderOption, renderTag, renderCreateLabel = defaultRenderCreateLabel, emptyContent = "No options found", loadingContent = "Loading…", messages, sheetThreshold = 10, sheetTitle, inputProps, portalProps, positionerProps, popupProps, className, style, id: idProp, ref, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, inputValue, defaultInputValue, onInputValueChange, disabled, readOnly, autoHighlight = true, ...rootProps }) {
|
|
83
203
|
const generatedId = useId();
|
|
84
204
|
const id = idProp ?? generatedId;
|
|
85
205
|
const { resolvedMode } = useTheme();
|
|
@@ -348,6 +468,291 @@ function ComboboxSelect({ options, multiple, value, defaultValue, onValueChange,
|
|
|
348
468
|
]
|
|
349
469
|
});
|
|
350
470
|
const valueChange = handleValueChange;
|
|
471
|
+
const mode = resolveComboboxSelectMode({
|
|
472
|
+
phone: usePhoneMedia(),
|
|
473
|
+
optionCount: countSelectableOptions(normalized.groups),
|
|
474
|
+
sheetThreshold,
|
|
475
|
+
create: Boolean(create || creatable),
|
|
476
|
+
remote
|
|
477
|
+
});
|
|
478
|
+
const fieldRef = useRef(null);
|
|
479
|
+
const triggerRef = useRef(null);
|
|
480
|
+
const searchInputRef = useRef(null);
|
|
481
|
+
const [uncontrolledPhoneOpen, setUncontrolledPhoneOpen] = useState(Boolean(rootProps.defaultOpen));
|
|
482
|
+
const phoneOpen = rootProps.open ?? uncontrolledPhoneOpen;
|
|
483
|
+
const [listSide, setListSide] = useState("bottom");
|
|
484
|
+
const [fieldLabel, setFieldLabel] = useState("");
|
|
485
|
+
const sheetNamed = sheetTitle != null || Boolean(inputLabel);
|
|
486
|
+
useLayoutEffect(() => {
|
|
487
|
+
if (mode !== "sheet" || !phoneOpen || sheetNamed) return;
|
|
488
|
+
setFieldLabel(readFieldLabel(triggerRef.current, inputLabelledBy));
|
|
489
|
+
}, [
|
|
490
|
+
mode,
|
|
491
|
+
phoneOpen,
|
|
492
|
+
sheetNamed,
|
|
493
|
+
inputLabelledBy
|
|
494
|
+
]);
|
|
495
|
+
const setSearchInput = useCallback((element) => {
|
|
496
|
+
searchInputRef.current = element;
|
|
497
|
+
assignRef(inputRef, element);
|
|
498
|
+
}, [inputRef]);
|
|
499
|
+
const onOpenChangeProp = rootProps.onOpenChange;
|
|
500
|
+
const openProp = rootProps.open;
|
|
501
|
+
const groups = normalized.groups;
|
|
502
|
+
const handlePhoneOpenChange = useCallback((next, details) => {
|
|
503
|
+
onOpenChangeProp?.(next, details);
|
|
504
|
+
if (details.isCanceled) return;
|
|
505
|
+
if (next && mode === "list") setListSide(measureListSide(fieldRef.current, groups));
|
|
506
|
+
if (!next && mode === "sheet") handleInputValueChange("", syntheticDetails("input-clear", details.event));
|
|
507
|
+
if (openProp === void 0) setUncontrolledPhoneOpen(next);
|
|
508
|
+
}, [
|
|
509
|
+
onOpenChangeProp,
|
|
510
|
+
openProp,
|
|
511
|
+
mode,
|
|
512
|
+
groups,
|
|
513
|
+
handleInputValueChange
|
|
514
|
+
]);
|
|
515
|
+
const handleSheetValueChange = useCallback((next, details) => {
|
|
516
|
+
handleValueChange(next, details);
|
|
517
|
+
if (!multiple && !details.isCanceled) handlePhoneOpenChange(false, details);
|
|
518
|
+
}, [
|
|
519
|
+
handleValueChange,
|
|
520
|
+
multiple,
|
|
521
|
+
handlePhoneOpenChange
|
|
522
|
+
]);
|
|
523
|
+
const handlePhoneClear = useCallback((event) => {
|
|
524
|
+
handleValueChange(multiple ? [] : null, syntheticDetails("clear-press", event.nativeEvent));
|
|
525
|
+
}, [handleValueChange, multiple]);
|
|
526
|
+
const focusSearchInput = useCallback(() => searchInputRef.current, []);
|
|
527
|
+
if (mode !== "field") {
|
|
528
|
+
const placeholderText = /* @__PURE__ */ jsx("span", {
|
|
529
|
+
className: "r-combobox-select__placeholder",
|
|
530
|
+
children: placeholder
|
|
531
|
+
});
|
|
532
|
+
const phoneValue = multiple ? selected.length ? /* @__PURE__ */ jsxs("span", {
|
|
533
|
+
className: "r-combobox-select__chips",
|
|
534
|
+
children: [selected.slice(0, tagLimit).map((record) => /* @__PURE__ */ jsx("span", {
|
|
535
|
+
className: "r-combobox-select__chip",
|
|
536
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
537
|
+
className: "r-combobox-select__text",
|
|
538
|
+
children: record.option === void 0 ? record.label : renderTag?.(record.option) ?? recordLabel(record, renderLabel)
|
|
539
|
+
})
|
|
540
|
+
}, optionKey(record.value))), overflow]
|
|
541
|
+
}) : placeholderText : selected[0] ? /* @__PURE__ */ jsx("span", {
|
|
542
|
+
className: "r-combobox-select__value",
|
|
543
|
+
children: selected[0].label
|
|
544
|
+
}) : placeholderText;
|
|
545
|
+
const phoneClear = clearable && selected.length > 0 && !disabled && !readOnly ? /* @__PURE__ */ jsx("button", {
|
|
546
|
+
type: "button",
|
|
547
|
+
className: "r-combobox-select__action r-combobox-select__clear",
|
|
548
|
+
"aria-label": strings.clear,
|
|
549
|
+
onClick: handlePhoneClear,
|
|
550
|
+
children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
|
|
551
|
+
}) : null;
|
|
552
|
+
const phoneActions = /* @__PURE__ */ jsxs("span", {
|
|
553
|
+
className: "r-combobox-select__actions",
|
|
554
|
+
children: [
|
|
555
|
+
loadingIndicator,
|
|
556
|
+
phoneClear,
|
|
557
|
+
showArrow ? /* @__PURE__ */ jsx("span", {
|
|
558
|
+
className: "r-combobox-select__action r-combobox-select__arrow",
|
|
559
|
+
"aria-hidden": true,
|
|
560
|
+
children: /* @__PURE__ */ jsx(ChevronDown, {})
|
|
561
|
+
}) : null
|
|
562
|
+
]
|
|
563
|
+
});
|
|
564
|
+
const phoneControlClassName = [
|
|
565
|
+
css,
|
|
566
|
+
"r-combobox-select__control r-combobox-select__control--trigger has-focus-visible:shadow-focus",
|
|
567
|
+
className
|
|
568
|
+
].filter(Boolean).join(" ");
|
|
569
|
+
const triggerProps = {
|
|
570
|
+
ref: triggerRef,
|
|
571
|
+
id,
|
|
572
|
+
"data-slot": "combobox-select-trigger",
|
|
573
|
+
className: "r-combobox-select__trigger",
|
|
574
|
+
"aria-label": inputLabel,
|
|
575
|
+
"aria-labelledby": inputLabelledBy,
|
|
576
|
+
"aria-describedby": inputDescribedBy,
|
|
577
|
+
"aria-invalid": inputInvalid
|
|
578
|
+
};
|
|
579
|
+
const field = (trigger) => /* @__PURE__ */ jsxs("div", {
|
|
580
|
+
ref: fieldRef,
|
|
581
|
+
"data-slot": "combobox-select",
|
|
582
|
+
"data-status": status,
|
|
583
|
+
"data-disabled": disabled || void 0,
|
|
584
|
+
"data-readonly": readOnly || void 0,
|
|
585
|
+
"data-popup-open": phoneOpen || void 0,
|
|
586
|
+
className: phoneControlClassName,
|
|
587
|
+
style,
|
|
588
|
+
children: [trigger, phoneActions]
|
|
589
|
+
});
|
|
590
|
+
if (mode === "list") {
|
|
591
|
+
const anchoredPopupClassName = `${popupClassName} r-combobox-select__popup--anchored`;
|
|
592
|
+
const hasOptions = normalized.groups.some((group) => group.items.length > 0);
|
|
593
|
+
return /* @__PURE__ */ jsxs(Select.Root, {
|
|
594
|
+
multiple,
|
|
595
|
+
value: controlValue,
|
|
596
|
+
onValueChange: valueChange,
|
|
597
|
+
open: phoneOpen,
|
|
598
|
+
onOpenChange: handlePhoneOpenChange,
|
|
599
|
+
onOpenChangeComplete: rootProps.onOpenChangeComplete,
|
|
600
|
+
modal: false,
|
|
601
|
+
name: rootProps.name,
|
|
602
|
+
form: rootProps.form,
|
|
603
|
+
required: rootProps.required,
|
|
604
|
+
autoComplete: rootProps.autoComplete,
|
|
605
|
+
disabled,
|
|
606
|
+
readOnly,
|
|
607
|
+
itemToStringLabel,
|
|
608
|
+
itemToStringValue,
|
|
609
|
+
isItemEqualToValue,
|
|
610
|
+
children: [field(/* @__PURE__ */ jsx(Select.Trigger, {
|
|
611
|
+
...triggerProps,
|
|
612
|
+
children: phoneValue
|
|
613
|
+
})), /* @__PURE__ */ jsx(Select.Portal, {
|
|
614
|
+
...scopedPortalProps,
|
|
615
|
+
children: /* @__PURE__ */ jsx(Select.Positioner, {
|
|
616
|
+
anchor: fieldRef,
|
|
617
|
+
side: listSide,
|
|
618
|
+
align: "start",
|
|
619
|
+
sideOffset: 4,
|
|
620
|
+
alignItemWithTrigger: false,
|
|
621
|
+
collisionAvoidance: PHONE_LIST_COLLISION,
|
|
622
|
+
className: positionerClassName,
|
|
623
|
+
children: /* @__PURE__ */ jsxs(Select.Popup, {
|
|
624
|
+
"data-slot": "combobox-select-popup",
|
|
625
|
+
className: anchoredPopupClassName,
|
|
626
|
+
children: [
|
|
627
|
+
loading ? /* @__PURE__ */ jsx("div", {
|
|
628
|
+
role: "status",
|
|
629
|
+
className: "r-combobox-select__message",
|
|
630
|
+
children: loadingContent
|
|
631
|
+
}) : null,
|
|
632
|
+
/* @__PURE__ */ jsx(Select.List, {
|
|
633
|
+
className: "r-combobox-select__list",
|
|
634
|
+
"aria-busy": loading,
|
|
635
|
+
children: normalized.groups.map((group) => /* @__PURE__ */ jsxs(Select.Group, { children: [group.label == null ? null : /* @__PURE__ */ jsx(Select.GroupLabel, {
|
|
636
|
+
className: "r-combobox-select__group-label",
|
|
637
|
+
children: group.label
|
|
638
|
+
}), group.items.map((record) => renderSelectOption({
|
|
639
|
+
record,
|
|
640
|
+
selected: selected.some((item) => item.value === record.value),
|
|
641
|
+
loading,
|
|
642
|
+
renderLabel,
|
|
643
|
+
renderOption
|
|
644
|
+
}))] }, group.key))
|
|
645
|
+
}),
|
|
646
|
+
!hasOptions ? emptyMessage : null
|
|
647
|
+
]
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
})]
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
const titleId = `${id}-sheet-title`;
|
|
654
|
+
const sheetCss = `r-combobox-select r-combobox-select--md${resolvedMode === "dark" ? " r-combobox-select--dark" : ""} r-combobox-select__sheet`;
|
|
655
|
+
const hiddenInputs = rootProps.name ? selectedKeys.map((key) => /* @__PURE__ */ jsx("input", {
|
|
656
|
+
type: "hidden",
|
|
657
|
+
name: rootProps.name,
|
|
658
|
+
form: rootProps.form,
|
|
659
|
+
value: String(key),
|
|
660
|
+
disabled
|
|
661
|
+
}, optionKey(key))) : null;
|
|
662
|
+
return /* @__PURE__ */ jsxs(Drawer, {
|
|
663
|
+
open: phoneOpen,
|
|
664
|
+
onOpenChange: handlePhoneOpenChange,
|
|
665
|
+
onOpenChangeComplete: rootProps.onOpenChangeComplete,
|
|
666
|
+
children: [
|
|
667
|
+
field(/* @__PURE__ */ jsx(DrawerTrigger, {
|
|
668
|
+
...triggerProps,
|
|
669
|
+
disabled,
|
|
670
|
+
role: "combobox",
|
|
671
|
+
"aria-haspopup": "dialog",
|
|
672
|
+
"aria-expanded": phoneOpen,
|
|
673
|
+
"aria-readonly": readOnly || void 0,
|
|
674
|
+
"aria-required": rootProps.required || void 0,
|
|
675
|
+
children: phoneValue
|
|
676
|
+
})),
|
|
677
|
+
hiddenInputs,
|
|
678
|
+
/* @__PURE__ */ jsx(DrawerContent, {
|
|
679
|
+
portalProps,
|
|
680
|
+
"data-slot": "combobox-select-sheet",
|
|
681
|
+
className: "r-combobox-select__sheet-popup",
|
|
682
|
+
initialFocus: create || creatable ? focusSearchInput : void 0,
|
|
683
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
684
|
+
className: sheetCss,
|
|
685
|
+
children: [
|
|
686
|
+
/* @__PURE__ */ jsxs(DrawerHeader, {
|
|
687
|
+
className: "r-combobox-select__sheet-header",
|
|
688
|
+
children: [/* @__PURE__ */ jsx(DrawerTitle, {
|
|
689
|
+
id: titleId,
|
|
690
|
+
className: "r-combobox-select__sheet-title",
|
|
691
|
+
children: sheetTitle ?? inputLabel ?? (fieldLabel || placeholder)
|
|
692
|
+
}), /* @__PURE__ */ jsx(DrawerClose, { "aria-label": strings.close })]
|
|
693
|
+
}),
|
|
694
|
+
/* @__PURE__ */ jsxs(Combobox.Root, {
|
|
695
|
+
inline: true,
|
|
696
|
+
multiple,
|
|
697
|
+
disabled,
|
|
698
|
+
readOnly,
|
|
699
|
+
items,
|
|
700
|
+
value: controlValue,
|
|
701
|
+
onValueChange: handleSheetValueChange,
|
|
702
|
+
inputValue,
|
|
703
|
+
defaultInputValue,
|
|
704
|
+
onInputValueChange: handleInputValueChange,
|
|
705
|
+
filter: filterOptions,
|
|
706
|
+
itemToStringLabel,
|
|
707
|
+
itemToStringValue,
|
|
708
|
+
isItemEqualToValue,
|
|
709
|
+
autoHighlight,
|
|
710
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
711
|
+
className: "r-combobox-select__sheet-search",
|
|
712
|
+
children: /* @__PURE__ */ jsxs(Combobox.InputGroup, {
|
|
713
|
+
className: "r-combobox-select__control has-focus-visible:shadow-focus",
|
|
714
|
+
children: [
|
|
715
|
+
/* @__PURE__ */ jsx(Search, {
|
|
716
|
+
className: "r-combobox-select__search-icon",
|
|
717
|
+
"aria-hidden": true
|
|
718
|
+
}),
|
|
719
|
+
/* @__PURE__ */ jsx(Combobox.Input, {
|
|
720
|
+
...inputProps,
|
|
721
|
+
ref: setSearchInput,
|
|
722
|
+
id: `${id}-search`,
|
|
723
|
+
"data-slot": "combobox-select-input",
|
|
724
|
+
className: inputClassName,
|
|
725
|
+
"aria-labelledby": titleId,
|
|
726
|
+
placeholder: strings.search,
|
|
727
|
+
readOnly: inputReadOnly,
|
|
728
|
+
enterKeyHint: "search"
|
|
729
|
+
}),
|
|
730
|
+
loadingIndicator
|
|
731
|
+
]
|
|
732
|
+
})
|
|
733
|
+
}), /* @__PURE__ */ jsxs(DrawerBody, {
|
|
734
|
+
className: "r-combobox-select__sheet-body",
|
|
735
|
+
children: [
|
|
736
|
+
/* @__PURE__ */ jsx(Combobox.Status, { children: loadingMessage }),
|
|
737
|
+
/* @__PURE__ */ jsx(Combobox.List, {
|
|
738
|
+
className: "r-combobox-select__list",
|
|
739
|
+
"aria-busy": loading,
|
|
740
|
+
children: renderGroup
|
|
741
|
+
}),
|
|
742
|
+
/* @__PURE__ */ jsx(Combobox.Empty, { children: emptyMessage })
|
|
743
|
+
]
|
|
744
|
+
})]
|
|
745
|
+
}),
|
|
746
|
+
multiple ? /* @__PURE__ */ jsx(DrawerFooter, { children: /* @__PURE__ */ jsx(DrawerActions, { children: /* @__PURE__ */ jsx(DrawerClose, {
|
|
747
|
+
render: /* @__PURE__ */ jsx(Button$1, { size: "lg" }),
|
|
748
|
+
children: strings.done
|
|
749
|
+
}) }) }) : null
|
|
750
|
+
]
|
|
751
|
+
})
|
|
752
|
+
})
|
|
753
|
+
]
|
|
754
|
+
});
|
|
755
|
+
}
|
|
351
756
|
return /* @__PURE__ */ jsxs(Combobox.Root, {
|
|
352
757
|
...rootProps,
|
|
353
758
|
id,
|
|
@@ -498,7 +903,7 @@ function AnnouncementDialogFooter({ className, ...props }) {
|
|
|
498
903
|
}
|
|
499
904
|
function AnnouncementDialogAction({ className, ...props }) {
|
|
500
905
|
const { action } = announcement();
|
|
501
|
-
return /* @__PURE__ */ jsx(Button, {
|
|
906
|
+
return /* @__PURE__ */ jsx(Button$1, {
|
|
502
907
|
variant: "accent",
|
|
503
908
|
"data-announcement-action": true,
|
|
504
909
|
className: action({ className }),
|
|
@@ -507,11 +912,502 @@ function AnnouncementDialogAction({ className, ...props }) {
|
|
|
507
912
|
}
|
|
508
913
|
function AnnouncementDialogSecondaryAction({ className, ...props }) {
|
|
509
914
|
const { secondary } = announcement();
|
|
510
|
-
return /* @__PURE__ */ jsx(Button, {
|
|
915
|
+
return /* @__PURE__ */ jsx(Button$1, {
|
|
511
916
|
variant: "ghost",
|
|
512
917
|
className: secondary({ className }),
|
|
513
918
|
...props
|
|
514
919
|
});
|
|
515
920
|
}
|
|
516
921
|
//#endregion
|
|
517
|
-
|
|
922
|
+
//#region src/components/comment/comment.recipe.ts
|
|
923
|
+
const comment = tv({
|
|
924
|
+
slots: {
|
|
925
|
+
margin: ["relative min-w-0"],
|
|
926
|
+
marginItem: [
|
|
927
|
+
"absolute inset-x-0 top-0",
|
|
928
|
+
"data-animate:transition-transform data-animate:duration-150 data-animate:ease-out",
|
|
929
|
+
"motion-reduce:transition-none",
|
|
930
|
+
"data-active:z-10"
|
|
931
|
+
],
|
|
932
|
+
card: [
|
|
933
|
+
"relative flex min-w-0 flex-col gap-2",
|
|
934
|
+
"rounded-container border border-line-hairline p-3",
|
|
935
|
+
"bg-layer-card text-foreground shadow-none outline-none",
|
|
936
|
+
"data-active:border-[1.5px] data-active:border-line-strong data-active:p-[11.5px] data-active:shadow-float"
|
|
937
|
+
],
|
|
938
|
+
cardTrigger: [
|
|
939
|
+
"absolute -inset-px z-10 cursor-pointer",
|
|
940
|
+
"rounded-container border-0 bg-transparent p-0",
|
|
941
|
+
"hover:bg-fill-pressed active:bg-fill-pressed",
|
|
942
|
+
"outline-none focus-visible:shadow-focus"
|
|
943
|
+
],
|
|
944
|
+
cardQuote: ["sr-only"],
|
|
945
|
+
cardMeta: [
|
|
946
|
+
"flex min-w-0 items-center gap-1",
|
|
947
|
+
"font-sans text-caption text-foreground-hint",
|
|
948
|
+
"[&_svg]:size-3.5 [&_svg]:shrink-0"
|
|
949
|
+
],
|
|
950
|
+
entry: ["flex min-w-0 flex-col gap-1", "[[data-slot=comment-entry]+&]:mt-1"],
|
|
951
|
+
entryHeader: ["flex min-w-0 items-center gap-1.5", "*:data-[slot=avatar]:me-0.5"],
|
|
952
|
+
entryAuthor: ["min-w-0 truncate", "text-subhead font-bold text-foreground"],
|
|
953
|
+
entryTime: ["shrink-0 whitespace-nowrap", "font-mono text-mono-meta tabular-nums text-foreground-hint"],
|
|
954
|
+
entryBody: [
|
|
955
|
+
"wrap-break-word select-text",
|
|
956
|
+
"text-callout text-foreground",
|
|
957
|
+
"selection:bg-brand-soft"
|
|
958
|
+
],
|
|
959
|
+
marker: [
|
|
960
|
+
"relative inline-flex h-6 shrink-0 items-center gap-1 align-top whitespace-nowrap",
|
|
961
|
+
"rounded-chip border border-line-hairline py-0 ps-[3px] pe-1.5",
|
|
962
|
+
"font-mono text-mono-meta font-bold tabular-nums text-foreground",
|
|
963
|
+
"bg-layer-card [--avatar-group-surface:var(--layer-card)]",
|
|
964
|
+
"cursor-pointer hover:press-overlay active:press-overlay",
|
|
965
|
+
"data-active:border-[1.5px] data-active:border-line-strong data-active:bg-brand-soft data-active:ps-[2.5px] data-active:pe-[5.5px]",
|
|
966
|
+
"data-popup-open:border-[1.5px] data-popup-open:border-line-strong data-popup-open:bg-brand-soft data-popup-open:ps-[2.5px] data-popup-open:pe-[5.5px]",
|
|
967
|
+
"data-active:[--avatar-group-surface:var(--brand-soft)] data-popup-open:[--avatar-group-surface:var(--brand-soft)]",
|
|
968
|
+
"outline-none focus-visible:shadow-focus",
|
|
969
|
+
"after:absolute after:-inset-x-1 after:-inset-y-2.5"
|
|
970
|
+
]
|
|
971
|
+
},
|
|
972
|
+
variants: { clamp: { true: { entryBody: ["line-clamp-2"] } } }
|
|
973
|
+
});
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/components/comment/comment-card.tsx
|
|
976
|
+
/**
|
|
977
|
+
* The card of one discussion: its comments, a line such as "2 replies", and
|
|
978
|
+
* the reply box when it is open. A `div`; which parts it holds (collapsed or
|
|
979
|
+
* open) is the caller's choice. To open it with a click, add
|
|
980
|
+
* `CommentCardTrigger`.
|
|
981
|
+
*/
|
|
982
|
+
function CommentCard({ className, render, active = false, ...props }) {
|
|
983
|
+
const { card } = comment();
|
|
984
|
+
const defaultProps = {
|
|
985
|
+
"data-slot": "comment-card",
|
|
986
|
+
"data-active": active ? "" : void 0,
|
|
987
|
+
"aria-current": active ? "true" : void 0,
|
|
988
|
+
className: card({ className })
|
|
989
|
+
};
|
|
990
|
+
return useRender({
|
|
991
|
+
defaultTagName: "div",
|
|
992
|
+
render,
|
|
993
|
+
props: mergeProps(defaultProps, props)
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* A button laid over the whole card, for a collapsed card that opens when it
|
|
998
|
+
* is clicked or chosen with the keyboard. It has no text of its own: give it
|
|
999
|
+
* an `aria-label` ("Comment from Martin").
|
|
1000
|
+
*/
|
|
1001
|
+
function CommentCardTrigger({ className, render, ...props }) {
|
|
1002
|
+
const { cardTrigger } = comment();
|
|
1003
|
+
const defaultProps = {
|
|
1004
|
+
"data-slot": "comment-card-trigger",
|
|
1005
|
+
type: "button",
|
|
1006
|
+
className: cardTrigger({ className })
|
|
1007
|
+
};
|
|
1008
|
+
return useRender({
|
|
1009
|
+
defaultTagName: "button",
|
|
1010
|
+
render,
|
|
1011
|
+
props: mergeProps(defaultProps, props)
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* A short line about the discussion, in the caller's words: how many replies
|
|
1016
|
+
* it has, that it is about the whole document, or that the passage it was on
|
|
1017
|
+
* can no longer be found.
|
|
1018
|
+
*/
|
|
1019
|
+
function CommentCardMeta({ className, render, ...props }) {
|
|
1020
|
+
const { cardMeta } = comment();
|
|
1021
|
+
const defaultProps = {
|
|
1022
|
+
"data-slot": "comment-card-meta",
|
|
1023
|
+
className: cardMeta({ className })
|
|
1024
|
+
};
|
|
1025
|
+
return useRender({
|
|
1026
|
+
defaultTagName: "div",
|
|
1027
|
+
render,
|
|
1028
|
+
props: mergeProps(defaultProps, props)
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* The passage the discussion is about, in the caller's words (usually the
|
|
1033
|
+
* quoted text). Hidden on screen, where the card's place beside the
|
|
1034
|
+
* highlighted passage says it; give it an `id` and point the
|
|
1035
|
+
* `aria-describedby` of `CommentCardTrigger` or `CommentMarker` at it, so a
|
|
1036
|
+
* screen reader says which passage a card is about.
|
|
1037
|
+
*/
|
|
1038
|
+
function CommentCardQuote({ className, render, ...props }) {
|
|
1039
|
+
const { cardQuote } = comment();
|
|
1040
|
+
const defaultProps = {
|
|
1041
|
+
"data-slot": "comment-card-quote",
|
|
1042
|
+
className: cardQuote({ className })
|
|
1043
|
+
};
|
|
1044
|
+
return useRender({
|
|
1045
|
+
defaultTagName: "span",
|
|
1046
|
+
render,
|
|
1047
|
+
props: mergeProps(defaultProps, props)
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
//#endregion
|
|
1051
|
+
//#region src/components/comment/comment-entry.tsx
|
|
1052
|
+
/** One comment in a discussion: its header, then its text. */
|
|
1053
|
+
function CommentEntry({ className, render, ...props }) {
|
|
1054
|
+
const { entry } = comment();
|
|
1055
|
+
const defaultProps = {
|
|
1056
|
+
"data-slot": "comment-entry",
|
|
1057
|
+
className: entry({ className })
|
|
1058
|
+
};
|
|
1059
|
+
return useRender({
|
|
1060
|
+
defaultTagName: "div",
|
|
1061
|
+
render,
|
|
1062
|
+
props: mergeProps(defaultProps, props)
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
/** One line: the author's `Avatar` (`size="xs"`), `CommentEntryAuthor`, then `CommentEntryTime`. */
|
|
1066
|
+
function CommentEntryHeader({ className, render, ...props }) {
|
|
1067
|
+
const { entryHeader } = comment();
|
|
1068
|
+
const defaultProps = {
|
|
1069
|
+
"data-slot": "comment-entry-header",
|
|
1070
|
+
className: entryHeader({ className })
|
|
1071
|
+
};
|
|
1072
|
+
return useRender({
|
|
1073
|
+
defaultTagName: "div",
|
|
1074
|
+
render,
|
|
1075
|
+
props: mergeProps(defaultProps, props)
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
/** Who wrote the comment, in bold. It truncates; the time after it doesn't. */
|
|
1079
|
+
function CommentEntryAuthor({ className, render, ...props }) {
|
|
1080
|
+
const { entryAuthor } = comment();
|
|
1081
|
+
const defaultProps = {
|
|
1082
|
+
"data-slot": "comment-entry-author",
|
|
1083
|
+
className: entryAuthor({ className })
|
|
1084
|
+
};
|
|
1085
|
+
return useRender({
|
|
1086
|
+
defaultTagName: "span",
|
|
1087
|
+
render,
|
|
1088
|
+
props: mergeProps(defaultProps, props)
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* When the comment was written, right after the author. A `<time>`: pass the
|
|
1093
|
+
* moment in `dateTime` (ISO 8601).
|
|
1094
|
+
*/
|
|
1095
|
+
function CommentEntryTime({ className, render, ...props }) {
|
|
1096
|
+
const { entryTime } = comment();
|
|
1097
|
+
const defaultProps = {
|
|
1098
|
+
"data-slot": "comment-entry-time",
|
|
1099
|
+
className: entryTime({ className })
|
|
1100
|
+
};
|
|
1101
|
+
return useRender({
|
|
1102
|
+
defaultTagName: "time",
|
|
1103
|
+
render,
|
|
1104
|
+
props: mergeProps(defaultProps, props)
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
/** What the comment says. */
|
|
1108
|
+
function CommentEntryBody({ className, render, clamp = false, ...props }) {
|
|
1109
|
+
const { entryBody } = comment({ clamp });
|
|
1110
|
+
const defaultProps = {
|
|
1111
|
+
"data-slot": "comment-entry-body",
|
|
1112
|
+
"data-clamp": clamp ? "" : void 0,
|
|
1113
|
+
className: entryBody({ className })
|
|
1114
|
+
};
|
|
1115
|
+
return useRender({
|
|
1116
|
+
defaultTagName: "div",
|
|
1117
|
+
render,
|
|
1118
|
+
props: mergeProps(defaultProps, props)
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
//#endregion
|
|
1122
|
+
//#region src/components/comment/comment-highlight.ts
|
|
1123
|
+
/** The highlight of every passage that has comments (`::highlight(raft-comment)` in styles.css). */
|
|
1124
|
+
const COMMENT_HIGHLIGHT = "raft-comment";
|
|
1125
|
+
/** The highlight of the passage of the open discussion (`::highlight(raft-comment-active)`). */
|
|
1126
|
+
const COMMENT_HIGHLIGHT_ACTIVE = "raft-comment-active";
|
|
1127
|
+
/**
|
|
1128
|
+
* Highlights the passages comments are on with the CSS Custom Highlight API,
|
|
1129
|
+
* so the document's DOM is never changed: no `<mark>` is wrapped around its
|
|
1130
|
+
* text. Register the ranges of every anchored comment under
|
|
1131
|
+
* `COMMENT_HIGHLIGHT`, and the open discussion's under
|
|
1132
|
+
* `COMMENT_HIGHLIGHT_ACTIVE`, which paints over it. Passing no ranges clears
|
|
1133
|
+
* the name. Returns a function that clears it.
|
|
1134
|
+
*
|
|
1135
|
+
* Where the browser has no `CSS.highlights`, it does nothing: the passages are
|
|
1136
|
+
* not highlighted, and the cards still say where they belong.
|
|
1137
|
+
*/
|
|
1138
|
+
function setCommentHighlights(name, ranges) {
|
|
1139
|
+
const registry = typeof CSS === "undefined" ? void 0 : CSS.highlights;
|
|
1140
|
+
if (!registry || typeof Highlight === "undefined") return () => {};
|
|
1141
|
+
const list = [...ranges];
|
|
1142
|
+
if (list.length === 0) {
|
|
1143
|
+
registry.delete(name);
|
|
1144
|
+
return () => {};
|
|
1145
|
+
}
|
|
1146
|
+
const highlight = new Highlight(...list);
|
|
1147
|
+
if (name === "raft-comment-active") highlight.priority = 1;
|
|
1148
|
+
registry.set(name, highlight);
|
|
1149
|
+
return () => {
|
|
1150
|
+
if (registry.get(name) === highlight) registry.delete(name);
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/components/comment/comment-margin-layout.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* Places the cards of a comment margin the way Google Docs does. Cards keep
|
|
1157
|
+
* document order (by their anchor's top; equal tops keep the order given) and
|
|
1158
|
+
* never overlap: each sits at its anchor, or `gap` below the card before it.
|
|
1159
|
+
* The active card is pinned to its anchor instead, the cards above it are
|
|
1160
|
+
* pushed up to make room and the cards below it pushed down. Nothing goes
|
|
1161
|
+
* above 0: when the cards above the active one don't fit between 0 and its
|
|
1162
|
+
* anchor, they stack from 0 and the active card follows them.
|
|
1163
|
+
*/
|
|
1164
|
+
function resolveCommentMarginLayout(items, { gap = 8, activeId = null } = {}) {
|
|
1165
|
+
const order = items.map((item, index) => ({
|
|
1166
|
+
item,
|
|
1167
|
+
index
|
|
1168
|
+
})).sort((a, b) => a.item.top - b.item.top || a.index - b.index).map(({ item }) => item);
|
|
1169
|
+
const tops = order.map((item) => item.top);
|
|
1170
|
+
const stackDown = (from, floor) => {
|
|
1171
|
+
let next = floor;
|
|
1172
|
+
for (let i = from; i < order.length; i += 1) {
|
|
1173
|
+
tops[i] = Math.max(order[i].top, next);
|
|
1174
|
+
next = tops[i] + order[i].height + gap;
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
const pinned = activeId == null ? -1 : order.findIndex((item) => item.id === activeId);
|
|
1178
|
+
if (pinned < 0) stackDown(0, 0);
|
|
1179
|
+
else {
|
|
1180
|
+
tops[pinned] = Math.max(order[pinned].top, 0);
|
|
1181
|
+
for (let i = pinned - 1; i >= 0; i -= 1) tops[i] = Math.min(order[i].top, tops[i + 1] - gap - order[i].height);
|
|
1182
|
+
if (pinned > 0 && tops[0] < 0) stackDown(0, 0);
|
|
1183
|
+
else stackDown(pinned + 1, tops[pinned] + order[pinned].height + gap);
|
|
1184
|
+
}
|
|
1185
|
+
const result = {};
|
|
1186
|
+
let height = 0;
|
|
1187
|
+
order.forEach((item, i) => {
|
|
1188
|
+
result[item.id] = tops[i];
|
|
1189
|
+
height = Math.max(height, tops[i] + item.height);
|
|
1190
|
+
});
|
|
1191
|
+
return {
|
|
1192
|
+
tops: result,
|
|
1193
|
+
height
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
//#endregion
|
|
1197
|
+
//#region src/components/comment/comment-margin.tsx
|
|
1198
|
+
const CommentMarginContext = createContext(null);
|
|
1199
|
+
function useCommentMargin() {
|
|
1200
|
+
const context = use(CommentMarginContext);
|
|
1201
|
+
if (!context) throw new Error("CommentMarginItem must be used within CommentMargin.");
|
|
1202
|
+
return context;
|
|
1203
|
+
}
|
|
1204
|
+
const heightOf = (element) => element.getBoundingClientRect().height;
|
|
1205
|
+
/**
|
|
1206
|
+
* The column beside a document that holds its comment cards, each level with
|
|
1207
|
+
* the passage it is about. It measures its `CommentMarginItem`s with one
|
|
1208
|
+
* `ResizeObserver`, resolves their collisions with
|
|
1209
|
+
* `resolveCommentMarginLayout`, and takes the height the lowest card needs.
|
|
1210
|
+
* Place it beside the document so that its top is the top the items' `top`
|
|
1211
|
+
* is measured from.
|
|
1212
|
+
*/
|
|
1213
|
+
function CommentMargin({ className, render, style, gap = 8, ...props }) {
|
|
1214
|
+
const { margin } = comment();
|
|
1215
|
+
const [entries, setEntries] = useState(() => /* @__PURE__ */ new Map());
|
|
1216
|
+
const [animate, setAnimate] = useState(false);
|
|
1217
|
+
const observer = useRef(null);
|
|
1218
|
+
const observed = useRef(/* @__PURE__ */ new Map());
|
|
1219
|
+
const patch = useCallback((id, next) => {
|
|
1220
|
+
setEntries((current) => {
|
|
1221
|
+
const previous = current.get(id);
|
|
1222
|
+
if (next === null) {
|
|
1223
|
+
if (!previous) return current;
|
|
1224
|
+
const without = new Map(current);
|
|
1225
|
+
without.delete(id);
|
|
1226
|
+
return without;
|
|
1227
|
+
}
|
|
1228
|
+
if (!previous && next.top === void 0) return current;
|
|
1229
|
+
const merged = {
|
|
1230
|
+
top: 0,
|
|
1231
|
+
height: 0,
|
|
1232
|
+
active: false,
|
|
1233
|
+
...previous,
|
|
1234
|
+
...next
|
|
1235
|
+
};
|
|
1236
|
+
if (previous && previous.top === merged.top && previous.height === merged.height && previous.active === merged.active) return current;
|
|
1237
|
+
const updated = new Map(current);
|
|
1238
|
+
updated.set(id, merged);
|
|
1239
|
+
return updated;
|
|
1240
|
+
});
|
|
1241
|
+
}, []);
|
|
1242
|
+
const place = useCallback((id, placement) => patch(id, placement), [patch]);
|
|
1243
|
+
const observe = useCallback((id, element) => {
|
|
1244
|
+
observer.current ??= new ResizeObserver((records) => {
|
|
1245
|
+
for (const record of records) {
|
|
1246
|
+
const target = observed.current.get(record.target);
|
|
1247
|
+
if (target) patch(target, { height: heightOf(record.target) });
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
observed.current.set(element, id);
|
|
1251
|
+
patch(id, { height: heightOf(element) });
|
|
1252
|
+
observer.current.observe(element);
|
|
1253
|
+
return () => {
|
|
1254
|
+
observer.current?.unobserve(element);
|
|
1255
|
+
observed.current.delete(element);
|
|
1256
|
+
};
|
|
1257
|
+
}, [patch]);
|
|
1258
|
+
useEffect(() => () => observer.current?.disconnect(), []);
|
|
1259
|
+
const layout = useMemo(() => {
|
|
1260
|
+
const items = [...entries].map(([id, { top, height }]) => ({
|
|
1261
|
+
id,
|
|
1262
|
+
top,
|
|
1263
|
+
height
|
|
1264
|
+
}));
|
|
1265
|
+
const activeId = [...entries].find(([, entry]) => entry.active)?.[0];
|
|
1266
|
+
return resolveCommentMarginLayout(items, {
|
|
1267
|
+
gap,
|
|
1268
|
+
activeId
|
|
1269
|
+
});
|
|
1270
|
+
}, [entries, gap]);
|
|
1271
|
+
const measured = entries.size > 0;
|
|
1272
|
+
useEffect(() => {
|
|
1273
|
+
if (!measured) return;
|
|
1274
|
+
const frame = requestAnimationFrame(() => setAnimate(true));
|
|
1275
|
+
return () => cancelAnimationFrame(frame);
|
|
1276
|
+
}, [measured]);
|
|
1277
|
+
const context = useMemo(() => ({
|
|
1278
|
+
tops: layout.tops,
|
|
1279
|
+
animate,
|
|
1280
|
+
place,
|
|
1281
|
+
observe
|
|
1282
|
+
}), [
|
|
1283
|
+
layout.tops,
|
|
1284
|
+
animate,
|
|
1285
|
+
place,
|
|
1286
|
+
observe
|
|
1287
|
+
]);
|
|
1288
|
+
const defaultProps = {
|
|
1289
|
+
"data-slot": "comment-margin",
|
|
1290
|
+
className: margin({ className }),
|
|
1291
|
+
style: {
|
|
1292
|
+
minHeight: layout.height,
|
|
1293
|
+
...style
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
const element = useRender({
|
|
1297
|
+
defaultTagName: "div",
|
|
1298
|
+
render,
|
|
1299
|
+
props: mergeProps(defaultProps, props)
|
|
1300
|
+
});
|
|
1301
|
+
return /* @__PURE__ */ jsx(CommentMarginContext, {
|
|
1302
|
+
value: context,
|
|
1303
|
+
children: element
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* One card's place in a `CommentMargin`. It sits at `top`, or lower when the
|
|
1308
|
+
* card above it is in the way; the active one stays at `top` and the others
|
|
1309
|
+
* make room. Items with the same `top` keep the order they were first
|
|
1310
|
+
* rendered in. The resolved top is `data-top`, applied as a `translateY`.
|
|
1311
|
+
*/
|
|
1312
|
+
function CommentMarginItem({ className, render, style, top, active = false, ...props }) {
|
|
1313
|
+
const { marginItem } = comment();
|
|
1314
|
+
const { tops, animate, place, observe } = useCommentMargin();
|
|
1315
|
+
const id = useId();
|
|
1316
|
+
const [element, setElement] = useState(null);
|
|
1317
|
+
useLayoutEffect(() => {
|
|
1318
|
+
place(id, {
|
|
1319
|
+
top,
|
|
1320
|
+
active
|
|
1321
|
+
});
|
|
1322
|
+
}, [
|
|
1323
|
+
place,
|
|
1324
|
+
id,
|
|
1325
|
+
top,
|
|
1326
|
+
active
|
|
1327
|
+
]);
|
|
1328
|
+
useLayoutEffect(() => element ? observe(id, element) : void 0, [
|
|
1329
|
+
observe,
|
|
1330
|
+
id,
|
|
1331
|
+
element
|
|
1332
|
+
]);
|
|
1333
|
+
useLayoutEffect(() => () => place(id, null), [place, id]);
|
|
1334
|
+
const resolved = tops[id] ?? Math.max(top, 0);
|
|
1335
|
+
const defaultProps = {
|
|
1336
|
+
"data-slot": "comment-margin-item",
|
|
1337
|
+
"data-top": String(resolved),
|
|
1338
|
+
"data-active": active ? "" : void 0,
|
|
1339
|
+
"data-animate": animate ? "" : void 0,
|
|
1340
|
+
className: marginItem({ className }),
|
|
1341
|
+
style: {
|
|
1342
|
+
...style,
|
|
1343
|
+
transform: `translateY(${resolved}px)`
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
return useRender({
|
|
1347
|
+
defaultTagName: "div",
|
|
1348
|
+
render,
|
|
1349
|
+
ref: setElement,
|
|
1350
|
+
props: mergeProps(defaultProps, props)
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/components/comment/comment-marker.tsx
|
|
1355
|
+
/**
|
|
1356
|
+
* Where there is no room for a margin: a small button beside a passage that
|
|
1357
|
+
* has comments, with the authors' avatars (an `AvatarGroup` of up to 3
|
|
1358
|
+
* `size="2xs"` avatars) and the count. It opens the discussion, in a bottom
|
|
1359
|
+
* sheet. Give it an `aria-label` that starts with the count ("2 comments").
|
|
1360
|
+
*/
|
|
1361
|
+
function CommentMarker({ className, render, nativeButton, active = false, ...props }) {
|
|
1362
|
+
const { marker } = comment();
|
|
1363
|
+
return /* @__PURE__ */ jsx(Button, {
|
|
1364
|
+
...props,
|
|
1365
|
+
render,
|
|
1366
|
+
nativeButton: nativeButton ?? defaultNativeButton(render),
|
|
1367
|
+
"data-slot": "comment-marker",
|
|
1368
|
+
"data-active": active ? "" : void 0,
|
|
1369
|
+
className: marker({ className })
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
//#endregion
|
|
1373
|
+
//#region src/lib/desktop-media.ts
|
|
1374
|
+
/**
|
|
1375
|
+
* Where the desktop layout and its metrics begin: from 768px, with a mouse or
|
|
1376
|
+
* a trackpad (design/foundations/spacing.md#hit-targets). The same query as
|
|
1377
|
+
* the desktop blocks in styles.css and foundation.css, for code that has to
|
|
1378
|
+
* decide in JavaScript what CSS decides with `md:pointer-fine:`.
|
|
1379
|
+
*/
|
|
1380
|
+
const DESKTOP_MEDIA_QUERY = "(width >= 48rem) and (pointer: fine)";
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/components/comment/use-comment-margin-fits.ts
|
|
1383
|
+
function subscribeDesktop(onChange) {
|
|
1384
|
+
if (typeof window === "undefined") return () => void 0;
|
|
1385
|
+
const media = window.matchMedia(DESKTOP_MEDIA_QUERY);
|
|
1386
|
+
media.addEventListener("change", onChange);
|
|
1387
|
+
return () => media.removeEventListener("change", onChange);
|
|
1388
|
+
}
|
|
1389
|
+
const getDesktop = () => typeof window !== "undefined" && window.matchMedia("(width >= 48rem) and (pointer: fine)").matches;
|
|
1390
|
+
/**
|
|
1391
|
+
* Whether a viewer has room for a comment margin beside its document: true
|
|
1392
|
+
* only on a desktop (from 768px with a mouse or a trackpad, the query the
|
|
1393
|
+
* desktop metrics use) and while `element`, the viewer that holds the
|
|
1394
|
+
* document and the margin, is at least `minWidth` wide. Otherwise show
|
|
1395
|
+
* `CommentMarker`s and open discussions in a bottom sheet. Cards and markers
|
|
1396
|
+
* are different trees, so this is measured in JavaScript (a
|
|
1397
|
+
* `ResizeObserver` on the element), not decided by a container query.
|
|
1398
|
+
*/
|
|
1399
|
+
function useCommentMarginFits(element, { minWidth = 784 } = {}) {
|
|
1400
|
+
const desktop = useSyncExternalStore(subscribeDesktop, getDesktop, () => false);
|
|
1401
|
+
const [width, setWidth] = useState(0);
|
|
1402
|
+
useLayoutEffect(() => {
|
|
1403
|
+
if (!element) return;
|
|
1404
|
+
const measure = () => setWidth(element.getBoundingClientRect().width);
|
|
1405
|
+
measure();
|
|
1406
|
+
const observer = new ResizeObserver(measure);
|
|
1407
|
+
observer.observe(element);
|
|
1408
|
+
return () => observer.disconnect();
|
|
1409
|
+
}, [element]);
|
|
1410
|
+
return desktop && element !== null && width >= minWidth;
|
|
1411
|
+
}
|
|
1412
|
+
//#endregion
|
|
1413
|
+
export { Dialog as AnnouncementDialog, AnnouncementDialogAction, AnnouncementDialogBody, DialogClose as AnnouncementDialogClose, AnnouncementDialogContent, AnnouncementDialogFooter, AnnouncementDialogHeader, AnnouncementDialogLabel, AnnouncementDialogPicture, AnnouncementDialogSecondaryAction, AnnouncementDialogText, AnnouncementDialogTitle, DialogTrigger as AnnouncementDialogTrigger, COMMENT_HIGHLIGHT, COMMENT_HIGHLIGHT_ACTIVE, ComboboxSelect, CommentCard, CommentCardMeta, CommentCardQuote, CommentCardTrigger, CommentEntry, CommentEntryAuthor, CommentEntryBody, CommentEntryHeader, CommentEntryTime, CommentMargin, CommentMarginItem, CommentMarker, resolveCommentMarginLayout, setCommentHighlights, useCommentMarginFits };
|