@kolkrabbi/kol-component 0.216.0 → 0.218.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import LabeledControl from '../molecules/LabeledControl.jsx'
6
6
  import ToggleSwitch from '../atoms/ToggleSwitch.jsx'
7
7
  import ShellDrawer from '../molecules/ShellDrawer.jsx'
8
8
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
9
+ import { Tooltip } from '../utilities/Popover.jsx'
9
10
 
10
11
  /**
11
12
  * SettingsPanel — a settings surface for the thing you are looking at: you
@@ -110,7 +111,8 @@ export default function SettingsPanel({
110
111
  * uppercase label (`kol-helper-10` tracked, meta ink) in a 160px column, the
111
112
  * control fills the rest. A switch sits at the far right (`align="end"`, the
112
113
  * default); a dropdown fills the row (`align="fill"`). No hint sentences on the
113
- * page — `hint` rides the control's `title`. `labelWidth="auto"` passes through
114
+ * page — `hint` rides the control's DS `Tooltip` (a native `title` until
115
+ * 2026-09-22, kol-client-olina). `labelWidth="auto"` passes through
114
116
  * to LabeledControl: the label flexes and truncates, the control hugs — for a
115
117
  * row living in a column narrower than the 160px label default
116
118
  * (SettingsShortcutsComboOverflow, kol-monitor 2026-09-01).
@@ -118,24 +120,28 @@ export default function SettingsPanel({
118
120
  export function SettingsRow({ label, hint, align = 'end', labelWidth = 160, children }) {
119
121
  return (
120
122
  <LabeledControl inline label={typeof label === 'string' ? label.toUpperCase() : label} labelWidth={labelWidth}>
121
- <span title={hint} className={`inline-flex w-full ${align === 'fill' ? '' : 'justify-end'}`.trim()}>{children}</span>
123
+ {hint
124
+ ? <Tooltip label={hint} triggerClassName={`inline-flex w-full ${align === 'fill' ? '' : 'justify-end'}`.trim()}>{children}</Tooltip>
125
+ : <span className={`inline-flex w-full ${align === 'fill' ? '' : 'justify-end'}`.trim()}>{children}</span>}
122
126
  </LabeledControl>
123
127
  )
124
128
  }
125
129
 
126
130
  /** SettingsSwitch — the row's on/off control: the DS ToggleSwitch, bare, sm.
127
- * `disabledHint` rides `title` so a switch that cannot act says why. */
131
+ * `disabledHint` rides a DS `Tooltip` so a switch that cannot act says why;
132
+ * inside a row's `hint` it wins while hovered, as the native `title` did. */
128
133
  export function SettingsSwitch({ on = false, onChange, disabled = false, disabledHint, label, title }) {
129
- return (
134
+ const tip = title ?? (disabled ? disabledHint : undefined)
135
+ const toggle = (
130
136
  <ToggleSwitch
131
137
  size="sm"
132
138
  checked={on}
133
139
  onChange={onChange}
134
140
  disabled={disabled}
135
- title={title ?? (disabled ? disabledHint : undefined)}
136
141
  aria-label={label}
137
142
  />
138
143
  )
144
+ return tip ? <Tooltip label={tip}>{toggle}</Tooltip> : toggle
139
145
  }
140
146
 
141
147
  /** LabeledControlSection — a section of LabeledControls: the EYEBROW
@@ -253,7 +259,11 @@ export function SettingsFooter({ onReset, resetLabel = 'Reset to defaults', chil
253
259
  <Divider />
254
260
  <div className="flex justify-end items-center gap-2">
255
261
  {children}
256
- {onReset && <IconFrame name="refresh" variant="primary" size="sm" onClick={onReset} title={resetLabel} aria-label={resetLabel} />}
262
+ {onReset && (
263
+ <Tooltip label={resetLabel}>
264
+ <IconFrame name="refresh" variant="primary" size="sm" onClick={onReset} aria-label={resetLabel} />
265
+ </Tooltip>
266
+ )}
257
267
  </div>
258
268
  </div>
259
269
  )
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef } from 'react'
2
+ import { pushLayer, popLayer, isTopLayer } from './layerStack.js'
2
3
  import CloseButton from './CloseButton.jsx'
3
4
 
4
5
  /**
@@ -36,6 +37,11 @@ export default function FullscreenOverlay({
36
37
  * caller should have to know to put focus in it. A plain `<div ref>` always
37
38
  * works. */
38
39
  initialFocus,
40
+ /* A DIM BACKDROP INSTEAD OF THE SURFACE (user 2026-09-23, on Quick Look: *"its overlay, not a
41
+ * black background … just dim the background slightly"*). The flat surface is still the default
42
+ * — that is the 2026-08-27 ruling, made because a wash read as a halo around a lightboxed image
43
+ * — so this is opt-in per overlay rather than a reversal for every consumer of the component. */
44
+ scrim = false,
39
45
  children,
40
46
  }) {
41
47
  const sheetRef = useRef(null)
@@ -44,10 +50,17 @@ export default function FullscreenOverlay({
44
50
  * the same trap ShellDrawer carries; before this a Tab from the overlay
45
51
  * walked into the page underneath). Focus moves into the sheet on open and
46
52
  * back to the opener on close. */
53
+ /* `onClose` through a ref: an inline handler is a new function every render, and with it in the
54
+ * deps the effect re-ran on every render — re-pushing this layer to the TOP of the stack and
55
+ * re-stealing focus while a sheet above it was open. */
56
+ const onCloseRef = useRef(onClose)
57
+ onCloseRef.current = onClose
47
58
  useEffect(() => {
48
59
  if (!open) return
60
+ const layer = pushLayer()
49
61
  const onKey = (e) => {
50
- if (e.key === 'Escape') { onClose?.(); return }
62
+ if (!isTopLayer(layer)) return
63
+ if (e.key === 'Escape') { onCloseRef.current?.(); return }
51
64
  if (e.key !== 'Tab') return
52
65
  const sheet = sheetRef.current
53
66
  if (!sheet) return
@@ -72,11 +85,12 @@ export default function FullscreenOverlay({
72
85
  : sheetRef.current
73
86
  target?.focus?.()
74
87
  return () => {
88
+ popLayer(layer)
75
89
  document.removeEventListener('keydown', onKey)
76
90
  document.body.style.overflow = prev
77
91
  if (prevFocus instanceof HTMLElement) prevFocus.focus()
78
92
  }
79
- }, [open, onClose, initialFocus])
93
+ }, [open, initialFocus])
80
94
 
81
95
  if (!open) return null
82
96
 
@@ -90,7 +104,7 @@ export default function FullscreenOverlay({
90
104
  }
91
105
 
92
106
  return (
93
- <div className="kol-overlay" role="dialog" aria-modal="true" onMouseDown={onBackdropClick}>
107
+ <div className={`kol-overlay${scrim ? ' kol-overlay-scrim' : ''}`} role="dialog" aria-modal="true" onMouseDown={onBackdropClick}>
94
108
  <div ref={sheetRef} tabIndex={-1} className="kol-overlay-sheet outline-none">
95
109
  {closeButton && (
96
110
  /* ONE close idiom (FullscreenOverlayCloseIdiom, kol-chess 2026-09-01,
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
1
+ import { createContext, useContext, useState } from 'react'
2
2
  import { toneClass } from './tone.js'
3
3
  import {
4
4
  useFloating,
@@ -131,6 +131,11 @@ export function usePopover({
131
131
  * <Button iconOnly="ptrn-checker" ... />
132
132
  * </Tooltip>
133
133
  */
134
+ /* A tooltip inside a tooltip's trigger (a disabled switch's reason inside its
135
+ * row's hint) hides the outer one while it is open, and hands it back on leave
136
+ * — the innermost wins, as the native `title` it replaced behaved. */
137
+ const TooltipNest = createContext(null)
138
+
134
139
  export function Tooltip({
135
140
  label,
136
141
  shortcut,
@@ -140,9 +145,11 @@ export function Tooltip({
140
145
  triggerClassName = 'inline-flex',
141
146
  }) {
142
147
  const [open, setOpen] = useState(false)
148
+ const [covered, setCovered] = useState(false)
149
+ const parent = useContext(TooltipNest)
143
150
  const popover = usePopover({
144
- open,
145
- onOpenChange: setOpen,
151
+ open: open && !covered,
152
+ onOpenChange: (v) => { setOpen(v); parent?.(v) },
146
153
  placement,
147
154
  offset,
148
155
  role: 'tooltip',
@@ -158,7 +165,7 @@ export function Tooltip({
158
165
  {...popover.getReferenceProps()}
159
166
  className={triggerClassName}
160
167
  >
161
- {children}
168
+ <TooltipNest.Provider value={setCovered}>{children}</TooltipNest.Provider>
162
169
  </span>
163
170
  <PopoverPanel
164
171
  popover={popover}
@@ -43,6 +43,9 @@ function apic(tag, version) {
43
43
  }
44
44
 
45
45
  export function readCover(url) {
46
+ /* only a URL a browser can fetch — a placeholder scheme (a fixture, an unresolved key) is "no
47
+ * cover", not a console error */
48
+ if (!/^(https?:|data:|blob:|\/)/.test(String(url ?? ''))) return Promise.resolve(null)
46
49
  if (!covers.has(url)) {
47
50
  covers.set(url, (async () => {
48
51
  try {
@@ -0,0 +1,18 @@
1
+ /* THE LAYER STACK — which open overlay owns the keyboard (2026-09-23). Every overlay and drawer
2
+ * listened for Escape and Tab on `document`, so with a sheet open over a drawer one Escape closed
3
+ * BOTH, and two focus traps fought over Tab. Each layer pushes itself on open and pops on close;
4
+ * only the top one answers. Order is open order, which is stacking order. */
5
+ const stack = []
6
+
7
+ export function pushLayer() {
8
+ const id = {}
9
+ stack.push(id)
10
+ return id
11
+ }
12
+
13
+ export function popLayer(id) {
14
+ const i = stack.indexOf(id)
15
+ if (i !== -1) stack.splice(i, 1)
16
+ }
17
+
18
+ export const isTopLayer = (id) => stack[stack.length - 1] === id
@@ -11,6 +11,9 @@ const EXT_KINDS = {
11
11
  js: 'code', mjs: 'code', cjs: 'code', ts: 'code', jsx: 'code', tsx: 'code',
12
12
  css: 'code', html: 'code', sh: 'code', py: 'code',
13
13
  m3u8: 'playlist',
14
+ /* A PDF IS ITS OWN KIND (user 2026-09-23: *"File I guess is pdf? weird name"*). It fell through
15
+ * to `other`, so the one document type with a real page-one preview was labelled "File". */
16
+ pdf: 'pdf',
14
17
  woff: 'font', woff2: 'font', ttf: 'font', otf: 'font',
15
18
  zip: 'archive', gz: 'archive', tar: 'archive', rar: 'archive', '7z': 'archive',
16
19
  }
@@ -42,12 +45,15 @@ export function kindOf(o) {
42
45
  return 'other'
43
46
  }
44
47
 
45
- export const KINDS = ['audio', 'video', 'image', 'markdown', 'json', 'yaml', 'text', 'code', 'playlist', 'font', 'archive', 'other']
48
+ export const KINDS = ['audio', 'video', 'image', 'pdf', 'markdown', 'json', 'yaml', 'text', 'code', 'playlist', 'font', 'archive', 'other']
46
49
 
50
+ /* A LABEL IS CAPITALISED, AN ACRONYM IS AN ACRONYM (user 2026-09-22). These read as values in a
51
+ * facts table ("Kind · Markdown") and beside them sit the MIME type and the filename, which are
52
+ * DATA and stay exactly as stored. The filter chips are unaffected — `.kol-tag` uppercases. */
47
53
  export const KIND_LABEL = {
48
- image: 'image', video: 'video', audio: 'audio', markdown: 'markdown', json: 'JSON', yaml: 'YAML', text: 'text', code: 'code',
49
- playlist: 'HLS', font: 'font', archive: 'archive', segments: 'HLS segments',
50
- system: 'system', other: 'file',
54
+ image: 'Image', video: 'Video', audio: 'Audio', pdf: 'PDF', markdown: 'Markdown', json: 'JSON', yaml: 'YAML', text: 'Text', code: 'Code',
55
+ playlist: 'HLS', font: 'Font', archive: 'Archive', segments: 'HLS segments',
56
+ system: 'System', other: 'File',
51
57
  }
52
58
 
53
59
  /* The chips a bucket always shows (user ruling 2026-08-27): media, then the text