@kolkrabbi/kol-component 0.247.0 → 0.249.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.247.0",
3
+ "version": "0.249.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
2
2
  import { createPortal } from 'react-dom'
3
3
  import Button from '../atoms/Button.jsx'
4
4
  import Input from '../atoms/Input.jsx'
5
+ import ToggleCheckbox from '../atoms/ToggleCheckbox.jsx'
5
6
 
6
7
  /**
7
8
  * Modal — promise-based prompt + confirm + alert dialogs.
@@ -21,10 +22,26 @@ import Input from '../atoms/Input.jsx'
21
22
  * Returned promise resolves to:
22
23
  * - prompt → string (value) on submit, `null` on cancel
23
24
  * - confirm → boolean: `true` on confirm, `false` on cancel
25
+ * - confirm with `options` → `{ ok, values: { [id]: boolean } }` (below)
24
26
  * - alert → undefined, once dismissed. One button; `okLabel` names it.
25
27
  * Added 2026-09-22 (media-pages-route-dialogs-through-usemodal) so an
26
28
  * error report needs no native `alert()` beside DS prompts.
27
29
  *
30
+ * TOGGLES (upload-dialog-optimise-and-keep-originals, kol-website 2026-10-09): `confirm` takes
31
+ * `options: [{ id, label, hint?, defaultValue? }]`, drawn as `ToggleCheckbox` rows between the
32
+ * title and the buttons, so one dialog asks a question and its settings:
33
+ *
34
+ * const { ok, values } = await confirm('Web-optimise raster images?', {
35
+ * okLabel: 'Upload',
36
+ * options: [
37
+ * { id: 'optimise', label: 'Optimise', hint: '≤2560 px, ≤500 KB', defaultValue: true },
38
+ * { id: 'keepOriginals', label: 'Keep originals', hint: 'original/<name>', defaultValue: true },
39
+ * ],
40
+ * })
41
+ *
42
+ * `values` comes back on cancel too (the toggles as left), so a consumer can remember them either
43
+ * way. Without `options`, confirm still resolves a plain boolean — existing callers untouched.
44
+ *
28
45
  * Mounted once at the app root (BrandLayout). Renders into `document.body`
29
46
  * via portal, so it floats above any rail / scroll-context.
30
47
  */
@@ -45,8 +62,8 @@ export function ModalProvider({ children }) {
45
62
  new Promise((resolve) => setState({ kind: 'prompt', title, defaultValue, okLabel, cancelLabel, resolve })),
46
63
  [])
47
64
 
48
- const confirm = useCallback((title, { okLabel, cancelLabel } = {}) =>
49
- new Promise((resolve) => setState({ kind: 'confirm', title, okLabel, cancelLabel, resolve })),
65
+ const confirm = useCallback((title, { okLabel, cancelLabel, options } = {}) =>
66
+ new Promise((resolve) => setState({ kind: 'confirm', title, okLabel, cancelLabel, options: options?.length ? options : null, resolve })),
50
67
  [])
51
68
 
52
69
  const alert = useCallback((title, { okLabel } = {}) =>
@@ -66,11 +83,14 @@ export function ModalProvider({ children }) {
66
83
 
67
84
  function ModalView({ state, closeWith }) {
68
85
  const [val, setVal] = useState(state.defaultValue ?? '')
86
+ const [values, setValues] = useState(() => Object.fromEntries((state.options ?? []).map((o) => [o.id, !!o.defaultValue])))
69
87
  const inputRef = useRef(null)
70
88
 
71
- /* alert resolves `undefined` whichever way it is dismissed */
72
- const submit = () => closeWith(state.kind === 'prompt' ? val : state.kind === 'alert' ? undefined : true)
73
- const cancel = () => closeWith(state.kind === 'prompt' ? null : state.kind === 'alert' ? undefined : false)
89
+ /* alert resolves `undefined` whichever way it is dismissed; a confirm with toggles resolves
90
+ * `{ ok, values }` */
91
+ const answer = (ok) => (state.options ? { ok, values } : ok)
92
+ const submit = () => closeWith(state.kind === 'prompt' ? val : state.kind === 'alert' ? undefined : answer(true))
93
+ const cancel = () => closeWith(state.kind === 'prompt' ? null : state.kind === 'alert' ? undefined : answer(false))
74
94
 
75
95
  useEffect(() => {
76
96
  if (state.kind === 'prompt') inputRef.current?.focus()
@@ -80,7 +100,7 @@ function ModalView({ state, closeWith }) {
80
100
  }
81
101
  window.addEventListener('keydown', onKey)
82
102
  return () => window.removeEventListener('keydown', onKey)
83
- }, [state.kind, val, closeWith]) // eslint-disable-line react-hooks/exhaustive-deps
103
+ }, [state.kind, val, values, closeWith]) // eslint-disable-line react-hooks/exhaustive-deps
84
104
 
85
105
  return (
86
106
  <div
@@ -118,6 +138,19 @@ function ModalView({ state, closeWith }) {
118
138
  className="w-full"
119
139
  />
120
140
  )}
141
+ {state.options && (
142
+ <div className="flex flex-col gap-3">
143
+ {state.options.map((o) => (
144
+ <ToggleCheckbox
145
+ key={o.id}
146
+ label={o.label}
147
+ hint={o.hint}
148
+ checked={values[o.id]}
149
+ onChange={(next) => setValues((v) => ({ ...v, [o.id]: next }))}
150
+ />
151
+ ))}
152
+ </div>
153
+ )}
121
154
  <div className="flex gap-2 justify-end">
122
155
  {state.kind !== 'alert' && <Button size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>}
123
156
  <Button tone="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
@@ -147,9 +180,10 @@ export function useModal() {
147
180
  const v = window.prompt(title, def)
148
181
  return v
149
182
  },
150
- confirm: async (title) => {
151
- if (typeof window === 'undefined') return false
152
- return window.confirm(title)
183
+ /* the fallback cannot draw toggles: options come back at their defaults */
184
+ confirm: async (title, { options } = {}) => {
185
+ const ok = typeof window !== 'undefined' && window.confirm(title)
186
+ return options?.length ? { ok, values: Object.fromEntries(options.map((o) => [o.id, !!o.defaultValue])) } : ok
153
187
  },
154
188
  alert: async (title) => {
155
189
  if (typeof window !== 'undefined') window.alert(title)
@@ -14,7 +14,7 @@ import ContentRow from '../molecules/ContentRow.jsx'
14
14
  import ContentFilters from './ContentFilters.jsx'
15
15
  import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
16
16
  import MediaViewer from './MediaViewer.jsx'
17
- import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
17
+ import { MediaLibraryBrowse, MediaLibraryLibrary, SETTINGS_BASE } from './MediaLibraryPages.jsx'
18
18
  import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
19
19
  import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
20
20
  import { filterMedia } from '../utilities/mediaSearch.js'
@@ -1021,11 +1021,16 @@ function PickerShell({ onClose, onPick }) {
1021
1021
  * `defaults` to seed) · `folderTree` (browse: the baked tree) · `headerActions`
1022
1022
  * (the app's own upload / write icons) · `refreshKey`.
1023
1023
  *
1024
- * @param {string} variant 'explorer' | 'browse' | 'library' | 'modal' | 'page' (alias of library)
1024
+ * @param {string} variant 'explorer' | 'browse' | 'library' | 'modal' | 'picker' | 'page' (alias of library)
1025
+ * `picker` — the modal card with the BROWSE surface inside it (columns · rows · grid, the
1026
+ * bucket dropdown, search), read-only, and a Use footer for the selected file. Same props
1027
+ * as `modal` (`open` · `client` · `accept` · `onClose` · `onSelect`); `modal` keeps its own
1028
+ * grid | list listing. Was apps/media's picker A (2026-10-08) — a variant, not a choice
1029
+ * (user 2026-10-09: "why is this even a choice? it should be a variant option").
1025
1030
  * `explorer` is browse + library as two views of ONE surface — one header, one
1026
1031
  * count, one listing, a switch beside the bucket dropdown. Prefer it for a new
1027
1032
  * consumer; the other two remain for the ones that stack them by hand.
1028
- * @param {boolean} open modal only — mounts the overlay
1033
+ * @param {boolean} open modal / picker only — mounts the overlay
1029
1034
  * @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
1030
1035
  * @param {string|string[]} accept 'all' (default) = everything · one kind · an
1031
1036
  * allow-list `['image','video']`
@@ -1052,6 +1057,7 @@ export default function MediaLibrary({
1052
1057
  if (!open) return null
1053
1058
  return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, opts)
1054
1059
  }
1060
+ if (variant === 'picker') return open ? <PickerBrowse client={client} accept={accept} onClose={onClose} onSelect={onSelect} {...pageProps} /> : null
1055
1061
  if (variant === 'browse') return <MediaLibraryBrowse client={client} {...pageProps} />
1056
1062
  /* `explorer` = the two above as TWO VIEWS OF ONE SURFACE, one mounted at a
1057
1063
  * time behind a switch in the header — one wordmark, one count, one listing,
@@ -1064,6 +1070,42 @@ export default function MediaLibrary({
1064
1070
  return <MediaLibraryLibrary client={client} {...pageProps} {...seed} />
1065
1071
  }
1066
1072
 
1073
+ /* THE PICKER — the browse surface in the modal card. Read-only (no verbs, no drop, no trash: a
1074
+ * picker picks). Its settings are held here and never saved, so a consumer's stored browse
1075
+ * settings are untouched. The views take the card's height less its chrome. */
1076
+ const PICKER_CHROME = 280 // ponytail: hand-measured header + count line + footer; tune if the chrome changes
1077
+ function PickerBrowse({ client, accept, onClose, onSelect, title = 'MEDIA', ...browseProps }) {
1078
+ const [settings, setSettings] = useState(() => ({ ...SETTINGS_BASE, columnHeight: `calc(var(--kol-media-picker-h) - ${PICKER_CHROME}px)` }))
1079
+ const [bucket, setBucket] = useState(browseProps.bucket)
1080
+ const [file, setFile] = useState(null)
1081
+ const ok = !!file && acceptsKind(accept)({ ...file, kind: kindOf(file) })
1082
+ const use = () => {
1083
+ onSelect?.(client.mediaUrl(file.key, bucket ?? undefined), { contentType: file.contentType, kind: kindOf(file) })
1084
+ onClose?.()
1085
+ }
1086
+ return (
1087
+ <FullscreenOverlay open scrim onClose={onClose}>
1088
+ <div className="kol-media-picker gap-4">
1089
+ <MediaLibraryBrowse
1090
+ {...browseProps}
1091
+ client={client}
1092
+ title={title}
1093
+ bucket={bucket}
1094
+ onBucketChange={(id) => { setBucket(id); browseProps.onBucketChange?.(id) }}
1095
+ settings={settings}
1096
+ onSettingsChange={setSettings}
1097
+ onPickFile={setFile}
1098
+ />
1099
+ <div className="mt-auto flex items-center justify-end gap-4">
1100
+ <span className="kol-mono-12 text-fg-48 truncate mr-auto">{file ? file.key : 'Select a file'}</span>
1101
+ <Button tone="grey" size="md" onClick={onClose}>Cancel</Button>
1102
+ <Button size="md" disabled={!ok} onClick={use}>Use</Button>
1103
+ </div>
1104
+ </div>
1105
+ </FullscreenOverlay>
1106
+ )
1107
+ }
1108
+
1067
1109
  /** @deprecated 2026-08-01 — alias of `MediaLibrary variant="modal"`. Kept so
1068
1110
  * existing call sites and the fxr editor's `onPick` naming keep working;
1069
1111
  * drops when nobody imports it (04-retirements.md). */