@adea-ai/ui 0.137.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.
- package/dist/components/composites/tree/tree.d.ts +8 -2
- package/dist/components/composites/tree/tree.d.ts.map +1 -1
- package/dist/components/composites/tree/tree.js +63 -8
- package/dist/components/composites/tree/tree.js.map +1 -1
- package/dist/components/ui/alert-dialog/alert-dialog.d.ts +5 -4
- package/dist/components/ui/alert-dialog/alert-dialog.d.ts.map +1 -1
- package/dist/components/ui/alert-dialog/alert-dialog.js +18 -13
- package/dist/components/ui/alert-dialog/alert-dialog.js.map +1 -1
- package/dist/components/ui/callout/callout.d.ts +7 -6
- package/dist/components/ui/callout/callout.d.ts.map +1 -1
- package/dist/components/ui/callout/callout.js +31 -16
- package/dist/components/ui/callout/callout.js.map +1 -1
- package/dist/r/src/components/composites/tree/tree.tsx +90 -11
- package/dist/r/src/components/ui/alert-dialog/alert-dialog.tsx +18 -13
- package/dist/r/src/components/ui/callout/callout.tsx +31 -10
- package/package.json +1 -1
- package/src/components/composites/tree/tree.tsx +90 -11
- package/src/components/ui/alert-dialog/alert-dialog.tsx +18 -13
- package/src/components/ui/callout/callout.tsx +31 -10
|
@@ -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
|
|
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.
|
|
118
|
-
*
|
|
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)
|
|
235
|
+
if (document.activeElement === row) setPendingFocus(null)
|
|
181
236
|
})
|
|
182
237
|
}
|
|
183
238
|
|
|
184
239
|
const focusItem = (id: string) => {
|
|
185
|
-
|
|
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 (
|
|
220
|
-
|
|
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
|
|
243
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|
|
@@ -17,10 +17,11 @@ import { cn } from '../../../lib/utils'
|
|
|
17
17
|
/**
|
|
18
18
|
* AlertDialog.
|
|
19
19
|
*
|
|
20
|
-
* A dialog that cannot be dismissed by accident:
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* A dialog that cannot be dismissed by accident: a click on the scrim does nothing,
|
|
21
|
+
* and the user must choose one of the actions. Escape is the keyboard's Cancel: it
|
|
22
|
+
* closes the dialog exactly as the cancel action does, so nothing is confirmed. That
|
|
23
|
+
* is the whole difference from Dialog, and it is why this exists as its own
|
|
24
|
+
* component rather than a prop — a caller has to reach for it deliberately.
|
|
24
25
|
*
|
|
25
26
|
* The consequence is a rule of use: this is for confirming a destructive or
|
|
26
27
|
* irreversible step. Wrapping an ordinary form in an AlertDialog traps someone who
|
|
@@ -59,22 +60,26 @@ export function AlertDialogOverlay(props: ComponentProps<typeof KobalteAlertDial
|
|
|
59
60
|
* The panel.
|
|
60
61
|
*
|
|
61
62
|
* Kobalte's `AlertDialog.Content` is `Dialog.Content` with `role="alertdialog"`
|
|
62
|
-
* and nothing else —
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* Dialog:
|
|
63
|
+
* and nothing else — an outside click dismisses it exactly as it dismisses a
|
|
64
|
+
* Dialog. That is a real gap in the primitive, so the blocking is implemented
|
|
65
|
+
* here, and it is the whole difference between this component and a Dialog:
|
|
66
66
|
*
|
|
67
|
-
* - Escape is prevented, because a key that closes a confirmation is a key that
|
|
68
|
-
* makes "confirm" meaningless.
|
|
69
67
|
* - Pointer and focus interactions outside are prevented, including the scrim
|
|
70
|
-
* click,
|
|
68
|
+
* click, because a stray click that closes a confirmation is a click that
|
|
69
|
+
* makes "confirm" meaningless.
|
|
70
|
+
*
|
|
71
|
+
* Escape is deliberately left to the primitive. It can only ever cancel, never
|
|
72
|
+
* confirm, so it is not an accident that matters; refusing it traps a keyboard
|
|
73
|
+
* user who has changed their mind, and the WAI-ARIA alertdialog pattern says
|
|
74
|
+
* Escape closes. It reports `onOpenChange(false)` and restores focus to the
|
|
75
|
+
* trigger, which is what the cancel action does, and as the topmost layer it
|
|
76
|
+
* closes only the confirmation, not a dialog underneath it.
|
|
71
77
|
*
|
|
72
|
-
* There is deliberately no prop to turn
|
|
78
|
+
* There is deliberately no prop to turn the scrim blocking off. A surface that can be escaped
|
|
73
79
|
* accidentally does not need to be an AlertDialog; it is a Dialog, and using this
|
|
74
80
|
* one would be a claim about the flow that is not true.
|
|
75
81
|
*/
|
|
76
82
|
const blockDismissal = {
|
|
77
|
-
onEscapeKeyDown: (event: KeyboardEvent) => event.preventDefault(),
|
|
78
83
|
onPointerDownOutside: (event: Event) => event.preventDefault(),
|
|
79
84
|
onFocusOutside: (event: Event) => event.preventDefault(),
|
|
80
85
|
onInteractOutside: (event: Event) => event.preventDefault(),
|
|
@@ -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
|
|
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.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
@@ -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.
|
|
118
|
-
*
|
|
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)
|
|
235
|
+
if (document.activeElement === row) setPendingFocus(null)
|
|
181
236
|
})
|
|
182
237
|
}
|
|
183
238
|
|
|
184
239
|
const focusItem = (id: string) => {
|
|
185
|
-
|
|
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 (
|
|
220
|
-
|
|
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
|
|
243
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|
|
@@ -17,10 +17,11 @@ import { cn } from '#lib/utils'
|
|
|
17
17
|
/**
|
|
18
18
|
* AlertDialog.
|
|
19
19
|
*
|
|
20
|
-
* A dialog that cannot be dismissed by accident:
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* A dialog that cannot be dismissed by accident: a click on the scrim does nothing,
|
|
21
|
+
* and the user must choose one of the actions. Escape is the keyboard's Cancel: it
|
|
22
|
+
* closes the dialog exactly as the cancel action does, so nothing is confirmed. That
|
|
23
|
+
* is the whole difference from Dialog, and it is why this exists as its own
|
|
24
|
+
* component rather than a prop — a caller has to reach for it deliberately.
|
|
24
25
|
*
|
|
25
26
|
* The consequence is a rule of use: this is for confirming a destructive or
|
|
26
27
|
* irreversible step. Wrapping an ordinary form in an AlertDialog traps someone who
|
|
@@ -59,22 +60,26 @@ export function AlertDialogOverlay(props: ComponentProps<typeof KobalteAlertDial
|
|
|
59
60
|
* The panel.
|
|
60
61
|
*
|
|
61
62
|
* Kobalte's `AlertDialog.Content` is `Dialog.Content` with `role="alertdialog"`
|
|
62
|
-
* and nothing else —
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* Dialog:
|
|
63
|
+
* and nothing else — an outside click dismisses it exactly as it dismisses a
|
|
64
|
+
* Dialog. That is a real gap in the primitive, so the blocking is implemented
|
|
65
|
+
* here, and it is the whole difference between this component and a Dialog:
|
|
66
66
|
*
|
|
67
|
-
* - Escape is prevented, because a key that closes a confirmation is a key that
|
|
68
|
-
* makes "confirm" meaningless.
|
|
69
67
|
* - Pointer and focus interactions outside are prevented, including the scrim
|
|
70
|
-
* click,
|
|
68
|
+
* click, because a stray click that closes a confirmation is a click that
|
|
69
|
+
* makes "confirm" meaningless.
|
|
70
|
+
*
|
|
71
|
+
* Escape is deliberately left to the primitive. It can only ever cancel, never
|
|
72
|
+
* confirm, so it is not an accident that matters; refusing it traps a keyboard
|
|
73
|
+
* user who has changed their mind, and the WAI-ARIA alertdialog pattern says
|
|
74
|
+
* Escape closes. It reports `onOpenChange(false)` and restores focus to the
|
|
75
|
+
* trigger, which is what the cancel action does, and as the topmost layer it
|
|
76
|
+
* closes only the confirmation, not a dialog underneath it.
|
|
71
77
|
*
|
|
72
|
-
* There is deliberately no prop to turn
|
|
78
|
+
* There is deliberately no prop to turn the scrim blocking off. A surface that can be escaped
|
|
73
79
|
* accidentally does not need to be an AlertDialog; it is a Dialog, and using this
|
|
74
80
|
* one would be a claim about the flow that is not true.
|
|
75
81
|
*/
|
|
76
82
|
const blockDismissal = {
|
|
77
|
-
onEscapeKeyDown: (event: KeyboardEvent) => event.preventDefault(),
|
|
78
83
|
onPointerDownOutside: (event: Event) => event.preventDefault(),
|
|
79
84
|
onFocusOutside: (event: Event) => event.preventDefault(),
|
|
80
85
|
onInteractOutside: (event: Event) => event.preventDefault(),
|