@adea-ai/ui 0.138.0 → 0.138.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,8 +72,14 @@ export type TreeProps = Omit<ComponentProps<'div'>, 'children' | 'role' | 'tabIn
72
72
  * the target row: when the active row leaves `visibleItems`, the first visible
73
73
  * row becomes the roving stop and `onActiveIdChange` is called with it. If the
74
74
  * removed row held focus, or was the last row the tree saw focused, focus moves
75
- * to that same row; if it had no focus, only the roving stop moves. This is the
76
- * same in both `disclosure` modes and for every way a host collapses a row.
75
+ * to that same row; if it had no focus, only the roving stop moves. Focus that
76
+ * has already gone to a control outside the tree is never pulled back: the tree
77
+ * forgets its last focused row when focus moves to an element outside it, and
78
+ * the roving stop alone moves if that control still has focus when the row is
79
+ * hidden. Focus the tree has asked for but not yet moved (a queued recovery, or a
80
+ * keyboard move to a row the host has not mounted) is withdrawn when focus lands
81
+ * outside the tree, and is checked again when a row mounts. This is the same in
82
+ * both `disclosure` modes and for every way a host collapses a row.
77
83
  */
78
84
  export declare function Tree(props: TreeProps): JSX.Element;
79
85
  export type TreeRowProps = Omit<ComponentProps<'div'>, 'children' | 'role' | 'tabIndex' | 'onClick' | 'onFocus' | 'onDoubleClick' | 'style' | 'aria-level' | 'aria-expanded' | 'aria-selected' | 'aria-posinset' | 'aria-setsize'> & {
@@ -1 +1 @@
1
- {"version":3,"file":"tree.d.ts","sourceRoot":"","sources":["../../../../src/components/composites/tree/tree.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tree.d.ts","sourceRoot":"","sources":["../../../../src/components/composites/tree/tree.tsx"],"names":[],"mappings":"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"}
@@ -38,8 +38,14 @@ function useTreeContext() {
38
38
  * the target row: when the active row leaves `visibleItems`, the first visible
39
39
  * row becomes the roving stop and `onActiveIdChange` is called with it. If the
40
40
  * removed row held focus, or was the last row the tree saw focused, focus moves
41
- * to that same row; if it had no focus, only the roving stop moves. This is the
42
- * same in both `disclosure` modes and for every way a host collapses a row.
41
+ * to that same row; if it had no focus, only the roving stop moves. Focus that
42
+ * has already gone to a control outside the tree is never pulled back: the tree
43
+ * forgets its last focused row when focus moves to an element outside it, and
44
+ * the roving stop alone moves if that control still has focus when the row is
45
+ * hidden. Focus the tree has asked for but not yet moved (a queued recovery, or a
46
+ * keyboard move to a row the host has not mounted) is withdrawn when focus lands
47
+ * outside the tree, and is checked again when a row mounts. This is the same in
48
+ * both `disclosure` modes and for every way a host collapses a row.
43
49
  */
44
50
  function Tree(props) {
45
51
  const [local, rest] = splitProps(props, [
@@ -66,9 +72,19 @@ function Tree(props) {
66
72
  const rows = /* @__PURE__ */ new Map();
67
73
  const rowIdsByElement = /* @__PURE__ */ new WeakMap();
68
74
  let pendingFocusId = null;
75
+ let pendingFocusGuard;
69
76
  let requestedRevealId = null;
70
77
  let focusedId = null;
71
78
  let disposed = false;
79
+ const isInsideTreeRow = (element) => {
80
+ const row = element.closest("[role=\"treeitem\"]");
81
+ return row instanceof HTMLDivElement && rowIdsByElement.has(row);
82
+ };
83
+ const focusIsOutsideTree = () => {
84
+ if (typeof document === "undefined") return false;
85
+ const active = document.activeElement;
86
+ return active instanceof Element && active !== document.body && active !== document.documentElement && !isInsideTreeRow(active);
87
+ };
72
88
  const rowSizeObserver = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver((entries) => {
73
89
  if (disposed) return;
74
90
  for (const entry of entries) {
@@ -77,8 +93,26 @@ function Tree(props) {
77
93
  if (id !== void 0 && Number.isFinite(blockSize) && blockSize > 0) local.onRowSizeChange?.(id, blockSize);
78
94
  }
79
95
  });
96
+ const setPendingFocus = (id) => {
97
+ pendingFocusId = id;
98
+ if (typeof document === "undefined") return;
99
+ if (id === null) {
100
+ if (pendingFocusGuard) document.removeEventListener("focusin", pendingFocusGuard, true);
101
+ pendingFocusGuard = void 0;
102
+ } else if (!pendingFocusGuard) {
103
+ pendingFocusGuard = (event) => {
104
+ if (event.target instanceof Element && !isInsideTreeRow(event.target)) cancelPendingFocus();
105
+ };
106
+ document.addEventListener("focusin", pendingFocusGuard, true);
107
+ }
108
+ };
109
+ const cancelPendingFocus = () => {
110
+ setPendingFocus(null);
111
+ focusedId = null;
112
+ };
80
113
  onCleanup(() => {
81
114
  disposed = true;
115
+ setPendingFocus(null);
82
116
  rowSizeObserver?.disconnect();
83
117
  });
84
118
  const requestReveal = (id) => {
@@ -91,12 +125,16 @@ function Tree(props) {
91
125
  if (pendingFocusId !== id || effectiveActiveId() !== id) return;
92
126
  const row = rows.get(id);
93
127
  if (!row || typeof document === "undefined") return;
128
+ if (focusIsOutsideTree()) {
129
+ cancelPendingFocus();
130
+ return;
131
+ }
94
132
  row.focus();
95
- if (document.activeElement === row) pendingFocusId = null;
133
+ if (document.activeElement === row) setPendingFocus(null);
96
134
  });
97
135
  };
98
136
  const focusItem = (id) => {
99
- pendingFocusId = id;
137
+ setPendingFocus(id);
100
138
  local.onActiveIdChange(id);
101
139
  if (rows.has(id)) requestedRevealId = null;
102
140
  else requestReveal(id);
@@ -124,13 +162,21 @@ function Tree(props) {
124
162
  if (requestedRevealId === id) requestedRevealId = null;
125
163
  if (focusWasInside) queueMicrotask(() => {
126
164
  const fallbackId = effectiveActiveId();
127
- if (!disposed && fallbackId !== null && pendingFocusId !== fallbackId) focusItem(fallbackId);
165
+ if (disposed || fallbackId === null || pendingFocusId === fallbackId) return;
166
+ if (focusIsOutsideTree()) {
167
+ focusedId = null;
168
+ return;
169
+ }
170
+ focusItem(fallbackId);
128
171
  });
129
172
  },
130
173
  focusItem,
131
174
  noteFocus: (id) => {
132
175
  focusedId = id;
133
176
  },
177
+ noteBlur: (next) => {
178
+ if (next instanceof Element && !isInsideTreeRow(next)) cancelPendingFocus();
179
+ },
134
180
  activate: (id, event) => local.onActivate?.(id, event),
135
181
  expand: (id, expanded) => local.onExpand?.(id, expanded),
136
182
  disclosure: () => local.disclosure ?? "row",
@@ -139,8 +185,12 @@ function Tree(props) {
139
185
  createEffect(() => {
140
186
  const activeId = effectiveActiveId();
141
187
  if (activeId === null) return;
142
- if (focusedId !== null && !index().byId.has(focusedId) && pendingFocusId !== activeId) focusItem(activeId);
143
- else if (local.activeId !== activeId) local.onActiveIdChange(activeId);
188
+ if (focusedId !== null && !index().byId.has(focusedId) && pendingFocusId !== activeId) {
189
+ if (focusIsOutsideTree()) {
190
+ focusedId = null;
191
+ if (local.activeId !== activeId) local.onActiveIdChange(activeId);
192
+ } else focusItem(activeId);
193
+ } else if (local.activeId !== activeId) local.onActiveIdChange(activeId);
144
194
  if (!rows.has(activeId)) requestReveal(activeId);
145
195
  if (pendingFocusId === activeId) focusWhenMounted(activeId);
146
196
  });
@@ -213,11 +263,16 @@ function TreeRow(props) {
213
263
  let row;
214
264
  let registeredId;
215
265
  const id = () => local.item.id;
216
- onCleanup(() => tree.unregister(registeredId ?? id(), row));
266
+ const handleFocusOut = (event) => tree.noteBlur(event.relatedTarget);
267
+ onCleanup(() => {
268
+ row?.removeEventListener("focusout", handleFocusOut);
269
+ tree.unregister(registeredId ?? id(), row);
270
+ });
217
271
  const setRef = (element) => {
218
272
  row = element;
219
273
  registeredId = id();
220
274
  tree.register(registeredId, element);
275
+ element.addEventListener("focusout", handleFocusOut);
221
276
  };
222
277
  createEffect(() => {
223
278
  const nextId = id();
@@ -1 +1 @@
1
- {"version":3,"file":"tree.js","names":["ComponentProps","JSX","ChevronRight","createContext","createEffect","createMemo","createUniqueId","onCleanup","splitProps","useContext","cn","indexTreeItems","treeNavigationAction","TreeItemDescriptor","TreeNavigationIndex","TreeContextValue","idPrefix","activeId","index","register","id","element","HTMLDivElement","unregister","focusItem","noteFocus","activate","event","Event","expand","expanded","disclosure","TreeDisclosure","onActiveIdChange","TreeContext","useTreeContext","tree","Error","TreeAccessibleName","TreeProps","Omit","visibleItems","onExpand","onActivate","onSelectItem","onRequestReveal","onRowSizeChange","blockSize","selectionMode","children","Element","Tree","props","local","rest","effectiveActiveId","candidate","visible","byId","has","ids","rows","Map","rowIdsByElement","WeakMap","pendingFocusId","requestedRevealId","focusedId","disposed","rowSizeObserver","ResizeObserver","undefined","entries","entry","get","target","borderBoxSize","HTMLElement","offsetHeight","Number","isFinite","disconnect","requestReveal","focusWhenMounted","queueMicrotask","row","document","focus","activeElement","context","set","observe","focusWasInside","Boolean","contains","delete","unobserve","fallbackId","handleKeyDown","EventHandler","KeyboardEvent","defaultPrevented","isActionTarget","currentTarget","action","key","preventDefault","type","_$createComponent","Provider","value","_el$","_tmpl$","_$spread","_$mergeProps","aria-multiselectable","class","_$insert","TreeRowProps","item","selected","leading","trailing","onDoubleClick","MouseEvent","TreeRow","registeredId","setRef","nextId","handleFocus","handleClick","handleDoubleClick","position","positionById","markerTarget","expandable","handleMarkerClick","HTMLSpanElement","stopPropagation","_el$2","_tmpl$2","_el$3","firstChild","_el$4","nextSibling","_ref$","composeRefs","ref","_$use","encodeURIComponent","aria-level","level","aria-expanded","_$memo","aria-selected","aria-posinset","aria-setsize","setSize","tabIndex","data-tree-id","data-active","data-selected","data-expanded","String","style","Math","max","_$addEventListener","handleMarkerDoubleClick","handleMarkerMouseDown","_c$","_c$2","_el$5","_tmpl$3","_c$3","_el$6","_tmpl$4","_$effect","_p$","_v$","_v$2","e","_$setAttribute","t","_$className","TreeRowActions","pinned","_el$7","_tmpl$5","data-pinned","EventTarget","closest","internal","external","_$delegateEvents","_$createComponent","_$mergeProps","_$use","_$memo","_$setAttribute","_$className","_$delegateEvents"],"sources":["../../../../src/components/composites/tree/tree.tsx"],"sourcesContent":["import type { ComponentProps, JSX } from 'solid-js'\nimport { ChevronRight } from 'lucide-solid'\nimport {\n createContext,\n createEffect,\n createMemo,\n createUniqueId,\n onCleanup,\n splitProps,\n useContext,\n} from 'solid-js'\nimport { cn } from '#lib/utils'\nimport {\n indexTreeItems,\n treeNavigationAction,\n type TreeItemDescriptor,\n type TreeNavigationIndex,\n} from './tree-navigation'\n\ntype TreeContextValue = {\n idPrefix: string\n activeId: () => string | null\n index: () => TreeNavigationIndex\n register: (id: string, element: HTMLDivElement) => void\n unregister: (id: string, element: HTMLDivElement | undefined) => void\n focusItem: (id: string) => void\n noteFocus: (id: string) => void\n activate: (id: string, event: Event) => void\n expand: (id: string, expanded: boolean) => void\n disclosure: () => TreeDisclosure\n onActiveIdChange: (id: string) => void\n}\n\nconst TreeContext = createContext<TreeContextValue>()\n\nfunction useTreeContext() {\n const tree = useContext(TreeContext)\n if (!tree) throw new Error('[adea-ui]: TreeRow must be used inside Tree.')\n return tree\n}\n\n/**\n * Who owns the pointer on a row's disclosure chevron.\n *\n * - `row` (default): the chevron is decoration; a click anywhere on the row,\n * chevron included, is a row activation. This is the file-explorer\n * convention, where the host's `onActivate` toggles a folder.\n * - `marker`: the chevron of an expandable row is its own pointer target. It\n * calls `onExpand(id, !expanded)` and nothing else; the rest of the row still\n * activates. For trees where expanding and selecting are independent (a\n * source list that selects a source and separately reveals its details).\n */\nexport type TreeDisclosure = 'row' | 'marker'\n\ntype TreeAccessibleName =\n | { 'aria-label': string; 'aria-labelledby'?: string }\n | { 'aria-label'?: string; 'aria-labelledby': string }\n\nexport type TreeProps = Omit<\n ComponentProps<'div'>,\n 'children' | 'role' | 'tabIndex' | 'onKeyDown' | 'aria-label' | 'aria-labelledby'\n> &\n TreeAccessibleName & {\n /** Complete, ordered projection of currently visible (expanded) items. */\n visibleItems: readonly TreeItemDescriptor[]\n /** The host-owned identity of the row that currently owns roving focus. */\n activeId: string | null\n /**\n * Called when keyboard or pointer navigation changes the active row, and when\n * the active row leaves `visibleItems` (collapsed away, filtered out) and the\n * first visible row takes over as the roving stop.\n */\n onActiveIdChange: (id: string) => void\n /** Called for Left/Right expand and collapse requests. */\n onExpand?: (id: string, expanded: boolean) => void\n /**\n * Opt-in pointer behaviour of the disclosure chevron; see {@link TreeDisclosure}.\n * Keyboard behaviour is identical in both modes: Left/Right expand and collapse.\n */\n disclosure?: TreeDisclosure\n /** Called for Enter or pointer activation. Domain action stays host-owned. */\n onActivate?: (id: string, event: Event) => void\n /** Called for Space selection. Selection state stays host-owned. */\n onSelectItem?: (id: string, event: Event) => void\n /** Ask the host window to mount/reveal an item before focus moves to it. */\n onRequestReveal?: (id: string) => void\n /** Report a mounted row's border-box block size in CSS pixels for host range math. */\n onRowSizeChange?: (id: string, blockSize: number) => void\n /** Declares a multi-select tree to assistive technology. */\n selectionMode?: 'single' | 'multiple'\n children?: JSX.Element\n }\n\n/**\n * Tree.\n *\n * Supplies semantic and keyboard behavior for a tree whose host owns the\n * visible projection, filesystem state, row window and domain actions. The\n * mounted children can be a small virtualized window; `visibleItems` remains\n * the complete ordered projection so keyboard movement and sibling positions\n * do not depend on which rows currently exist in the DOM.\n *\n * Kobalte 0.13.14 has no Tree primitive, so this component implements the\n * WAI-ARIA tree row navigation contract and leaves product state to callbacks.\n *\n * By default the disclosure chevron is decoration and a click anywhere on a\n * row activates it, so a file explorer can toggle a folder from `onActivate`.\n * Reach for `disclosure=\"marker\"` when expanding and selecting are separate\n * decisions: the chevron then calls only `onExpand`, and the rest of the row\n * calls only `onActivate`. The chevron stays `aria-hidden` and out of the tab\n * order in both modes; keyboard expansion is Left/Right either way.\n *\n * Collapsing is the one thing that can move focus without a key or a click on\n * the target row: when the active row leaves `visibleItems`, the first visible\n * row becomes the roving stop and `onActiveIdChange` is called with it. If the\n * removed row held focus, or was the last row the tree saw focused, focus moves\n * to that same row; if it had no focus, only the roving stop moves. This is the\n * same in both `disclosure` modes and for every way a host collapses a row.\n */\nexport function Tree(props: TreeProps) {\n const [local, rest] = splitProps(props, [\n 'class',\n 'visibleItems',\n 'activeId',\n 'onActiveIdChange',\n 'onExpand',\n 'disclosure',\n 'onActivate',\n 'onSelectItem',\n 'onRequestReveal',\n 'onRowSizeChange',\n 'selectionMode',\n 'children',\n ])\n\n const index = createMemo(() => indexTreeItems(local.visibleItems))\n const effectiveActiveId = createMemo(() => {\n const candidate = local.activeId\n const visible = index()\n return candidate !== null && visible.byId.has(candidate) ? candidate : (visible.ids[0] ?? null)\n })\n const idPrefix = `tree-${createUniqueId()}`\n const rows = new Map<string, HTMLDivElement>()\n const rowIdsByElement = new WeakMap<HTMLDivElement, string>()\n let pendingFocusId: string | null = null\n let requestedRevealId: string | null = null\n let focusedId: string | null = null\n let disposed = false\n const rowSizeObserver =\n typeof ResizeObserver === 'undefined'\n ? undefined\n : new ResizeObserver((entries) => {\n if (disposed) return\n for (const entry of entries) {\n const id = rowIdsByElement.get(entry.target as HTMLDivElement)\n const blockSize =\n entry.borderBoxSize[0]?.blockSize ?? (entry.target as HTMLElement).offsetHeight\n if (id !== undefined && Number.isFinite(blockSize) && blockSize > 0)\n local.onRowSizeChange?.(id, blockSize)\n }\n })\n\n onCleanup(() => {\n disposed = true\n rowSizeObserver?.disconnect()\n })\n\n const requestReveal = (id: string) => {\n if (requestedRevealId === id) return\n requestedRevealId = id\n local.onRequestReveal?.(id)\n }\n\n const focusWhenMounted = (id: string) => {\n queueMicrotask(() => {\n if (pendingFocusId !== id || effectiveActiveId() !== id) return\n const row = rows.get(id)\n if (!row || typeof document === 'undefined') return\n row.focus()\n if (document.activeElement === row) pendingFocusId = null\n })\n }\n\n const focusItem = (id: string) => {\n pendingFocusId = id\n local.onActiveIdChange(id)\n if (rows.has(id)) requestedRevealId = null\n else requestReveal(id)\n focusWhenMounted(id)\n }\n\n const context: TreeContextValue = {\n idPrefix,\n activeId: effectiveActiveId,\n index,\n register: (id, element) => {\n rows.set(id, element)\n rowIdsByElement.set(element, id)\n rowSizeObserver?.observe(element)\n if (requestedRevealId === id) requestedRevealId = null\n if (pendingFocusId === id) focusWhenMounted(id)\n },\n unregister: (id, element) => {\n if (rows.get(id) !== element) return\n const focusWasInside =\n !disposed &&\n Boolean(\n element && typeof document !== 'undefined' && element.contains(document.activeElement)\n )\n rows.delete(id)\n if (element) {\n rowIdsByElement.delete(element)\n rowSizeObserver?.unobserve(element)\n }\n if (requestedRevealId === id) requestedRevealId = null\n if (focusWasInside) {\n queueMicrotask(() => {\n const fallbackId = effectiveActiveId()\n if (!disposed && fallbackId !== null && pendingFocusId !== fallbackId)\n focusItem(fallbackId)\n })\n }\n },\n focusItem,\n noteFocus: (id) => {\n focusedId = id\n },\n activate: (id, event) => local.onActivate?.(id, event),\n expand: (id, expanded) => local.onExpand?.(id, expanded),\n disclosure: () => local.disclosure ?? 'row',\n onActiveIdChange: local.onActiveIdChange,\n }\n\n // A filtered, collapsed-away or removed active ID falls back to the first\n // visible item. This keeps one treeitem in the sequential tab order while\n // notifying the host.\n createEffect(() => {\n const activeId = effectiveActiveId()\n if (activeId === null) return\n // If the projection removes the last row that took focus (a host filter, or\n // an ancestor collapsing), move focus to the same fallback that now owns the\n // roving tab stop. `focusedId` is not cleared when focus leaves the tree, so\n // this also reclaims focus from a control outside it; that long-standing\n // behaviour is deliberately left as it was.\n if (focusedId !== null && !index().byId.has(focusedId) && pendingFocusId !== activeId) {\n focusItem(activeId)\n } else if (local.activeId !== activeId) {\n local.onActiveIdChange(activeId)\n }\n // Preserve the active row in a caller-owned virtual window, including when\n // the pane mounts with its saved active row outside the initial render range.\n if (!rows.has(activeId)) requestReveal(activeId)\n if (pendingFocusId === activeId) focusWhenMounted(activeId)\n })\n\n const handleKeyDown: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (event) => {\n if (event.defaultPrevented || isActionTarget(event.target, event.currentTarget)) return\n\n const action = treeNavigationAction(index(), effectiveActiveId(), event.key)\n if (!action) return\n event.preventDefault()\n\n switch (action.type) {\n case 'focus':\n focusItem(action.id)\n break\n case 'expand':\n local.onExpand?.(action.id, action.expanded)\n break\n case 'activate':\n local.onActivate?.(action.id, event)\n break\n case 'select':\n local.onSelectItem?.(action.id, event)\n break\n }\n }\n\n return (\n <TreeContext.Provider value={context}>\n <div\n {...rest}\n role=\"tree\"\n aria-multiselectable={local.selectionMode === 'multiple' ? 'true' : undefined}\n data-slot=\"tree\"\n class={cn(\n /* A virtualized host remounts rows as it scrolls; scroll anchoring\n would latch onto the focused row and fight the scrollbar. */\n 'flex min-w-0 flex-col [overflow-anchor:none]',\n local.class\n )}\n onKeyDown={handleKeyDown}\n >\n {local.children}\n </div>\n </TreeContext.Provider>\n )\n}\n\nexport type TreeRowProps = Omit<\n ComponentProps<'div'>,\n | 'children'\n | 'role'\n | 'tabIndex'\n | 'onClick'\n | 'onFocus'\n | 'onDoubleClick'\n | 'style'\n | 'aria-level'\n | 'aria-expanded'\n | 'aria-selected'\n | 'aria-posinset'\n | 'aria-setsize'\n> & {\n item: TreeItemDescriptor\n /** Host-owned selection state; omitted for non-selectable trees. */\n selected?: boolean\n leading?: JSX.Element\n trailing?: JSX.Element\n children: JSX.Element\n onDoubleClick?: (id: string, event: MouseEvent & { currentTarget: HTMLDivElement }) => void\n}\n\n/**\n * TreeRow.\n *\n * Keeps the donor's disclosure marker, leading file/folder affordance, primary\n * label and trailing-action hierarchy while replacing the donor's button and\n * runtime callbacks with a shared treeitem and host-owned slots/actions.\n *\n * With `Tree`'s `disclosure=\"marker\"` the chevron of an expandable row is a\n * separate pointer target (`data-slot=\"tree-row-disclosure\"`) that only calls\n * `onExpand`. It is not focusable and has no role: the row remains the single\n * focus stop, Enter and Space still activate and select, and Left/Right still\n * expand and collapse, so the marker adds a pointer path and nothing else.\n * Pressing it does not move focus or the roving stop to its row; if the toggle\n * hides the focused row, `Tree` recovers focus as it does for any collapse (see\n * the `Tree` comment and docs/tree.md).\n */\nexport function TreeRow(props: TreeRowProps) {\n const tree = useTreeContext()\n const [local, rest] = splitProps(props, [\n 'class',\n 'item',\n 'selected',\n 'leading',\n 'trailing',\n 'children',\n 'onDoubleClick',\n 'ref',\n ])\n\n let row: HTMLDivElement | undefined\n let registeredId: string | undefined\n const id = () => local.item.id\n\n onCleanup(() => tree.unregister(registeredId ?? id(), row))\n\n const setRef = (element: HTMLDivElement) => {\n row = element\n registeredId = id()\n tree.register(registeredId, element)\n }\n\n createEffect(() => {\n const nextId = id()\n if (!row || registeredId === nextId) return\n if (registeredId !== undefined) tree.unregister(registeredId, row)\n registeredId = nextId\n tree.register(nextId, row)\n })\n\n const handleFocus = () => {\n tree.noteFocus(id())\n if (tree.activeId() !== id()) tree.onActiveIdChange(id())\n }\n\n const handleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {\n if (isActionTarget(event.target, event.currentTarget)) return\n tree.focusItem(id())\n tree.activate(id(), event)\n }\n\n const handleDoubleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {\n if (isActionTarget(event.target, event.currentTarget)) return\n local.onDoubleClick?.(id(), event)\n }\n\n const position = () => tree.index().positionById.get(id())\n // Only an expandable row has a chevron to press; a leaf's marker slot stays\n // part of the row so a click there is an ordinary row click.\n const markerTarget = () => tree.disclosure() === 'marker' && local.item.expandable\n\n const handleMarkerClick: JSX.EventHandler<HTMLSpanElement, MouseEvent> = (event) => {\n // The row's own handler would focus and activate the row.\n event.stopPropagation()\n tree.expand(id(), !local.item.expanded)\n }\n return (\n <div\n {...rest}\n ref={composeRefs(setRef, () => local.ref)}\n id={rest.id ?? `${tree.idPrefix}-item-${encodeURIComponent(id())}`}\n role=\"treeitem\"\n aria-level={local.item.level}\n aria-expanded={local.item.expandable ? (local.item.expanded ? 'true' : 'false') : undefined}\n aria-selected={local.selected === undefined ? undefined : local.selected ? 'true' : 'false'}\n aria-posinset={position()?.position}\n aria-setsize={position()?.setSize}\n tabIndex={tree.activeId() === id() ? 0 : -1}\n data-tree-id={id()}\n data-active={tree.activeId() === id() ? '' : undefined}\n data-selected={local.selected ? '' : undefined}\n data-expanded={local.item.expandable ? String(local.item.expanded) : undefined}\n class={cn(\n 'group/tree-row flex min-h-row-sm w-full min-w-0 items-center gap-1.5 rounded-md pe-1.5 text-left text-sm outline-none transition-colors',\n 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',\n {\n 'bg-primary-subtle text-foreground': local.selected,\n 'hover:bg-surface-hover': !local.selected,\n },\n local.class\n )}\n style={{ 'padding-inline-start': `${6 + Math.max(0, local.item.level - 1) * 12}px` }}\n onFocus={handleFocus}\n onClick={handleClick}\n onDblClick={handleDoubleClick}\n >\n <span\n data-slot={markerTarget() ? 'tree-row-disclosure' : undefined}\n class={cn(\n 'flex',\n !markerTarget() && 'size-3.5',\n 'shrink-0 items-center justify-center text-muted-foreground',\n /* The glyph stays 14px wide but the target is not: it takes the row's full\n height (a flex child's `self-stretch`, so it follows row density and a\n tall trailing control) and the pseudo-element widens it to 26px, which\n stops at the row's own padding and gap, never over the icon or label. */\n markerTarget() &&\n 'relative w-3.5 cursor-pointer self-stretch hover:text-foreground after:absolute after:inset-y-0 after:-inset-x-1.5'\n )}\n onMouseDown={markerTarget() ? handleMarkerMouseDown : undefined}\n onClick={markerTarget() ? handleMarkerClick : undefined}\n onDblClick={markerTarget() ? handleMarkerDoubleClick : undefined}\n >\n {local.item.expandable ? (\n <ChevronRight\n aria-hidden=\"true\"\n class={cn('size-3.5 transition-transform', local.item.expanded && 'rotate-90')}\n />\n ) : null}\n </span>\n {local.leading ? (\n <span class=\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\">\n {local.leading}\n </span>\n ) : null}\n <span class=\"min-w-0 flex-1 truncate\">{local.children}</span>\n {local.trailing ? (\n <span class=\"ms-auto flex shrink-0 items-center gap-1 text-muted-foreground\">\n {local.trailing}\n </span>\n ) : null}\n </div>\n )\n}\n\n/**\n * TreeRowActions.\n *\n * Row-level commands (rename, copy, delete) that stay out of the way until the\n * row is hovered or holds focus, so a long tree reads as names rather than a\n * wall of buttons. Put it in `TreeRow`'s `trailing` slot beside any status that\n * must stay visible. `pinned` keeps the actions shown, e.g. while a destructive\n * action waits for its confirming second press.\n */\nexport function TreeRowActions(props: ComponentProps<'span'> & { pinned?: boolean }) {\n const [local, rest] = splitProps(props, ['class', 'pinned'])\n return (\n <span\n data-slot=\"tree-row-actions\"\n data-pinned={local.pinned ? '' : undefined}\n class={cn(\n 'flex shrink-0 items-center gap-1 opacity-0 transition-opacity ease-out',\n 'group-hover/tree-row:opacity-100 group-focus-within/tree-row:opacity-100 data-pinned:opacity-100',\n local.class\n )}\n {...rest}\n />\n )\n}\n\n// Pressing the chevron must not move focus to its row: a mousedown on a\n// non-focusable child would otherwise focus the row, making it the roving stop\n// (and notifying `onActiveIdChange`) for what is a disclosure toggle, not a\n// selection. The press itself leaves focus where it was, so Left/Right keep\n// acting on the row the keyboard user left off at. It cannot stop the toggle\n// from hiding that row; `Tree` then recovers focus onto the first visible row.\nconst handleMarkerMouseDown: JSX.EventHandler<HTMLSpanElement, MouseEvent> = (event) =>\n event.preventDefault()\n// A double-click is two toggles; it must not also reach `onDoubleClick`\n// (rename, open) as though the row itself had been double-clicked.\nconst handleMarkerDoubleClick: JSX.EventHandler<HTMLSpanElement, MouseEvent> = (event) =>\n event.stopPropagation()\n\nfunction isActionTarget(target: EventTarget | null, currentTarget: HTMLElement): boolean {\n if (!(target instanceof Element) || target === currentTarget) return false\n return Boolean(\n target.closest(\n 'a, button, input, textarea, select, [contenteditable=\"true\"], [role=\"button\"], [data-tree-keyboard-stop]'\n )\n )\n}\n\nfunction composeRefs(\n internal: (element: HTMLDivElement) => void,\n external: () => ComponentProps<'div'>['ref']\n): (element: HTMLDivElement) => void {\n return (element) => {\n internal(element)\n const ref = external()\n if (typeof ref === 'function') ref(element)\n }\n}\n\nexport type { TreeItemDescriptor } from './tree-navigation'\n"],"mappings":"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"}
1
+ {"version":3,"file":"tree.js","names":["ComponentProps","JSX","ChevronRight","createContext","createEffect","createMemo","createUniqueId","onCleanup","splitProps","useContext","cn","indexTreeItems","treeNavigationAction","TreeItemDescriptor","TreeNavigationIndex","TreeContextValue","idPrefix","activeId","index","register","id","element","HTMLDivElement","unregister","focusItem","noteFocus","noteBlur","next","EventTarget","activate","event","Event","expand","expanded","disclosure","TreeDisclosure","onActiveIdChange","TreeContext","useTreeContext","tree","Error","TreeAccessibleName","TreeProps","Omit","visibleItems","onExpand","onActivate","onSelectItem","onRequestReveal","onRowSizeChange","blockSize","selectionMode","children","Element","Tree","props","local","rest","effectiveActiveId","candidate","visible","byId","has","ids","rows","Map","rowIdsByElement","WeakMap","pendingFocusId","pendingFocusGuard","FocusEvent","requestedRevealId","focusedId","disposed","isInsideTreeRow","row","closest","focusIsOutsideTree","document","active","activeElement","body","documentElement","rowSizeObserver","ResizeObserver","undefined","entries","entry","get","target","borderBoxSize","HTMLElement","offsetHeight","Number","isFinite","setPendingFocus","removeEventListener","cancelPendingFocus","addEventListener","disconnect","requestReveal","focusWhenMounted","queueMicrotask","focus","context","set","observe","focusWasInside","Boolean","contains","delete","unobserve","fallbackId","handleKeyDown","EventHandler","KeyboardEvent","defaultPrevented","isActionTarget","currentTarget","action","key","preventDefault","type","_$createComponent","Provider","value","_el$","_tmpl$","_$spread","_$mergeProps","aria-multiselectable","class","_$insert","TreeRowProps","item","selected","leading","trailing","onDoubleClick","MouseEvent","TreeRow","registeredId","handleFocusOut","relatedTarget","setRef","nextId","handleFocus","handleClick","handleDoubleClick","position","positionById","markerTarget","expandable","handleMarkerClick","HTMLSpanElement","stopPropagation","_el$2","_tmpl$2","_el$3","firstChild","_el$4","nextSibling","_ref$","composeRefs","ref","_$use","encodeURIComponent","aria-level","level","aria-expanded","_$memo","aria-selected","aria-posinset","aria-setsize","setSize","tabIndex","data-tree-id","data-active","data-selected","data-expanded","String","style","Math","max","_$addEventListener","handleMarkerDoubleClick","handleMarkerMouseDown","_c$","_c$2","_el$5","_tmpl$3","_c$3","_el$6","_tmpl$4","_$effect","_p$","_v$","_v$2","e","_$setAttribute","t","_$className","TreeRowActions","pinned","_el$7","_tmpl$5","data-pinned","internal","external","_$delegateEvents","_$createComponent","_$mergeProps","_$use","_$memo","_$setAttribute","_$className","_$delegateEvents"],"sources":["../../../../src/components/composites/tree/tree.tsx"],"sourcesContent":["import type { ComponentProps, JSX } from 'solid-js'\nimport { ChevronRight } from 'lucide-solid'\nimport {\n createContext,\n createEffect,\n createMemo,\n createUniqueId,\n onCleanup,\n splitProps,\n useContext,\n} from 'solid-js'\nimport { cn } from '#lib/utils'\nimport {\n indexTreeItems,\n treeNavigationAction,\n type TreeItemDescriptor,\n type TreeNavigationIndex,\n} from './tree-navigation'\n\ntype TreeContextValue = {\n idPrefix: string\n activeId: () => string | null\n index: () => TreeNavigationIndex\n register: (id: string, element: HTMLDivElement) => void\n unregister: (id: string, element: HTMLDivElement | undefined) => void\n focusItem: (id: string) => void\n noteFocus: (id: string) => void\n noteBlur: (next: EventTarget | null) => void\n activate: (id: string, event: Event) => void\n expand: (id: string, expanded: boolean) => void\n disclosure: () => TreeDisclosure\n onActiveIdChange: (id: string) => void\n}\n\nconst TreeContext = createContext<TreeContextValue>()\n\nfunction useTreeContext() {\n const tree = useContext(TreeContext)\n if (!tree) throw new Error('[adea-ui]: TreeRow must be used inside Tree.')\n return tree\n}\n\n/**\n * Who owns the pointer on a row's disclosure chevron.\n *\n * - `row` (default): the chevron is decoration; a click anywhere on the row,\n * chevron included, is a row activation. This is the file-explorer\n * convention, where the host's `onActivate` toggles a folder.\n * - `marker`: the chevron of an expandable row is its own pointer target. It\n * calls `onExpand(id, !expanded)` and nothing else; the rest of the row still\n * activates. For trees where expanding and selecting are independent (a\n * source list that selects a source and separately reveals its details).\n */\nexport type TreeDisclosure = 'row' | 'marker'\n\ntype TreeAccessibleName =\n | { 'aria-label': string; 'aria-labelledby'?: string }\n | { 'aria-label'?: string; 'aria-labelledby': string }\n\nexport type TreeProps = Omit<\n ComponentProps<'div'>,\n 'children' | 'role' | 'tabIndex' | 'onKeyDown' | 'aria-label' | 'aria-labelledby'\n> &\n TreeAccessibleName & {\n /** Complete, ordered projection of currently visible (expanded) items. */\n visibleItems: readonly TreeItemDescriptor[]\n /** The host-owned identity of the row that currently owns roving focus. */\n activeId: string | null\n /**\n * Called when keyboard or pointer navigation changes the active row, and when\n * the active row leaves `visibleItems` (collapsed away, filtered out) and the\n * first visible row takes over as the roving stop.\n */\n onActiveIdChange: (id: string) => void\n /** Called for Left/Right expand and collapse requests. */\n onExpand?: (id: string, expanded: boolean) => void\n /**\n * Opt-in pointer behaviour of the disclosure chevron; see {@link TreeDisclosure}.\n * Keyboard behaviour is identical in both modes: Left/Right expand and collapse.\n */\n disclosure?: TreeDisclosure\n /** Called for Enter or pointer activation. Domain action stays host-owned. */\n onActivate?: (id: string, event: Event) => void\n /** Called for Space selection. Selection state stays host-owned. */\n onSelectItem?: (id: string, event: Event) => void\n /** Ask the host window to mount/reveal an item before focus moves to it. */\n onRequestReveal?: (id: string) => void\n /** Report a mounted row's border-box block size in CSS pixels for host range math. */\n onRowSizeChange?: (id: string, blockSize: number) => void\n /** Declares a multi-select tree to assistive technology. */\n selectionMode?: 'single' | 'multiple'\n children?: JSX.Element\n }\n\n/**\n * Tree.\n *\n * Supplies semantic and keyboard behavior for a tree whose host owns the\n * visible projection, filesystem state, row window and domain actions. The\n * mounted children can be a small virtualized window; `visibleItems` remains\n * the complete ordered projection so keyboard movement and sibling positions\n * do not depend on which rows currently exist in the DOM.\n *\n * Kobalte 0.13.14 has no Tree primitive, so this component implements the\n * WAI-ARIA tree row navigation contract and leaves product state to callbacks.\n *\n * By default the disclosure chevron is decoration and a click anywhere on a\n * row activates it, so a file explorer can toggle a folder from `onActivate`.\n * Reach for `disclosure=\"marker\"` when expanding and selecting are separate\n * decisions: the chevron then calls only `onExpand`, and the rest of the row\n * calls only `onActivate`. The chevron stays `aria-hidden` and out of the tab\n * order in both modes; keyboard expansion is Left/Right either way.\n *\n * Collapsing is the one thing that can move focus without a key or a click on\n * the target row: when the active row leaves `visibleItems`, the first visible\n * row becomes the roving stop and `onActiveIdChange` is called with it. If the\n * removed row held focus, or was the last row the tree saw focused, focus moves\n * to that same row; if it had no focus, only the roving stop moves. Focus that\n * has already gone to a control outside the tree is never pulled back: the tree\n * forgets its last focused row when focus moves to an element outside it, and\n * the roving stop alone moves if that control still has focus when the row is\n * hidden. Focus the tree has asked for but not yet moved (a queued recovery, or a\n * keyboard move to a row the host has not mounted) is withdrawn when focus lands\n * outside the tree, and is checked again when a row mounts. This is the same in\n * both `disclosure` modes and for every way a host collapses a row.\n */\nexport function Tree(props: TreeProps) {\n const [local, rest] = splitProps(props, [\n 'class',\n 'visibleItems',\n 'activeId',\n 'onActiveIdChange',\n 'onExpand',\n 'disclosure',\n 'onActivate',\n 'onSelectItem',\n 'onRequestReveal',\n 'onRowSizeChange',\n 'selectionMode',\n 'children',\n ])\n\n const index = createMemo(() => indexTreeItems(local.visibleItems))\n const effectiveActiveId = createMemo(() => {\n const candidate = local.activeId\n const visible = index()\n return candidate !== null && visible.byId.has(candidate) ? candidate : (visible.ids[0] ?? null)\n })\n const idPrefix = `tree-${createUniqueId()}`\n const rows = new Map<string, HTMLDivElement>()\n const rowIdsByElement = new WeakMap<HTMLDivElement, string>()\n let pendingFocusId: string | null = null\n let pendingFocusGuard: ((event: FocusEvent) => void) | undefined\n let requestedRevealId: string | null = null\n let focusedId: string | null = null\n let disposed = false\n const isInsideTreeRow = (element: Element) => {\n const row = element.closest('[role=\"treeitem\"]')\n return row instanceof HTMLDivElement && rowIdsByElement.has(row)\n }\n // True when a focusable control outside this tree owns focus. Nothing focused\n // (`body`) is not outside: that is where focus falls when a focused row is removed.\n const focusIsOutsideTree = () => {\n if (typeof document === 'undefined') return false\n const active = document.activeElement\n return (\n active instanceof Element &&\n active !== document.body &&\n active !== document.documentElement &&\n !isInsideTreeRow(active)\n )\n }\n const rowSizeObserver =\n typeof ResizeObserver === 'undefined'\n ? undefined\n : new ResizeObserver((entries) => {\n if (disposed) return\n for (const entry of entries) {\n const id = rowIdsByElement.get(entry.target as HTMLDivElement)\n const blockSize =\n entry.borderBoxSize[0]?.blockSize ?? (entry.target as HTMLElement).offsetHeight\n if (id !== undefined && Number.isFinite(blockSize) && blockSize > 0)\n local.onRowSizeChange?.(id, blockSize)\n }\n })\n\n // Focus the tree has asked for but not yet moved (a microtask away, or waiting for a\n // virtualised row to mount) is only a request. While one is pending, focus arriving\n // on a control outside the tree withdraws it, so a row mounting later cannot take\n // focus back from a control the user, or the host, has since chosen.\n const setPendingFocus = (id: string | null) => {\n pendingFocusId = id\n if (typeof document === 'undefined') return\n if (id === null) {\n if (pendingFocusGuard) document.removeEventListener('focusin', pendingFocusGuard, true)\n pendingFocusGuard = undefined\n } else if (!pendingFocusGuard) {\n pendingFocusGuard = (event) => {\n if (event.target instanceof Element && !isInsideTreeRow(event.target)) cancelPendingFocus()\n }\n document.addEventListener('focusin', pendingFocusGuard, true)\n }\n }\n // Withdrawing a request also drops the memory of the row it was recovering for:\n // nothing is left to recover.\n const cancelPendingFocus = () => {\n setPendingFocus(null)\n focusedId = null\n }\n\n onCleanup(() => {\n disposed = true\n setPendingFocus(null)\n rowSizeObserver?.disconnect()\n })\n\n const requestReveal = (id: string) => {\n if (requestedRevealId === id) return\n requestedRevealId = id\n local.onRequestReveal?.(id)\n }\n\n const focusWhenMounted = (id: string) => {\n queueMicrotask(() => {\n if (pendingFocusId !== id || effectiveActiveId() !== id) return\n const row = rows.get(id)\n if (!row || typeof document === 'undefined') return\n // Ownership is checked when focus would move, not when it was requested: a\n // control outside the tree may have taken focus since, and keeps it.\n if (focusIsOutsideTree()) {\n cancelPendingFocus()\n return\n }\n row.focus()\n if (document.activeElement === row) setPendingFocus(null)\n })\n }\n\n const focusItem = (id: string) => {\n setPendingFocus(id)\n local.onActiveIdChange(id)\n if (rows.has(id)) requestedRevealId = null\n else requestReveal(id)\n focusWhenMounted(id)\n }\n\n const context: TreeContextValue = {\n idPrefix,\n activeId: effectiveActiveId,\n index,\n register: (id, element) => {\n rows.set(id, element)\n rowIdsByElement.set(element, id)\n rowSizeObserver?.observe(element)\n if (requestedRevealId === id) requestedRevealId = null\n if (pendingFocusId === id) focusWhenMounted(id)\n },\n unregister: (id, element) => {\n if (rows.get(id) !== element) return\n const focusWasInside =\n !disposed &&\n Boolean(\n element && typeof document !== 'undefined' && element.contains(document.activeElement)\n )\n rows.delete(id)\n if (element) {\n rowIdsByElement.delete(element)\n rowSizeObserver?.unobserve(element)\n }\n if (requestedRevealId === id) requestedRevealId = null\n if (focusWasInside) {\n queueMicrotask(() => {\n const fallbackId = effectiveActiveId()\n if (disposed || fallbackId === null || pendingFocusId === fallbackId) return\n // Focus fell out of the removed row, but it may have been given to a control\n // outside the tree before this ran. Only nothing-focused (body) recovers.\n if (focusIsOutsideTree()) {\n focusedId = null\n return\n }\n focusItem(fallbackId)\n })\n }\n },\n focusItem,\n noteFocus: (id) => {\n focusedId = id\n },\n noteBlur: (next) => {\n // Focus moving to an element outside this tree ends the memory of the last\n // focused row and withdraws any focus the tree was still waiting to give a\n // row. A null target (focus to nothing, or the window losing focus) is\n // ambiguous, so both stay for the recovery below.\n if (next instanceof Element && !isInsideTreeRow(next)) cancelPendingFocus()\n },\n activate: (id, event) => local.onActivate?.(id, event),\n expand: (id, expanded) => local.onExpand?.(id, expanded),\n disclosure: () => local.disclosure ?? 'row',\n onActiveIdChange: local.onActiveIdChange,\n }\n\n // A filtered, collapsed-away or removed active ID falls back to the first\n // visible item. This keeps one treeitem in the sequential tab order while\n // notifying the host.\n createEffect(() => {\n const activeId = effectiveActiveId()\n if (activeId === null) return\n // If the projection removes the last row that took focus (a host filter, or\n // an ancestor collapsing), move focus to the same fallback that now owns the\n // roving tab stop, unless a control outside the tree has focus: that control\n // keeps it, and only the roving stop moves.\n if (focusedId !== null && !index().byId.has(focusedId) && pendingFocusId !== activeId) {\n if (focusIsOutsideTree()) {\n focusedId = null\n if (local.activeId !== activeId) local.onActiveIdChange(activeId)\n } else {\n focusItem(activeId)\n }\n } else if (local.activeId !== activeId) {\n local.onActiveIdChange(activeId)\n }\n // Preserve the active row in a caller-owned virtual window, including when\n // the pane mounts with its saved active row outside the initial render range.\n if (!rows.has(activeId)) requestReveal(activeId)\n if (pendingFocusId === activeId) focusWhenMounted(activeId)\n })\n\n const handleKeyDown: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (event) => {\n if (event.defaultPrevented || isActionTarget(event.target, event.currentTarget)) return\n\n const action = treeNavigationAction(index(), effectiveActiveId(), event.key)\n if (!action) return\n event.preventDefault()\n\n switch (action.type) {\n case 'focus':\n focusItem(action.id)\n break\n case 'expand':\n local.onExpand?.(action.id, action.expanded)\n break\n case 'activate':\n local.onActivate?.(action.id, event)\n break\n case 'select':\n local.onSelectItem?.(action.id, event)\n break\n }\n }\n\n return (\n <TreeContext.Provider value={context}>\n <div\n {...rest}\n role=\"tree\"\n aria-multiselectable={local.selectionMode === 'multiple' ? 'true' : undefined}\n data-slot=\"tree\"\n class={cn(\n /* A virtualized host remounts rows as it scrolls; scroll anchoring\n would latch onto the focused row and fight the scrollbar. */\n 'flex min-w-0 flex-col [overflow-anchor:none]',\n local.class\n )}\n onKeyDown={handleKeyDown}\n >\n {local.children}\n </div>\n </TreeContext.Provider>\n )\n}\n\nexport type TreeRowProps = Omit<\n ComponentProps<'div'>,\n | 'children'\n | 'role'\n | 'tabIndex'\n | 'onClick'\n | 'onFocus'\n | 'onDoubleClick'\n | 'style'\n | 'aria-level'\n | 'aria-expanded'\n | 'aria-selected'\n | 'aria-posinset'\n | 'aria-setsize'\n> & {\n item: TreeItemDescriptor\n /** Host-owned selection state; omitted for non-selectable trees. */\n selected?: boolean\n leading?: JSX.Element\n trailing?: JSX.Element\n children: JSX.Element\n onDoubleClick?: (id: string, event: MouseEvent & { currentTarget: HTMLDivElement }) => void\n}\n\n/**\n * TreeRow.\n *\n * Keeps the donor's disclosure marker, leading file/folder affordance, primary\n * label and trailing-action hierarchy while replacing the donor's button and\n * runtime callbacks with a shared treeitem and host-owned slots/actions.\n *\n * With `Tree`'s `disclosure=\"marker\"` the chevron of an expandable row is a\n * separate pointer target (`data-slot=\"tree-row-disclosure\"`) that only calls\n * `onExpand`. It is not focusable and has no role: the row remains the single\n * focus stop, Enter and Space still activate and select, and Left/Right still\n * expand and collapse, so the marker adds a pointer path and nothing else.\n * Pressing it does not move focus or the roving stop to its row; if the toggle\n * hides the focused row, `Tree` recovers focus as it does for any collapse (see\n * the `Tree` comment and docs/tree.md).\n */\nexport function TreeRow(props: TreeRowProps) {\n const tree = useTreeContext()\n const [local, rest] = splitProps(props, [\n 'class',\n 'item',\n 'selected',\n 'leading',\n 'trailing',\n 'children',\n 'onDoubleClick',\n 'ref',\n ])\n\n let row: HTMLDivElement | undefined\n let registeredId: string | undefined\n const id = () => local.item.id\n\n const handleFocusOut = (event: FocusEvent) => tree.noteBlur(event.relatedTarget)\n\n onCleanup(() => {\n row?.removeEventListener('focusout', handleFocusOut)\n tree.unregister(registeredId ?? id(), row)\n })\n\n const setRef = (element: HTMLDivElement) => {\n row = element\n registeredId = id()\n tree.register(registeredId, element)\n // A listener, not an `onFocusOut` prop, so a host's own handler is untouched.\n element.addEventListener('focusout', handleFocusOut)\n }\n\n createEffect(() => {\n const nextId = id()\n if (!row || registeredId === nextId) return\n if (registeredId !== undefined) tree.unregister(registeredId, row)\n registeredId = nextId\n tree.register(nextId, row)\n })\n\n const handleFocus = () => {\n tree.noteFocus(id())\n if (tree.activeId() !== id()) tree.onActiveIdChange(id())\n }\n\n const handleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {\n if (isActionTarget(event.target, event.currentTarget)) return\n tree.focusItem(id())\n tree.activate(id(), event)\n }\n\n const handleDoubleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {\n if (isActionTarget(event.target, event.currentTarget)) return\n local.onDoubleClick?.(id(), event)\n }\n\n const position = () => tree.index().positionById.get(id())\n // Only an expandable row has a chevron to press; a leaf's marker slot stays\n // part of the row so a click there is an ordinary row click.\n const markerTarget = () => tree.disclosure() === 'marker' && local.item.expandable\n\n const handleMarkerClick: JSX.EventHandler<HTMLSpanElement, MouseEvent> = (event) => {\n // The row's own handler would focus and activate the row.\n event.stopPropagation()\n tree.expand(id(), !local.item.expanded)\n }\n return (\n <div\n {...rest}\n ref={composeRefs(setRef, () => local.ref)}\n id={rest.id ?? `${tree.idPrefix}-item-${encodeURIComponent(id())}`}\n role=\"treeitem\"\n aria-level={local.item.level}\n aria-expanded={local.item.expandable ? (local.item.expanded ? 'true' : 'false') : undefined}\n aria-selected={local.selected === undefined ? undefined : local.selected ? 'true' : 'false'}\n aria-posinset={position()?.position}\n aria-setsize={position()?.setSize}\n tabIndex={tree.activeId() === id() ? 0 : -1}\n data-tree-id={id()}\n data-active={tree.activeId() === id() ? '' : undefined}\n data-selected={local.selected ? '' : undefined}\n data-expanded={local.item.expandable ? String(local.item.expanded) : undefined}\n class={cn(\n 'group/tree-row flex min-h-row-sm w-full min-w-0 items-center gap-1.5 rounded-md pe-1.5 text-left text-sm outline-none transition-colors',\n 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',\n {\n 'bg-primary-subtle text-foreground': local.selected,\n 'hover:bg-surface-hover': !local.selected,\n },\n local.class\n )}\n style={{ 'padding-inline-start': `${6 + Math.max(0, local.item.level - 1) * 12}px` }}\n onFocus={handleFocus}\n onClick={handleClick}\n onDblClick={handleDoubleClick}\n >\n <span\n data-slot={markerTarget() ? 'tree-row-disclosure' : undefined}\n class={cn(\n 'flex',\n !markerTarget() && 'size-3.5',\n 'shrink-0 items-center justify-center text-muted-foreground',\n /* The glyph stays 14px wide but the target is not: it takes the row's full\n height (a flex child's `self-stretch`, so it follows row density and a\n tall trailing control) and the pseudo-element widens it to 26px, which\n stops at the row's own padding and gap, never over the icon or label. */\n markerTarget() &&\n 'relative w-3.5 cursor-pointer self-stretch hover:text-foreground after:absolute after:inset-y-0 after:-inset-x-1.5'\n )}\n onMouseDown={markerTarget() ? handleMarkerMouseDown : undefined}\n onClick={markerTarget() ? handleMarkerClick : undefined}\n onDblClick={markerTarget() ? handleMarkerDoubleClick : undefined}\n >\n {local.item.expandable ? (\n <ChevronRight\n aria-hidden=\"true\"\n class={cn('size-3.5 transition-transform', local.item.expanded && 'rotate-90')}\n />\n ) : null}\n </span>\n {local.leading ? (\n <span class=\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\">\n {local.leading}\n </span>\n ) : null}\n <span class=\"min-w-0 flex-1 truncate\">{local.children}</span>\n {local.trailing ? (\n <span class=\"ms-auto flex shrink-0 items-center gap-1 text-muted-foreground\">\n {local.trailing}\n </span>\n ) : null}\n </div>\n )\n}\n\n/**\n * TreeRowActions.\n *\n * Row-level commands (rename, copy, delete) that stay out of the way until the\n * row is hovered or holds focus, so a long tree reads as names rather than a\n * wall of buttons. Put it in `TreeRow`'s `trailing` slot beside any status that\n * must stay visible. `pinned` keeps the actions shown, e.g. while a destructive\n * action waits for its confirming second press.\n */\nexport function TreeRowActions(props: ComponentProps<'span'> & { pinned?: boolean }) {\n const [local, rest] = splitProps(props, ['class', 'pinned'])\n return (\n <span\n data-slot=\"tree-row-actions\"\n data-pinned={local.pinned ? '' : undefined}\n class={cn(\n 'flex shrink-0 items-center gap-1 opacity-0 transition-opacity ease-out',\n 'group-hover/tree-row:opacity-100 group-focus-within/tree-row:opacity-100 data-pinned:opacity-100',\n local.class\n )}\n {...rest}\n />\n )\n}\n\n// Pressing the chevron must not move focus to its row: a mousedown on a\n// non-focusable child would otherwise focus the row, making it the roving stop\n// (and notifying `onActiveIdChange`) for what is a disclosure toggle, not a\n// selection. The press itself leaves focus where it was, so Left/Right keep\n// acting on the row the keyboard user left off at. It cannot stop the toggle\n// from hiding that row; `Tree` then recovers focus onto the first visible row.\nconst handleMarkerMouseDown: JSX.EventHandler<HTMLSpanElement, MouseEvent> = (event) =>\n event.preventDefault()\n// A double-click is two toggles; it must not also reach `onDoubleClick`\n// (rename, open) as though the row itself had been double-clicked.\nconst handleMarkerDoubleClick: JSX.EventHandler<HTMLSpanElement, MouseEvent> = (event) =>\n event.stopPropagation()\n\nfunction isActionTarget(target: EventTarget | null, currentTarget: HTMLElement): boolean {\n if (!(target instanceof Element) || target === currentTarget) return false\n return Boolean(\n target.closest(\n 'a, button, input, textarea, select, [contenteditable=\"true\"], [role=\"button\"], [data-tree-keyboard-stop]'\n )\n )\n}\n\nfunction composeRefs(\n internal: (element: HTMLDivElement) => void,\n external: () => ComponentProps<'div'>['ref']\n): (element: HTMLDivElement) => void {\n return (element) => {\n internal(element)\n const ref = external()\n if (typeof ref === 'function') ref(element)\n }\n}\n\nexport type { TreeItemDescriptor } from './tree-navigation'\n"],"mappings":"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"}
@@ -58,15 +58,16 @@ export declare function Callout(props: CalloutProps): import("solid-js").JSX.Ele
58
58
  * level would break it. Pass `role="heading"` with `aria-level` where the
59
59
  * content does belong in the outline.
60
60
  *
61
- * The title always carries a 4px (`mb-1`) margin below it, whatever follows: a
61
+ * The title carries a 4px (`mb-1`) margin below it, whatever follows: a
62
62
  * paragraph, a list, or bare text. CSS cannot see text nodes, so a selector such
63
63
  * as `:last-child` or `:only-child` cannot tell `<Title/>text` (needs the gap)
64
64
  * from `<Title/>` (needs none), and `not-last:mb-1` silently dropped the gap for
65
- * bare text, the most common body. The unconditional margin is the less
66
- * surprising failure: it is never missing where text follows. The price is the
67
- * title-only callout, which would end 4px taller than its padding says. Pass
68
- * `class="mb-0"` there (`cn` lets the caller's margin win); the `WithTitle` story
69
- * shows it.
65
+ * bare text, the most common body. So the gap stays the default and the one case
66
+ * that does not want it is detected by `Callout`, which sees its rendered
67
+ * children: a callout whose only child is a title carries `data-title-only`, and
68
+ * the title drops its margin so the block ends at its own padding. A caller never
69
+ * passes `mb-0` for this. Anything else after the title, text included, keeps the
70
+ * gap.
70
71
  */
71
72
  export declare function CalloutTitle(props: ComponentProps<'div'>): import("solid-js").JSX.Element;
72
73
  //# sourceMappingURL=callout.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"callout.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/callout/callout.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AAE9C,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,eAAe,CAAA;AAGtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,eAAO,MAAM,eAAe;;;8EAsB3B,CAAA;AAED,MAAM,MAAM,YAAY,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG,YAAY,CAAC,OAAO,eAAe,CAAC,CAAA;AAEvF,wBAAgB,OAAO,CAAC,KAAK,EAAE,YAAY,kCAU1C;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,CAAC,KAAK,CAAC,kCASxD"}
1
+ {"version":3,"file":"callout.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/callout/callout.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AAE9C,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,eAAe,CAAA;AAGtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,eAAO,MAAM,eAAe;;;8EAsB3B,CAAA;AAED,MAAM,MAAM,YAAY,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG,YAAY,CAAC,OAAO,eAAe,CAAC,CAAA;AAYvF,wBAAgB,OAAO,CAAC,KAAK,EAAE,YAAY,kCAoB1C;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,CAAC,KAAK,CAAC,kCASxD"}
@@ -1,7 +1,7 @@
1
1
  import { cn } from "../../../lib/utils.js";
2
2
  import { cva } from "../../../lib/variants.js";
3
- import { mergeProps, spread, template } from "solid-js/web";
4
- import { splitProps } from "solid-js";
3
+ import { insert, mergeProps, spread, template } from "solid-js/web";
4
+ import { children, createMemo, splitProps } from "solid-js";
5
5
  //#region src/components/ui/callout/callout.tsx
6
6
  var _tmpl$ = /*#__PURE__*/ template(`<div data-slot=callout role=note>`);
7
7
  var _tmpl$2 = /*#__PURE__*/ template(`<div data-slot=callout-title>`);
@@ -74,20 +74,34 @@ var calloutVariants = cva("bg-card text-card-foreground block rounded-none wrap-
74
74
  size: "md"
75
75
  }
76
76
  });
77
+ var isRendered = (node) => node != null && typeof node !== "boolean" && (typeof node !== "string" || node.trim() !== "");
78
+ var isCalloutTitle = (node) => typeof node === "object" && node !== null && "getAttribute" in node && typeof node.getAttribute === "function" && node.getAttribute("data-slot") === "callout-title";
77
79
  function Callout(props) {
78
80
  const [local, rest] = splitProps(props, [
79
81
  "class",
80
82
  "variant",
81
- "size"
83
+ "size",
84
+ "children"
82
85
  ]);
86
+ const resolved = children(() => local.children);
87
+ const titleOnly = createMemo(() => {
88
+ const nodes = resolved.toArray().filter(isRendered);
89
+ return nodes.length === 1 && isCalloutTitle(nodes[0]);
90
+ });
83
91
  return (() => {
84
92
  var _el$ = _tmpl$();
85
- spread(_el$, mergeProps({ get ["class"]() {
86
- return cn(calloutVariants({
87
- variant: local.variant,
88
- size: local.size
89
- }), local.class);
90
- } }, rest), false, false);
93
+ spread(_el$, mergeProps({
94
+ get ["data-title-only"]() {
95
+ return titleOnly() ? "" : void 0;
96
+ },
97
+ get ["class"]() {
98
+ return cn(calloutVariants({
99
+ variant: local.variant,
100
+ size: local.size
101
+ }), local.class);
102
+ }
103
+ }, rest), false, true);
104
+ insert(_el$, resolved);
91
105
  return _el$;
92
106
  })();
93
107
  }
@@ -97,22 +111,23 @@ function Callout(props) {
97
111
  * level would break it. Pass `role="heading"` with `aria-level` where the
98
112
  * content does belong in the outline.
99
113
  *
100
- * The title always carries a 4px (`mb-1`) margin below it, whatever follows: a
114
+ * The title carries a 4px (`mb-1`) margin below it, whatever follows: a
101
115
  * paragraph, a list, or bare text. CSS cannot see text nodes, so a selector such
102
116
  * as `:last-child` or `:only-child` cannot tell `<Title/>text` (needs the gap)
103
117
  * from `<Title/>` (needs none), and `not-last:mb-1` silently dropped the gap for
104
- * bare text, the most common body. The unconditional margin is the less
105
- * surprising failure: it is never missing where text follows. The price is the
106
- * title-only callout, which would end 4px taller than its padding says. Pass
107
- * `class="mb-0"` there (`cn` lets the caller's margin win); the `WithTitle` story
108
- * shows it.
118
+ * bare text, the most common body. So the gap stays the default and the one case
119
+ * that does not want it is detected by `Callout`, which sees its rendered
120
+ * children: a callout whose only child is a title carries `data-title-only`, and
121
+ * the title drops its margin so the block ends at its own padding. A caller never
122
+ * passes `mb-0` for this. Anything else after the title, text included, keeps the
123
+ * gap.
109
124
  */
110
125
  function CalloutTitle(props) {
111
126
  const [local, rest] = splitProps(props, ["class"]);
112
127
  return (() => {
113
128
  var _el$2 = _tmpl$2();
114
129
  spread(_el$2, mergeProps({ get ["class"]() {
115
- return cn("mb-1 font-medium tracking-tight", local.class);
130
+ return cn("mb-1 font-medium tracking-tight in-data-title-only:mb-0", local.class);
116
131
  } }, rest), false, false);
117
132
  return _el$2;
118
133
  })();
@@ -1 +1 @@
1
- {"version":3,"file":"callout.js","names":["ComponentProps","splitProps","cva","VariantProps","cn","calloutVariants","variants","variant","default","destructive","success","warning","info","size","sm","md","lg","defaultVariants","CalloutProps","Callout","props","local","rest","_el$","_tmpl$","_$spread","_$mergeProps","class","CalloutTitle","_el$2","_tmpl$2","_$mergeProps"],"sources":["../../../../src/components/ui/callout/callout.tsx"],"sourcesContent":["import type { ComponentProps } from 'solid-js'\nimport { splitProps } from 'solid-js'\nimport { cva, type VariantProps } from '#lib/variants'\nimport { cn } from '#lib/utils'\n\n/**\n * Callout.\n *\n * A quiet aside that belongs to the content around it: the summary at the top of\n * an answer, a note in documentation, a remembered fact beside the reply that\n * used it. It is a card-coloured block with one rule on its start edge, and\n * nothing about it interrupts the reader.\n *\n * That is the difference from `Alert`. An `Alert` is a message about the state of\n * the surface it sits in — a failed sync, a validation summary — so it is always\n * `role=\"alert\"`, a live announcement, with a tone glyph and a tinted status\n * surface. A callout is static page content: a plain `div` with `role=\"note\"`, no\n * live region, no icon, and no tint. A caller whose note really is a status\n * message uses `Alert`; one whose note needs a different landmark role passes\n * `role`. Reach for `Card` when the block is a self-contained object with its own\n * header, content and footer; a callout has no parts beyond an optional\n * `CalloutTitle`.\n *\n * The rule is the only thing that varies by `variant`: `default` is the accent\n * (`--primary`), and the four status hues use the same tokens `Alert` does. The\n * rule is a logical `border-s-*`, so it moves to the right in RTL. The corners\n * stay square because a rounded box would curve the rule into a crescent.\n *\n * `size` is the padding rung and the rule's weight together, so a caller never\n * sets `p-*` or a border width to make one denser. The rungs are Cortana's three\n * answer-view callouts, expressed in the Tailwind spacing scale:\n *\n * - `lg`: 22px / 24px padding, 3px rule — Cortana's `.answer-copy`.\n * - `md`: 18px / 20px padding, 3px rule — Cortana's `.answer-memory`.\n * - `sm`: 12px / 14px padding, 2px rule — Cortana's `.answer-note`.\n *\n * Type size and colour are inherited rather than set (only the card foreground is\n * applied), so a callout in a dense panel and one in an article both read at the\n * size of the text around them.\n *\n * Text that has no break opportunity (a 200-character token, a URL, a file path)\n * wraps with `overflow-wrap: anywhere` rather than overflowing the box. `anywhere`\n * and not `break-word`, because only `anywhere` lowers the box's min-content width:\n * a callout that is a flex or grid item (auto minimum size) then shrinks to its\n * track instead of stretching it. The callout's own padding, rule and width are\n * untouched, so none of its sizing differs from Cortana's.\n *\n * The block is deliberately not a flex or grid container: its children are inline\n * text, links and code, and a flex root would turn each of those into a separate\n * item and break the line.\n */\nexport const calloutVariants = cva(\n 'bg-card text-card-foreground block rounded-none wrap-anywhere',\n {\n variants: {\n variant: {\n default: 'border-s-primary',\n destructive: 'border-s-destructive',\n success: 'border-s-success',\n warning: 'border-s-warning',\n info: 'border-s-info',\n },\n size: {\n /** 12px block / 14px inline padding, 2px rule: a short inline note. */\n sm: 'border-s-2 px-3.5 py-3',\n /** 18px block / 20px inline padding, 3px rule: a supporting aside. */\n md: 'border-s-3 px-5 py-4.5',\n /** 22px block / 24px inline padding, 3px rule: the lead summary of a view. */\n lg: 'border-s-3 px-6 py-5.5',\n },\n },\n defaultVariants: { variant: 'default', size: 'md' },\n }\n)\n\nexport type CalloutProps = ComponentProps<'div'> & VariantProps<typeof calloutVariants>\n\nexport function Callout(props: CalloutProps) {\n const [local, rest] = splitProps(props, ['class', 'variant', 'size'])\n return (\n <div\n data-slot=\"callout\"\n role=\"note\"\n class={cn(calloutVariants({ variant: local.variant, size: local.size }), local.class)}\n {...rest}\n />\n )\n}\n\n/**\n * An optional heading line for the callout. It is a styled `div`, not a heading\n * element: a callout sits inside someone else's outline, and a fixed heading\n * level would break it. Pass `role=\"heading\"` with `aria-level` where the\n * content does belong in the outline.\n *\n * The title always carries a 4px (`mb-1`) margin below it, whatever follows: a\n * paragraph, a list, or bare text. CSS cannot see text nodes, so a selector such\n * as `:last-child` or `:only-child` cannot tell `<Title/>text` (needs the gap)\n * from `<Title/>` (needs none), and `not-last:mb-1` silently dropped the gap for\n * bare text, the most common body. The unconditional margin is the less\n * surprising failure: it is never missing where text follows. The price is the\n * title-only callout, which would end 4px taller than its padding says. Pass\n * `class=\"mb-0\"` there (`cn` lets the caller's margin win); the `WithTitle` story\n * shows it.\n */\nexport function CalloutTitle(props: ComponentProps<'div'>) {\n const [local, rest] = splitProps(props, ['class'])\n return (\n <div\n data-slot=\"callout-title\"\n class={cn('mb-1 font-medium tracking-tight', local.class)}\n {...rest}\n />\n )\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDA,IAAaK,kBAAkBH,IAC7B,iEACA;CACEI,UAAU;EACRC,SAAS;GACPC,SAAS;GACTC,aAAa;GACbC,SAAS;GACTC,SAAS;GACTC,MAAM;EACR;EACAC,MAAM;;GAEJC,IAAI;;GAEJC,IAAI;;GAEJC,IAAI;EACN;CACF;CACAC,iBAAiB;EAAEV,SAAS;EAAWM,MAAM;CAAK;AACpD,CACF;AAIA,SAAgBM,QAAQC,OAAqB;CAC3C,MAAM,CAACC,OAAOC,QAAQrB,WAAWmB,OAAO;EAAC;EAAS;EAAW;CAAM,CAAC;CACpE,cAAA;EAAA,IAAAG,OAAAC,OAAA;EAAAC,OAAAF,MAAAG,WAAA,EAAA,KAAA,WAAA;GAAA,OAIWtB,GAAGC,gBAAgB;IAAEE,SAASc,MAAMd;IAASM,MAAMQ,MAAMR;GAAK,CAAC,GAAGQ,MAAMM,KAAK;EAAC,EAAA,GACjFL,IAAI,GAAA,OAAA,KAAA;EAAA,OAAAC;CAAA,EAAA,CAAA;AAGd;;;;;;;;;;;;;;;;;AAkBA,SAAgBK,aAAaR,OAA8B;CACzD,MAAM,CAACC,OAAOC,QAAQrB,WAAWmB,OAAO,CAAC,OAAO,CAAC;CACjD,cAAA;EAAA,IAAAS,QAAAC,QAAA;EAAAL,OAAAI,OAAAH,WAAA,EAAA,KAAA,WAAA;GAAA,OAGWtB,GAAG,mCAAmCiB,MAAMM,KAAK;EAAC,EAAA,GACrDL,IAAI,GAAA,OAAA,KAAA;EAAA,OAAAO;CAAA,EAAA,CAAA;AAGd"}
1
+ {"version":3,"file":"callout.js","names":["ComponentProps","children","createMemo","splitProps","cva","VariantProps","cn","calloutVariants","variants","variant","default","destructive","success","warning","info","size","sm","md","lg","defaultVariants","CalloutProps","isRendered","node","trim","isCalloutTitle","getAttribute","Callout","props","local","rest","resolved","titleOnly","nodes","toArray","filter","length","_el$","_tmpl$","_$spread","_$mergeProps","data-title-only","undefined","class","_$insert","CalloutTitle","_el$2","_tmpl$2","_$mergeProps"],"sources":["../../../../src/components/ui/callout/callout.tsx"],"sourcesContent":["import type { ComponentProps } from 'solid-js'\nimport { children, createMemo, splitProps } from 'solid-js'\nimport { cva, type VariantProps } from '#lib/variants'\nimport { cn } from '#lib/utils'\n\n/**\n * Callout.\n *\n * A quiet aside that belongs to the content around it: the summary at the top of\n * an answer, a note in documentation, a remembered fact beside the reply that\n * used it. It is a card-coloured block with one rule on its start edge, and\n * nothing about it interrupts the reader.\n *\n * That is the difference from `Alert`. An `Alert` is a message about the state of\n * the surface it sits in — a failed sync, a validation summary — so it is always\n * `role=\"alert\"`, a live announcement, with a tone glyph and a tinted status\n * surface. A callout is static page content: a plain `div` with `role=\"note\"`, no\n * live region, no icon, and no tint. A caller whose note really is a status\n * message uses `Alert`; one whose note needs a different landmark role passes\n * `role`. Reach for `Card` when the block is a self-contained object with its own\n * header, content and footer; a callout has no parts beyond an optional\n * `CalloutTitle`.\n *\n * The rule is the only thing that varies by `variant`: `default` is the accent\n * (`--primary`), and the four status hues use the same tokens `Alert` does. The\n * rule is a logical `border-s-*`, so it moves to the right in RTL. The corners\n * stay square because a rounded box would curve the rule into a crescent.\n *\n * `size` is the padding rung and the rule's weight together, so a caller never\n * sets `p-*` or a border width to make one denser. The rungs are Cortana's three\n * answer-view callouts, expressed in the Tailwind spacing scale:\n *\n * - `lg`: 22px / 24px padding, 3px rule — Cortana's `.answer-copy`.\n * - `md`: 18px / 20px padding, 3px rule — Cortana's `.answer-memory`.\n * - `sm`: 12px / 14px padding, 2px rule — Cortana's `.answer-note`.\n *\n * Type size and colour are inherited rather than set (only the card foreground is\n * applied), so a callout in a dense panel and one in an article both read at the\n * size of the text around them.\n *\n * Text that has no break opportunity (a 200-character token, a URL, a file path)\n * wraps with `overflow-wrap: anywhere` rather than overflowing the box. `anywhere`\n * and not `break-word`, because only `anywhere` lowers the box's min-content width:\n * a callout that is a flex or grid item (auto minimum size) then shrinks to its\n * track instead of stretching it. The callout's own padding, rule and width are\n * untouched, so none of its sizing differs from Cortana's.\n *\n * The block is deliberately not a flex or grid container: its children are inline\n * text, links and code, and a flex root would turn each of those into a separate\n * item and break the line.\n */\nexport const calloutVariants = cva(\n 'bg-card text-card-foreground block rounded-none wrap-anywhere',\n {\n variants: {\n variant: {\n default: 'border-s-primary',\n destructive: 'border-s-destructive',\n success: 'border-s-success',\n warning: 'border-s-warning',\n info: 'border-s-info',\n },\n size: {\n /** 12px block / 14px inline padding, 2px rule: a short inline note. */\n sm: 'border-s-2 px-3.5 py-3',\n /** 18px block / 20px inline padding, 3px rule: a supporting aside. */\n md: 'border-s-3 px-5 py-4.5',\n /** 22px block / 24px inline padding, 3px rule: the lead summary of a view. */\n lg: 'border-s-3 px-6 py-5.5',\n },\n },\n defaultVariants: { variant: 'default', size: 'md' },\n }\n)\n\nexport type CalloutProps = ComponentProps<'div'> & VariantProps<typeof calloutVariants>\n\nconst isRendered = (node: unknown) =>\n node != null && typeof node !== 'boolean' && (typeof node !== 'string' || node.trim() !== '')\n\nconst isCalloutTitle = (node: unknown) =>\n typeof node === 'object' &&\n node !== null &&\n 'getAttribute' in node &&\n typeof node.getAttribute === 'function' &&\n node.getAttribute('data-slot') === 'callout-title'\n\nexport function Callout(props: CalloutProps) {\n const [local, rest] = splitProps(props, ['class', 'variant', 'size', 'children'])\n const resolved = children(() => local.children)\n // CSS cannot see text nodes, so the callout looks at what it renders and marks a\n // title with nothing after it. The title reads the mark to drop its bottom margin.\n const titleOnly = createMemo(() => {\n const nodes = resolved.toArray().filter(isRendered)\n return nodes.length === 1 && isCalloutTitle(nodes[0])\n })\n return (\n <div\n data-slot=\"callout\"\n data-title-only={titleOnly() ? '' : undefined}\n role=\"note\"\n class={cn(calloutVariants({ variant: local.variant, size: local.size }), local.class)}\n {...rest}\n >\n {resolved()}\n </div>\n )\n}\n\n/**\n * An optional heading line for the callout. It is a styled `div`, not a heading\n * element: a callout sits inside someone else's outline, and a fixed heading\n * level would break it. Pass `role=\"heading\"` with `aria-level` where the\n * content does belong in the outline.\n *\n * The title carries a 4px (`mb-1`) margin below it, whatever follows: a\n * paragraph, a list, or bare text. CSS cannot see text nodes, so a selector such\n * as `:last-child` or `:only-child` cannot tell `<Title/>text` (needs the gap)\n * from `<Title/>` (needs none), and `not-last:mb-1` silently dropped the gap for\n * bare text, the most common body. So the gap stays the default and the one case\n * that does not want it is detected by `Callout`, which sees its rendered\n * children: a callout whose only child is a title carries `data-title-only`, and\n * the title drops its margin so the block ends at its own padding. A caller never\n * passes `mb-0` for this. Anything else after the title, text included, keeps the\n * gap.\n */\nexport function CalloutTitle(props: ComponentProps<'div'>) {\n const [local, rest] = splitProps(props, ['class'])\n return (\n <div\n data-slot=\"callout-title\"\n class={cn('mb-1 font-medium tracking-tight in-data-title-only:mb-0', local.class)}\n {...rest}\n />\n )\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDA,IAAaO,kBAAkBH,IAC7B,iEACA;CACEI,UAAU;EACRC,SAAS;GACPC,SAAS;GACTC,aAAa;GACbC,SAAS;GACTC,SAAS;GACTC,MAAM;EACR;EACAC,MAAM;;GAEJC,IAAI;;GAEJC,IAAI;;GAEJC,IAAI;EACN;CACF;CACAC,iBAAiB;EAAEV,SAAS;EAAWM,MAAM;CAAK;AACpD,CACF;AAIA,IAAMM,cAAcC,SAClBA,QAAQ,QAAQ,OAAOA,SAAS,cAAc,OAAOA,SAAS,YAAYA,KAAKC,KAAK,MAAM;AAE5F,IAAMC,kBAAkBF,SACtB,OAAOA,SAAS,YAChBA,SAAS,QACT,kBAAkBA,QAClB,OAAOA,KAAKG,iBAAiB,cAC7BH,KAAKG,aAAa,WAAW,MAAM;AAErC,SAAgBC,QAAQC,OAAqB;CAC3C,MAAM,CAACC,OAAOC,QAAQ1B,WAAWwB,OAAO;EAAC;EAAS;EAAW;EAAQ;CAAU,CAAC;CAChF,MAAMG,WAAW7B,eAAe2B,MAAM3B,QAAQ;CAG9C,MAAM8B,YAAY7B,iBAAiB;EACjC,MAAM8B,QAAQF,SAASG,QAAQ,CAAC,CAACC,OAAOb,UAAU;EAClD,OAAOW,MAAMG,WAAW,KAAKX,eAAeQ,MAAM,EAAE;CACtD,CAAC;CACD,cAAA;EAAA,IAAAI,OAAAC,OAAA;EAAAC,OAAAF,MAAAG,WAAA;GAAA,KAAA,qBAAA;IAAA,OAGqBR,UAAU,IAAI,KAAKU,KAAAA;GAAS;GAAA,KAAA,WAAA;IAAA,OAEtCnC,GAAGC,gBAAgB;KAAEE,SAASmB,MAAMnB;KAASM,MAAMa,MAAMb;IAAK,CAAC,GAAGa,MAAMc,KAAK;GAAC;EAAA,GACjFb,IAAI,GAAA,OAAA,IAAA;EAAAc,OAAAP,MAEPN,QAAQ;EAAA,OAAAM;CAAA,EAAA,CAAA;AAGf;;;;;;;;;;;;;;;;;;AAmBA,SAAgBQ,aAAajB,OAA8B;CACzD,MAAM,CAACC,OAAOC,QAAQ1B,WAAWwB,OAAO,CAAC,OAAO,CAAC;CACjD,cAAA;EAAA,IAAAkB,QAAAC,QAAA;EAAAR,OAAAO,OAAAN,WAAA,EAAA,KAAA,WAAA;GAAA,OAGWjC,GAAG,2DAA2DsB,MAAMc,KAAK;EAAC,EAAA,GAC7Eb,IAAI,GAAA,OAAA,KAAA;EAAA,OAAAgB;CAAA,EAAA,CAAA;AAGd"}
@@ -25,6 +25,7 @@ type TreeContextValue = {
25
25
  unregister: (id: string, element: HTMLDivElement | undefined) => void
26
26
  focusItem: (id: string) => void
27
27
  noteFocus: (id: string) => void
28
+ noteBlur: (next: EventTarget | null) => void
28
29
  activate: (id: string, event: Event) => void
29
30
  expand: (id: string, expanded: boolean) => void
30
31
  disclosure: () => TreeDisclosure
@@ -114,8 +115,14 @@ export type TreeProps = Omit<
114
115
  * the target row: when the active row leaves `visibleItems`, the first visible
115
116
  * row becomes the roving stop and `onActiveIdChange` is called with it. If the
116
117
  * removed row held focus, or was the last row the tree saw focused, focus moves
117
- * to that same row; if it had no focus, only the roving stop moves. This is the
118
- * same in both `disclosure` modes and for every way a host collapses a row.
118
+ * to that same row; if it had no focus, only the roving stop moves. Focus that
119
+ * has already gone to a control outside the tree is never pulled back: the tree
120
+ * forgets its last focused row when focus moves to an element outside it, and
121
+ * the roving stop alone moves if that control still has focus when the row is
122
+ * hidden. Focus the tree has asked for but not yet moved (a queued recovery, or a
123
+ * keyboard move to a row the host has not mounted) is withdrawn when focus lands
124
+ * outside the tree, and is checked again when a row mounts. This is the same in
125
+ * both `disclosure` modes and for every way a host collapses a row.
119
126
  */
120
127
  export function Tree(props: TreeProps) {
121
128
  const [local, rest] = splitProps(props, [
@@ -143,9 +150,26 @@ export function Tree(props: TreeProps) {
143
150
  const rows = new Map<string, HTMLDivElement>()
144
151
  const rowIdsByElement = new WeakMap<HTMLDivElement, string>()
145
152
  let pendingFocusId: string | null = null
153
+ let pendingFocusGuard: ((event: FocusEvent) => void) | undefined
146
154
  let requestedRevealId: string | null = null
147
155
  let focusedId: string | null = null
148
156
  let disposed = false
157
+ const isInsideTreeRow = (element: Element) => {
158
+ const row = element.closest('[role="treeitem"]')
159
+ return row instanceof HTMLDivElement && rowIdsByElement.has(row)
160
+ }
161
+ // True when a focusable control outside this tree owns focus. Nothing focused
162
+ // (`body`) is not outside: that is where focus falls when a focused row is removed.
163
+ const focusIsOutsideTree = () => {
164
+ if (typeof document === 'undefined') return false
165
+ const active = document.activeElement
166
+ return (
167
+ active instanceof Element &&
168
+ active !== document.body &&
169
+ active !== document.documentElement &&
170
+ !isInsideTreeRow(active)
171
+ )
172
+ }
149
173
  const rowSizeObserver =
150
174
  typeof ResizeObserver === 'undefined'
151
175
  ? undefined
@@ -160,8 +184,33 @@ export function Tree(props: TreeProps) {
160
184
  }
161
185
  })
162
186
 
187
+ // Focus the tree has asked for but not yet moved (a microtask away, or waiting for a
188
+ // virtualised row to mount) is only a request. While one is pending, focus arriving
189
+ // on a control outside the tree withdraws it, so a row mounting later cannot take
190
+ // focus back from a control the user, or the host, has since chosen.
191
+ const setPendingFocus = (id: string | null) => {
192
+ pendingFocusId = id
193
+ if (typeof document === 'undefined') return
194
+ if (id === null) {
195
+ if (pendingFocusGuard) document.removeEventListener('focusin', pendingFocusGuard, true)
196
+ pendingFocusGuard = undefined
197
+ } else if (!pendingFocusGuard) {
198
+ pendingFocusGuard = (event) => {
199
+ if (event.target instanceof Element && !isInsideTreeRow(event.target)) cancelPendingFocus()
200
+ }
201
+ document.addEventListener('focusin', pendingFocusGuard, true)
202
+ }
203
+ }
204
+ // Withdrawing a request also drops the memory of the row it was recovering for:
205
+ // nothing is left to recover.
206
+ const cancelPendingFocus = () => {
207
+ setPendingFocus(null)
208
+ focusedId = null
209
+ }
210
+
163
211
  onCleanup(() => {
164
212
  disposed = true
213
+ setPendingFocus(null)
165
214
  rowSizeObserver?.disconnect()
166
215
  })
167
216
 
@@ -176,13 +225,19 @@ export function Tree(props: TreeProps) {
176
225
  if (pendingFocusId !== id || effectiveActiveId() !== id) return
177
226
  const row = rows.get(id)
178
227
  if (!row || typeof document === 'undefined') return
228
+ // Ownership is checked when focus would move, not when it was requested: a
229
+ // control outside the tree may have taken focus since, and keeps it.
230
+ if (focusIsOutsideTree()) {
231
+ cancelPendingFocus()
232
+ return
233
+ }
179
234
  row.focus()
180
- if (document.activeElement === row) pendingFocusId = null
235
+ if (document.activeElement === row) setPendingFocus(null)
181
236
  })
182
237
  }
183
238
 
184
239
  const focusItem = (id: string) => {
185
- pendingFocusId = id
240
+ setPendingFocus(id)
186
241
  local.onActiveIdChange(id)
187
242
  if (rows.has(id)) requestedRevealId = null
188
243
  else requestReveal(id)
@@ -216,8 +271,14 @@ export function Tree(props: TreeProps) {
216
271
  if (focusWasInside) {
217
272
  queueMicrotask(() => {
218
273
  const fallbackId = effectiveActiveId()
219
- if (!disposed && fallbackId !== null && pendingFocusId !== fallbackId)
220
- focusItem(fallbackId)
274
+ if (disposed || fallbackId === null || pendingFocusId === fallbackId) return
275
+ // Focus fell out of the removed row, but it may have been given to a control
276
+ // outside the tree before this ran. Only nothing-focused (body) recovers.
277
+ if (focusIsOutsideTree()) {
278
+ focusedId = null
279
+ return
280
+ }
281
+ focusItem(fallbackId)
221
282
  })
222
283
  }
223
284
  },
@@ -225,6 +286,13 @@ export function Tree(props: TreeProps) {
225
286
  noteFocus: (id) => {
226
287
  focusedId = id
227
288
  },
289
+ noteBlur: (next) => {
290
+ // Focus moving to an element outside this tree ends the memory of the last
291
+ // focused row and withdraws any focus the tree was still waiting to give a
292
+ // row. A null target (focus to nothing, or the window losing focus) is
293
+ // ambiguous, so both stay for the recovery below.
294
+ if (next instanceof Element && !isInsideTreeRow(next)) cancelPendingFocus()
295
+ },
228
296
  activate: (id, event) => local.onActivate?.(id, event),
229
297
  expand: (id, expanded) => local.onExpand?.(id, expanded),
230
298
  disclosure: () => local.disclosure ?? 'row',
@@ -239,11 +307,15 @@ export function Tree(props: TreeProps) {
239
307
  if (activeId === null) return
240
308
  // If the projection removes the last row that took focus (a host filter, or
241
309
  // an ancestor collapsing), move focus to the same fallback that now owns the
242
- // roving tab stop. `focusedId` is not cleared when focus leaves the tree, so
243
- // this also reclaims focus from a control outside it; that long-standing
244
- // behaviour is deliberately left as it was.
310
+ // roving tab stop, unless a control outside the tree has focus: that control
311
+ // keeps it, and only the roving stop moves.
245
312
  if (focusedId !== null && !index().byId.has(focusedId) && pendingFocusId !== activeId) {
246
- focusItem(activeId)
313
+ if (focusIsOutsideTree()) {
314
+ focusedId = null
315
+ if (local.activeId !== activeId) local.onActiveIdChange(activeId)
316
+ } else {
317
+ focusItem(activeId)
318
+ }
247
319
  } else if (local.activeId !== activeId) {
248
320
  local.onActiveIdChange(activeId)
249
321
  }
@@ -354,12 +426,19 @@ export function TreeRow(props: TreeRowProps) {
354
426
  let registeredId: string | undefined
355
427
  const id = () => local.item.id
356
428
 
357
- onCleanup(() => tree.unregister(registeredId ?? id(), row))
429
+ const handleFocusOut = (event: FocusEvent) => tree.noteBlur(event.relatedTarget)
430
+
431
+ onCleanup(() => {
432
+ row?.removeEventListener('focusout', handleFocusOut)
433
+ tree.unregister(registeredId ?? id(), row)
434
+ })
358
435
 
359
436
  const setRef = (element: HTMLDivElement) => {
360
437
  row = element
361
438
  registeredId = id()
362
439
  tree.register(registeredId, element)
440
+ // A listener, not an `onFocusOut` prop, so a host's own handler is untouched.
441
+ element.addEventListener('focusout', handleFocusOut)
363
442
  }
364
443
 
365
444
  createEffect(() => {
@@ -1,5 +1,5 @@
1
1
  import type { ComponentProps } from 'solid-js'
2
- import { splitProps } from 'solid-js'
2
+ import { children, createMemo, splitProps } from 'solid-js'
3
3
  import { cva, type VariantProps } from '../../../lib/variants'
4
4
  import { cn } from '../../../lib/utils'
5
5
 
@@ -75,15 +75,35 @@ export const calloutVariants = cva(
75
75
 
76
76
  export type CalloutProps = ComponentProps<'div'> & VariantProps<typeof calloutVariants>
77
77
 
78
+ const isRendered = (node: unknown) =>
79
+ node != null && typeof node !== 'boolean' && (typeof node !== 'string' || node.trim() !== '')
80
+
81
+ const isCalloutTitle = (node: unknown) =>
82
+ typeof node === 'object' &&
83
+ node !== null &&
84
+ 'getAttribute' in node &&
85
+ typeof node.getAttribute === 'function' &&
86
+ node.getAttribute('data-slot') === 'callout-title'
87
+
78
88
  export function Callout(props: CalloutProps) {
79
- const [local, rest] = splitProps(props, ['class', 'variant', 'size'])
89
+ const [local, rest] = splitProps(props, ['class', 'variant', 'size', 'children'])
90
+ const resolved = children(() => local.children)
91
+ // CSS cannot see text nodes, so the callout looks at what it renders and marks a
92
+ // title with nothing after it. The title reads the mark to drop its bottom margin.
93
+ const titleOnly = createMemo(() => {
94
+ const nodes = resolved.toArray().filter(isRendered)
95
+ return nodes.length === 1 && isCalloutTitle(nodes[0])
96
+ })
80
97
  return (
81
98
  <div
82
99
  data-slot="callout"
100
+ data-title-only={titleOnly() ? '' : undefined}
83
101
  role="note"
84
102
  class={cn(calloutVariants({ variant: local.variant, size: local.size }), local.class)}
85
103
  {...rest}
86
- />
104
+ >
105
+ {resolved()}
106
+ </div>
87
107
  )
88
108
  }
89
109
 
@@ -93,22 +113,23 @@ export function Callout(props: CalloutProps) {
93
113
  * level would break it. Pass `role="heading"` with `aria-level` where the
94
114
  * content does belong in the outline.
95
115
  *
96
- * The title always carries a 4px (`mb-1`) margin below it, whatever follows: a
116
+ * The title carries a 4px (`mb-1`) margin below it, whatever follows: a
97
117
  * paragraph, a list, or bare text. CSS cannot see text nodes, so a selector such
98
118
  * as `:last-child` or `:only-child` cannot tell `<Title/>text` (needs the gap)
99
119
  * from `<Title/>` (needs none), and `not-last:mb-1` silently dropped the gap for
100
- * bare text, the most common body. The unconditional margin is the less
101
- * surprising failure: it is never missing where text follows. The price is the
102
- * title-only callout, which would end 4px taller than its padding says. Pass
103
- * `class="mb-0"` there (`cn` lets the caller's margin win); the `WithTitle` story
104
- * shows it.
120
+ * bare text, the most common body. So the gap stays the default and the one case
121
+ * that does not want it is detected by `Callout`, which sees its rendered
122
+ * children: a callout whose only child is a title carries `data-title-only`, and
123
+ * the title drops its margin so the block ends at its own padding. A caller never
124
+ * passes `mb-0` for this. Anything else after the title, text included, keeps the
125
+ * gap.
105
126
  */
106
127
  export function CalloutTitle(props: ComponentProps<'div'>) {
107
128
  const [local, rest] = splitProps(props, ['class'])
108
129
  return (
109
130
  <div
110
131
  data-slot="callout-title"
111
- class={cn('mb-1 font-medium tracking-tight', local.class)}
132
+ class={cn('mb-1 font-medium tracking-tight in-data-title-only:mb-0', local.class)}
112
133
  {...rest}
113
134
  />
114
135
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.138.0",
3
+ "version": "0.138.1",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",
@@ -25,6 +25,7 @@ type TreeContextValue = {
25
25
  unregister: (id: string, element: HTMLDivElement | undefined) => void
26
26
  focusItem: (id: string) => void
27
27
  noteFocus: (id: string) => void
28
+ noteBlur: (next: EventTarget | null) => void
28
29
  activate: (id: string, event: Event) => void
29
30
  expand: (id: string, expanded: boolean) => void
30
31
  disclosure: () => TreeDisclosure
@@ -114,8 +115,14 @@ export type TreeProps = Omit<
114
115
  * the target row: when the active row leaves `visibleItems`, the first visible
115
116
  * row becomes the roving stop and `onActiveIdChange` is called with it. If the
116
117
  * removed row held focus, or was the last row the tree saw focused, focus moves
117
- * to that same row; if it had no focus, only the roving stop moves. This is the
118
- * same in both `disclosure` modes and for every way a host collapses a row.
118
+ * to that same row; if it had no focus, only the roving stop moves. Focus that
119
+ * has already gone to a control outside the tree is never pulled back: the tree
120
+ * forgets its last focused row when focus moves to an element outside it, and
121
+ * the roving stop alone moves if that control still has focus when the row is
122
+ * hidden. Focus the tree has asked for but not yet moved (a queued recovery, or a
123
+ * keyboard move to a row the host has not mounted) is withdrawn when focus lands
124
+ * outside the tree, and is checked again when a row mounts. This is the same in
125
+ * both `disclosure` modes and for every way a host collapses a row.
119
126
  */
120
127
  export function Tree(props: TreeProps) {
121
128
  const [local, rest] = splitProps(props, [
@@ -143,9 +150,26 @@ export function Tree(props: TreeProps) {
143
150
  const rows = new Map<string, HTMLDivElement>()
144
151
  const rowIdsByElement = new WeakMap<HTMLDivElement, string>()
145
152
  let pendingFocusId: string | null = null
153
+ let pendingFocusGuard: ((event: FocusEvent) => void) | undefined
146
154
  let requestedRevealId: string | null = null
147
155
  let focusedId: string | null = null
148
156
  let disposed = false
157
+ const isInsideTreeRow = (element: Element) => {
158
+ const row = element.closest('[role="treeitem"]')
159
+ return row instanceof HTMLDivElement && rowIdsByElement.has(row)
160
+ }
161
+ // True when a focusable control outside this tree owns focus. Nothing focused
162
+ // (`body`) is not outside: that is where focus falls when a focused row is removed.
163
+ const focusIsOutsideTree = () => {
164
+ if (typeof document === 'undefined') return false
165
+ const active = document.activeElement
166
+ return (
167
+ active instanceof Element &&
168
+ active !== document.body &&
169
+ active !== document.documentElement &&
170
+ !isInsideTreeRow(active)
171
+ )
172
+ }
149
173
  const rowSizeObserver =
150
174
  typeof ResizeObserver === 'undefined'
151
175
  ? undefined
@@ -160,8 +184,33 @@ export function Tree(props: TreeProps) {
160
184
  }
161
185
  })
162
186
 
187
+ // Focus the tree has asked for but not yet moved (a microtask away, or waiting for a
188
+ // virtualised row to mount) is only a request. While one is pending, focus arriving
189
+ // on a control outside the tree withdraws it, so a row mounting later cannot take
190
+ // focus back from a control the user, or the host, has since chosen.
191
+ const setPendingFocus = (id: string | null) => {
192
+ pendingFocusId = id
193
+ if (typeof document === 'undefined') return
194
+ if (id === null) {
195
+ if (pendingFocusGuard) document.removeEventListener('focusin', pendingFocusGuard, true)
196
+ pendingFocusGuard = undefined
197
+ } else if (!pendingFocusGuard) {
198
+ pendingFocusGuard = (event) => {
199
+ if (event.target instanceof Element && !isInsideTreeRow(event.target)) cancelPendingFocus()
200
+ }
201
+ document.addEventListener('focusin', pendingFocusGuard, true)
202
+ }
203
+ }
204
+ // Withdrawing a request also drops the memory of the row it was recovering for:
205
+ // nothing is left to recover.
206
+ const cancelPendingFocus = () => {
207
+ setPendingFocus(null)
208
+ focusedId = null
209
+ }
210
+
163
211
  onCleanup(() => {
164
212
  disposed = true
213
+ setPendingFocus(null)
165
214
  rowSizeObserver?.disconnect()
166
215
  })
167
216
 
@@ -176,13 +225,19 @@ export function Tree(props: TreeProps) {
176
225
  if (pendingFocusId !== id || effectiveActiveId() !== id) return
177
226
  const row = rows.get(id)
178
227
  if (!row || typeof document === 'undefined') return
228
+ // Ownership is checked when focus would move, not when it was requested: a
229
+ // control outside the tree may have taken focus since, and keeps it.
230
+ if (focusIsOutsideTree()) {
231
+ cancelPendingFocus()
232
+ return
233
+ }
179
234
  row.focus()
180
- if (document.activeElement === row) pendingFocusId = null
235
+ if (document.activeElement === row) setPendingFocus(null)
181
236
  })
182
237
  }
183
238
 
184
239
  const focusItem = (id: string) => {
185
- pendingFocusId = id
240
+ setPendingFocus(id)
186
241
  local.onActiveIdChange(id)
187
242
  if (rows.has(id)) requestedRevealId = null
188
243
  else requestReveal(id)
@@ -216,8 +271,14 @@ export function Tree(props: TreeProps) {
216
271
  if (focusWasInside) {
217
272
  queueMicrotask(() => {
218
273
  const fallbackId = effectiveActiveId()
219
- if (!disposed && fallbackId !== null && pendingFocusId !== fallbackId)
220
- focusItem(fallbackId)
274
+ if (disposed || fallbackId === null || pendingFocusId === fallbackId) return
275
+ // Focus fell out of the removed row, but it may have been given to a control
276
+ // outside the tree before this ran. Only nothing-focused (body) recovers.
277
+ if (focusIsOutsideTree()) {
278
+ focusedId = null
279
+ return
280
+ }
281
+ focusItem(fallbackId)
221
282
  })
222
283
  }
223
284
  },
@@ -225,6 +286,13 @@ export function Tree(props: TreeProps) {
225
286
  noteFocus: (id) => {
226
287
  focusedId = id
227
288
  },
289
+ noteBlur: (next) => {
290
+ // Focus moving to an element outside this tree ends the memory of the last
291
+ // focused row and withdraws any focus the tree was still waiting to give a
292
+ // row. A null target (focus to nothing, or the window losing focus) is
293
+ // ambiguous, so both stay for the recovery below.
294
+ if (next instanceof Element && !isInsideTreeRow(next)) cancelPendingFocus()
295
+ },
228
296
  activate: (id, event) => local.onActivate?.(id, event),
229
297
  expand: (id, expanded) => local.onExpand?.(id, expanded),
230
298
  disclosure: () => local.disclosure ?? 'row',
@@ -239,11 +307,15 @@ export function Tree(props: TreeProps) {
239
307
  if (activeId === null) return
240
308
  // If the projection removes the last row that took focus (a host filter, or
241
309
  // an ancestor collapsing), move focus to the same fallback that now owns the
242
- // roving tab stop. `focusedId` is not cleared when focus leaves the tree, so
243
- // this also reclaims focus from a control outside it; that long-standing
244
- // behaviour is deliberately left as it was.
310
+ // roving tab stop, unless a control outside the tree has focus: that control
311
+ // keeps it, and only the roving stop moves.
245
312
  if (focusedId !== null && !index().byId.has(focusedId) && pendingFocusId !== activeId) {
246
- focusItem(activeId)
313
+ if (focusIsOutsideTree()) {
314
+ focusedId = null
315
+ if (local.activeId !== activeId) local.onActiveIdChange(activeId)
316
+ } else {
317
+ focusItem(activeId)
318
+ }
247
319
  } else if (local.activeId !== activeId) {
248
320
  local.onActiveIdChange(activeId)
249
321
  }
@@ -354,12 +426,19 @@ export function TreeRow(props: TreeRowProps) {
354
426
  let registeredId: string | undefined
355
427
  const id = () => local.item.id
356
428
 
357
- onCleanup(() => tree.unregister(registeredId ?? id(), row))
429
+ const handleFocusOut = (event: FocusEvent) => tree.noteBlur(event.relatedTarget)
430
+
431
+ onCleanup(() => {
432
+ row?.removeEventListener('focusout', handleFocusOut)
433
+ tree.unregister(registeredId ?? id(), row)
434
+ })
358
435
 
359
436
  const setRef = (element: HTMLDivElement) => {
360
437
  row = element
361
438
  registeredId = id()
362
439
  tree.register(registeredId, element)
440
+ // A listener, not an `onFocusOut` prop, so a host's own handler is untouched.
441
+ element.addEventListener('focusout', handleFocusOut)
363
442
  }
364
443
 
365
444
  createEffect(() => {
@@ -1,5 +1,5 @@
1
1
  import type { ComponentProps } from 'solid-js'
2
- import { splitProps } from 'solid-js'
2
+ import { children, createMemo, splitProps } from 'solid-js'
3
3
  import { cva, type VariantProps } from '#lib/variants'
4
4
  import { cn } from '#lib/utils'
5
5
 
@@ -75,15 +75,35 @@ export const calloutVariants = cva(
75
75
 
76
76
  export type CalloutProps = ComponentProps<'div'> & VariantProps<typeof calloutVariants>
77
77
 
78
+ const isRendered = (node: unknown) =>
79
+ node != null && typeof node !== 'boolean' && (typeof node !== 'string' || node.trim() !== '')
80
+
81
+ const isCalloutTitle = (node: unknown) =>
82
+ typeof node === 'object' &&
83
+ node !== null &&
84
+ 'getAttribute' in node &&
85
+ typeof node.getAttribute === 'function' &&
86
+ node.getAttribute('data-slot') === 'callout-title'
87
+
78
88
  export function Callout(props: CalloutProps) {
79
- const [local, rest] = splitProps(props, ['class', 'variant', 'size'])
89
+ const [local, rest] = splitProps(props, ['class', 'variant', 'size', 'children'])
90
+ const resolved = children(() => local.children)
91
+ // CSS cannot see text nodes, so the callout looks at what it renders and marks a
92
+ // title with nothing after it. The title reads the mark to drop its bottom margin.
93
+ const titleOnly = createMemo(() => {
94
+ const nodes = resolved.toArray().filter(isRendered)
95
+ return nodes.length === 1 && isCalloutTitle(nodes[0])
96
+ })
80
97
  return (
81
98
  <div
82
99
  data-slot="callout"
100
+ data-title-only={titleOnly() ? '' : undefined}
83
101
  role="note"
84
102
  class={cn(calloutVariants({ variant: local.variant, size: local.size }), local.class)}
85
103
  {...rest}
86
- />
104
+ >
105
+ {resolved()}
106
+ </div>
87
107
  )
88
108
  }
89
109
 
@@ -93,22 +113,23 @@ export function Callout(props: CalloutProps) {
93
113
  * level would break it. Pass `role="heading"` with `aria-level` where the
94
114
  * content does belong in the outline.
95
115
  *
96
- * The title always carries a 4px (`mb-1`) margin below it, whatever follows: a
116
+ * The title carries a 4px (`mb-1`) margin below it, whatever follows: a
97
117
  * paragraph, a list, or bare text. CSS cannot see text nodes, so a selector such
98
118
  * as `:last-child` or `:only-child` cannot tell `<Title/>text` (needs the gap)
99
119
  * from `<Title/>` (needs none), and `not-last:mb-1` silently dropped the gap for
100
- * bare text, the most common body. The unconditional margin is the less
101
- * surprising failure: it is never missing where text follows. The price is the
102
- * title-only callout, which would end 4px taller than its padding says. Pass
103
- * `class="mb-0"` there (`cn` lets the caller's margin win); the `WithTitle` story
104
- * shows it.
120
+ * bare text, the most common body. So the gap stays the default and the one case
121
+ * that does not want it is detected by `Callout`, which sees its rendered
122
+ * children: a callout whose only child is a title carries `data-title-only`, and
123
+ * the title drops its margin so the block ends at its own padding. A caller never
124
+ * passes `mb-0` for this. Anything else after the title, text included, keeps the
125
+ * gap.
105
126
  */
106
127
  export function CalloutTitle(props: ComponentProps<'div'>) {
107
128
  const [local, rest] = splitProps(props, ['class'])
108
129
  return (
109
130
  <div
110
131
  data-slot="callout-title"
111
- class={cn('mb-1 font-medium tracking-tight', local.class)}
132
+ class={cn('mb-1 font-medium tracking-tight in-data-title-only:mb-0', local.class)}
112
133
  {...rest}
113
134
  />
114
135
  )